Files
tiku-backend.net/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

567 lines
26 KiB
C#

using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Assets;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Commerce;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Jobs;
internal sealed partial class BackgroundJobService
{
private async Task<JsonElement> ProcessCoreAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var scopedDbContext = scopedProvider.GetRequiredService<TikuDbContext>();
var handlers = new Dictionary<string, Func<Task<JsonElement>>>(StringComparer.Ordinal)
{
["content_export"] = () => ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
["content_import"] = () => ProcessContentImportAsync(scopedProvider, job, cancellationToken),
["asset_security_scan"] = () =>
ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken),
["tenant_export"] = () => ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken),
["statistics_aggregation"] = () =>
ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
["commerce_reconciliation"] =
() => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
["tenant_domain_recheck"] = () => ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken)
};
return handlers.TryGetValue(job.JobType, out var handler)
? await handler()
: throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.");
}
private static async Task<JsonElement> ProcessContentImportAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
var directContentService = scopedProvider.GetRequiredService<IDirectContentService>();
var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty;
if (createdBy == Guid.Empty) throw new InvalidOperationException("content_import job requires createdBy.");
var command = new SimpleImportCommand(
GetJsonString(job.Payload, "importType") ??
throw new InvalidOperationException("content_import job requires importType."),
GetJsonString(job.Payload, "sourceFormat"),
GetJsonString(job.Payload, "sourceName"),
GetJsonGuid(job.Payload, "regionId"),
GetJsonGuid(job.Payload, "entryId"),
GetJsonGuid(job.Payload, "contentNodeId"),
GetJsonGuid(job.Payload, "subjectId"),
GetJsonGuid(job.Payload, "categoryId"),
GetJsonGuid(job.Payload, "questionBankId"),
GetJsonGuid(job.Payload, "collectionId"),
GetJsonArray(job.Payload, "items"),
false);
var result = await directContentService.ExecuteImportAsync(
new DirectContentActor(job.TenantId, createdBy),
command,
cancellationToken);
return JsonSerializer.SerializeToElement(new
{
importJobId = result.Job.Id,
result.Job.ImportType,
result.Job.Status,
result.Job.TotalCount,
result.Job.InsertedCount,
result.Job.UpdatedCount,
result.Job.ErrorCount
});
}
private static async Task<JsonElement> ProcessAssetSecurityScanAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var assetId = GetJsonGuid(job.Payload, "assetId") ??
throw new InvalidOperationException("asset_security_scan job requires assetId.");
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.Id == assetId,
cancellationToken) ?? throw new InvalidOperationException("Asset security scan target was not found.");
if (asset.UploadStatus != AssetUploadStatus.Verified ||
string.IsNullOrWhiteSpace(asset.Bucket) ||
string.IsNullOrWhiteSpace(asset.ObjectKey))
throw new InvalidOperationException("Asset must have a verified object location before security scanning.");
asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning;
await scopedDbContext.SaveChangesAsync(cancellationToken);
var scanner = scopedProvider.GetRequiredService<IAssetSecurityScanner>();
var storage = scopedProvider.GetRequiredService<IObjectStorageService>();
await using var content = await storage.OpenReadAsync(
new ObjectStorageReadRequest(
job.TenantId,
asset.StorageProvider switch
{
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => throw new InvalidOperationException(
"Asset storage provider does not support security scanning.")
},
asset.Bucket,
asset.ObjectKey),
cancellationToken);
var result =
await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
var infected = result.Verdict == AssetSecurityScanVerdict.Infected;
asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed;
asset.SecurityScannedAt = DateTimeOffset.UtcNow;
asset.SecurityScanProvider = result.Provider;
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new
{
verdict = result.Verdict.ToString(),
result.Signature,
result.BytesScanned
});
scopedDbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
{
TenantId = job.TenantId,
AssetId = asset.Id,
Provider = result.Provider,
ScanStatus = asset.SecurityScanStatus,
RiskLevel = infected ? AssetSecurityRiskLevel.Critical : AssetSecurityRiskLevel.None,
IssueCodes = infected ? [result.Signature ?? "malware_detected"] : [],
Details = asset.SecurityScanSummary
});
await scopedDbContext.SaveChangesAsync(cancellationToken);
return JsonSerializer.SerializeToElement(new
{
assetId = asset.Id,
status = asset.SecurityScanStatus.ToString(),
result.Signature,
result.BytesScanned
});
}
private async Task RecordAssetScanRetryAsync(
BackgroundJob job,
AssetSecurityScannerException exception,
CancellationToken cancellationToken)
{
var assetId = GetJsonGuid(job.Payload, "assetId");
if (assetId is null) return;
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.Id == assetId,
cancellationToken);
if (asset is null) return;
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
asset.SecurityScanProvider = "clamav";
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { errorCode = exception.Code });
dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
{
TenantId = job.TenantId,
AssetId = asset.Id,
Provider = "clamav",
ScanStatus = AssetSecurityScanStatus.Pending,
RiskLevel = AssetSecurityRiskLevel.None,
IssueCodes = [exception.Code],
Details = asset.SecurityScanSummary
});
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task<JsonElement> ProcessTenantExportAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var operationId = GetJsonGuid(job.Payload, "operationId") ??
throw new InvalidOperationException("tenant_export job requires operationId.");
var operation = await scopedDbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item =>
item.TenantId == job.TenantId && item.Id == operationId &&
item.OperationType == TenantLifecycleOperationType.Export,
cancellationToken) ?? throw new InvalidOperationException("Tenant export operation was not found.");
operation.Status = TenantLifecycleOperationStatus.Processing;
operation.StartedAt ??= DateTimeOffset.UtcNow;
operation.LastError = null;
await scopedDbContext.SaveChangesAsync(cancellationToken);
var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz");
try
{
var tenant = await scopedDbContext.Tenants.AsNoTracking()
.SingleAsync(item => item.Id == job.TenantId, cancellationToken);
var memberships = await scopedDbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == job.TenantId)
.Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt })
.ToArrayAsync(cancellationToken);
var domains = await scopedDbContext.TenantDomains.AsNoTracking()
.Where(item => item.TenantId == job.TenantId)
.Select(item => new
{
item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt
})
.ToArrayAsync(cancellationToken);
var assets = await scopedDbContext.ContentAssets.AsNoTracking()
.Where(item => item.TenantId == job.TenantId && item.Status == ContentStatus.Active)
.ToArrayAsync(cancellationToken);
var storage = scopedProvider.GetRequiredService<IObjectStorageService>();
await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
128 * 1024, FileOptions.Asynchronous))
await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, false))
await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, false))
{
await WriteJsonEntryAsync(archive, "manifest.json", new
{
format = "tiku-tenant-export",
version = 1,
tenantId = job.TenantId,
operationId,
generatedAt = DateTimeOffset.UtcNow,
exclusions = new[]
{
"password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys",
"global_platform_data"
},
tables = new[] { "tenant", "tenant_memberships", "tenant_domains", "content_assets" }
}, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant.jsonl", new[]
{
new
{
tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode,
tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt
}
}, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships,
cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/tenant_domains.jsonl", domains, cancellationToken);
await WriteJsonLinesEntryAsync(archive, "data/content_assets.jsonl", assets.Select(item => new
{
item.Id,
item.AssetKey,
item.Title,
item.FileName,
item.StorageProvider,
item.Bucket,
item.ObjectKey,
item.MimeType,
item.FileSizeBytes,
item.ChecksumSha256,
item.UploadStatus,
item.SecurityScanStatus,
item.CreatedAt,
item.UpdatedAt
}), cancellationToken);
foreach (var asset in assets.Where(item =>
item.UploadStatus == AssetUploadStatus.Verified &&
item.SecurityScanStatus is AssetSecurityScanStatus.Passed
or AssetSecurityScanStatus.NotRequired &&
!string.IsNullOrWhiteSpace(item.Bucket) &&
!string.IsNullOrWhiteSpace(item.ObjectKey)))
{
var provider = asset.StorageProvider switch
{
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => null
};
if (provider is null) continue;
await using var content = await storage.OpenReadAsync(
new ObjectStorageReadRequest(
job.TenantId, provider, asset.Bucket!, asset.ObjectKey!),
cancellationToken);
var name = SanitizeTarPath(asset.FileName ?? asset.Id.ToString("N"));
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, $"assets/{asset.Id:N}/{name}")
{
DataStream = content
});
}
}
await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read,
128 * 1024, FileOptions.Asynchronous);
var providerName = storage.ConfiguredDefaultProvider();
var bucket = storage.ConfiguredDefaultBucket();
var objectKey =
storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz");
var written = await storage.WriteObjectAsync(
new ObjectStorageWriteRequest(
job.TenantId,
providerName,
bucket,
objectKey,
"application/gzip",
upload,
upload.Length,
Upsert: false),
cancellationToken);
var exportAsset = new ContentAsset
{
TenantId = job.TenantId,
Title = "Tenant export archive",
FileName = $"tenant-export-{operation.Id:N}.tar.gz",
AssetType = ContentAssetType.Document,
StorageProvider = providerName switch
{
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
_ => AssetStorageProvider.ExternalUrl
},
Bucket = written.Bucket,
ObjectKey = written.ObjectKey,
MimeType = "application/gzip",
FileSizeBytes = written.SizeBytes,
ChecksumSha256 = written.ChecksumSha256,
UploadStatus = AssetUploadStatus.Verified,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedSizeBytes = written.SizeBytes,
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
Source = "tenant_export"
};
scopedDbContext.ContentAssets.Add(exportAsset);
operation.ExportAssetId = exportAsset.Id;
operation.Status = TenantLifecycleOperationStatus.Succeeded;
operation.CompletedAt = DateTimeOffset.UtcNow;
operation.Result = JsonSerializer.SerializeToElement(new
{
exportAssetId = exportAsset.Id,
written.SizeBytes,
assetCount = assets.Length
});
await scopedDbContext.SaveChangesAsync(cancellationToken);
job.OutputAssetId = exportAsset.Id;
return operation.Result;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
operation.Status = TenantLifecycleOperationStatus.Failed;
operation.LastError = exception.Message;
operation.CompletedAt = DateTimeOffset.UtcNow;
await scopedDbContext.SaveChangesAsync(cancellationToken);
throw;
}
finally
{
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
}
}
private static async Task WriteJsonEntryAsync<T>(
TarWriter archive,
string name,
T value,
CancellationToken cancellationToken)
{
var stream = new MemoryStream();
await JsonSerializer.SerializeAsync(stream, value, cancellationToken: cancellationToken);
stream.Position = 0;
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
await stream.DisposeAsync();
}
private static async Task WriteJsonLinesEntryAsync<T>(
TarWriter archive,
string name,
IEnumerable<T> values,
CancellationToken cancellationToken)
{
var stream = new MemoryStream();
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true))
{
foreach (var value in values)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteLineAsync(JsonSerializer.Serialize(value));
}
}
stream.Position = 0;
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
await stream.DisposeAsync();
}
private static string SanitizeTarPath(string value)
{
var name = Path.GetFileName(value).Replace('\\', '_').Replace('/', '_');
return string.IsNullOrWhiteSpace(name) ? "asset.bin" : name;
}
private async Task<JsonElement> ProcessContentExportAsync(
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
var assetKey = $"background-jobs/{job.Id:N}/content-export.json";
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
cancellationToken);
if (asset is null)
{
asset = new ContentAsset
{
TenantId = job.TenantId,
AssetKey = assetKey,
AssetType = ContentAssetType.Document,
StorageProvider = AssetStorageProvider.ExternalUrl,
UploadStatus = AssetUploadStatus.Verified,
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
Source = "background_job"
};
scopedDbContext.ContentAssets.Add(asset);
}
var questionBankCount =
await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var questionCount =
await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var studentCount =
await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
asset.Title = "Content export manifest";
asset.Description = $"Generated content export manifest for {exportType}.";
asset.ObjectKey = assetKey;
asset.MimeType = "application/json";
asset.Metadata = JsonSerializer.SerializeToElement(new
{
exportType,
generatedAt = DateTimeOffset.UtcNow,
questionBankCount,
questionCount,
studentCount,
payload = job.Payload
});
await scopedDbContext.SaveChangesAsync(cancellationToken);
job.OutputAssetId = asset.Id;
return JsonSerializer.SerializeToElement(new
{
outputAssetId = asset.Id,
asset.AssetKey,
questionBankCount,
questionCount,
studentCount
});
}
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync(
item =>
item.TenantId == job.TenantId &&
item.Capability == TenantExternalProviderCapability.Payment &&
item.Provider == provider &&
item.Status == TenantExternalProviderStatus.Active,
cancellationToken);
if (!hasProviderConfig)
throw new InvalidOperationException(
$"Active payment provider '{provider}' is required for commerce reconciliation job.");
var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
var sourceHash = $"background-job:{job.Id:N}";
var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
item =>
item.TenantId == job.TenantId &&
item.Provider == provider &&
item.Source == ReconciliationSource.ProviderDownload &&
item.SourceHash == sourceHash,
cancellationToken);
if (batch is null)
{
batch = new CommerceReconciliationBatch
{
TenantId = job.TenantId,
Provider = provider,
BillDate = billDate,
BillType = billType,
Source = ReconciliationSource.ProviderDownload,
SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}",
SourceHash = sourceHash,
Status = ReconciliationBatchStatus.Pending,
Metadata = JsonSerializer.SerializeToElement(new
{
jobId = job.Id,
note =
"Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
})
};
scopedDbContext.CommerceReconciliationBatches.Add(batch);
await scopedDbContext.SaveChangesAsync(cancellationToken);
}
return JsonSerializer.SerializeToElement(new
{
batchId = batch.Id,
provider,
billDate,
billType = billType.ToString(),
status = batch.Status.ToString()
});
}
private static async Task<JsonElement> ProcessTenantDomainRecheckAsync(
IServiceProvider scopedProvider,
CancellationToken cancellationToken)
{
var lifecycleService = scopedProvider.GetRequiredService<ITenantDomainLifecycleService>();
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
return JsonSerializer.SerializeToElement(new
{
processed
});
}
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var since = DateTimeOffset.UtcNow.AddDays(-7);
var activeLearnerCount = await scopedDbContext.PracticeSessions
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
.Select(item => item.UserId)
.Distinct()
.CountAsync(cancellationToken);
var paidOrderCount = await scopedDbContext.Orders
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
var revenueCents = await scopedDbContext.Orders
.Where(item => item.TenantId == job.TenantId &&
(item.Status == OrderStatus.Paid ||
item.Status == OrderStatus.PartiallyRefunded ||
item.Status == OrderStatus.Refunded))
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
var quotaUsage = await scopedProvider.GetRequiredService<IFeatureUsageReconciliationService>()
.ReconcileTenantAsync(
new ReconcileFeatureUsageRequest(
job.TenantId,
SystemScopeCallerType.Worker,
nameof(BackgroundJobService),
"Reconcile tenant current feature usage during statistics aggregation",
$"feature-usage-{job.Id:N}"),
cancellationToken);
return JsonSerializer.SerializeToElement(new
{
since,
activeLearnerCount,
paidOrderCount,
revenueCents,
quotaUsage
});
}
}