feat(security): complete capability messaging workflows

This commit is contained in:
2026-07-29 11:21:15 +08:00
parent df88fa19cb
commit 5c4de4b282
35 changed files with 19544 additions and 89 deletions

View File

@@ -15,9 +15,9 @@ namespace Tiku.Infrastructure.Jobs;
internal sealed class BackgroundJobService(
TikuDbContext dbContext,
IServiceProvider serviceProvider,
ITenantExecutionScope tenantExecutionScope,
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
ICapabilityAccessEvaluator capabilityAccessEvaluator,
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
@@ -43,6 +43,15 @@ internal sealed class BackgroundJobService(
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
};
dbContext.BackgroundJobs.Add(job);
if (job.RunAfter is null && backgroundJobDispatcher.IsEnabled)
{
await backgroundJobDispatcher.DispatchAsync(
job.Id,
job.TenantId,
job.JobType,
job.Id.ToString("N"),
cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(job);
}
@@ -50,12 +59,14 @@ internal sealed class BackgroundJobService(
public async Task<int> ProcessPendingAsync(
string workerId,
int batchSize,
bool includeImmediateJobs = true,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var jobs = await dbContext.BackgroundJobs
.Where(job =>
job.Status == BackgroundJobStatus.Pending &&
(includeImmediateJobs || job.RunAfter != null) &&
(job.RunAfter == null || job.RunAfter <= now))
.OrderBy(job => job.CreatedAt)
.Take(Math.Clamp(batchSize, 1, 100))
@@ -65,59 +76,102 @@ internal sealed class BackgroundJobService(
foreach (var job in jobs)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await capabilityAccessEvaluator.IsAllowedAsync(
job.TenantId,
ResolveCapabilityModule(job.JobType),
CapabilityOperation.Write,
cancellationToken))
{
job.Status = BackgroundJobStatus.Failed;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = "Tenant capability was revoked before job execution.";
await dbContext.SaveChangesAsync(cancellationToken);
processed++;
continue;
}
job.Status = BackgroundJobStatus.Processing;
job.LockedBy = workerId;
job.LockExpiresAt = now.Add(LeaseDuration);
job.StartedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
try
{
var result = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
job.TenantId, SystemScopeCallerType.Worker, workerId,
$"Background job {job.JobType}", job.Id.ToString("N")),
(provider, token) => ProcessCoreAsync(provider, job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = null;
job.Result = result;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
job.RetryCount++;
job.LastError = exception.Message;
job.Status = job.RetryCount > job.MaxRetries
? BackgroundJobStatus.Failed
: BackgroundJobStatus.Pending;
job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount));
}
finally
{
job.LockedBy = null;
job.LockExpiresAt = null;
processed++;
await dbContext.SaveChangesAsync(cancellationToken);
}
if (await ProcessJobAsync(job, workerId, cancellationToken)) processed++;
}
return processed;
}
public async Task<bool> ProcessRequestedAsync(
Guid jobId,
Guid tenantId,
string jobType,
string workerId,
CancellationToken cancellationToken = default)
{
var normalizedJobType = NormalizeJobType(jobType);
var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync(
item => item.Id == jobId && item.TenantId == tenantId,
cancellationToken);
if (job is null)
{
throw new InvalidOperationException("The requested background job does not exist in the target tenant.");
}
if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal))
{
throw new InvalidOperationException("The requested background job type does not match the persisted job.");
}
if (job.Status != BackgroundJobStatus.Pending || job.RunAfter is not null)
{
return false;
}
return await ProcessJobAsync(job, workerId, cancellationToken);
}
private async Task<bool> ProcessJobAsync(
BackgroundJob job,
string workerId,
CancellationToken cancellationToken)
{
if (job.Status != BackgroundJobStatus.Pending)
{
return false;
}
if (!await capabilityAccessEvaluator.IsAllowedAsync(
job.TenantId,
ResolveCapabilityModule(job.JobType),
CapabilityOperation.Write,
cancellationToken))
{
job.Status = BackgroundJobStatus.Failed;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = "Tenant capability was revoked before job execution.";
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
var now = DateTimeOffset.UtcNow;
job.Status = BackgroundJobStatus.Processing;
job.LockedBy = workerId;
job.LockExpiresAt = now.Add(LeaseDuration);
job.StartedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
try
{
var result = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
job.TenantId, SystemScopeCallerType.Worker, workerId,
$"Background job {job.JobType}", job.Id.ToString("N")),
(provider, token) => ProcessCoreAsync(provider, job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = null;
job.Result = result;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
job.RetryCount++;
job.LastError = exception.Message;
job.Status = job.RetryCount > job.MaxRetries
? BackgroundJobStatus.Failed
: BackgroundJobStatus.Pending;
job.RunAfter = job.Status == BackgroundJobStatus.Pending
? DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount))
: null;
}
finally
{
job.LockedBy = null;
job.LockExpiresAt = null;
await dbContext.SaveChangesAsync(cancellationToken);
}
return true;
}
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
Guid tenantId,
string? jobType = null,
@@ -145,14 +199,15 @@ internal sealed class BackgroundJobService(
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var scopedDbContext = scopedProvider.GetRequiredService<TikuDbContext>();
return job.JobType switch
{
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedDbContext, job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
};
}
@@ -199,12 +254,13 @@ internal sealed class BackgroundJobService(
}
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 dbContext.ContentAssets.SingleOrDefaultAsync(
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
cancellationToken);
if (asset is null)
@@ -219,12 +275,12 @@ internal sealed class BackgroundJobService(
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
Source = "background_job"
};
dbContext.ContentAssets.Add(asset);
scopedDbContext.ContentAssets.Add(asset);
}
var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var studentCount = await dbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
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}.";
@@ -239,7 +295,7 @@ internal sealed class BackgroundJobService(
studentCount,
payload = job.Payload
});
await dbContext.SaveChangesAsync(cancellationToken);
await scopedDbContext.SaveChangesAsync(cancellationToken);
job.OutputAssetId = asset.Id;
return JsonSerializer.SerializeToElement(new
{
@@ -252,11 +308,12 @@ internal sealed class BackgroundJobService(
}
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync(
var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync(
item =>
item.TenantId == job.TenantId &&
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
@@ -271,7 +328,7 @@ internal sealed class BackgroundJobService(
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 dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
item =>
item.TenantId == job.TenantId &&
item.Provider == provider &&
@@ -296,8 +353,8 @@ internal sealed class BackgroundJobService(
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
})
};
dbContext.CommerceReconciliationBatches.Add(batch);
await dbContext.SaveChangesAsync(cancellationToken);
scopedDbContext.CommerceReconciliationBatches.Add(batch);
await scopedDbContext.SaveChangesAsync(cancellationToken);
}
return JsonSerializer.SerializeToElement(new
@@ -310,9 +367,11 @@ internal sealed class BackgroundJobService(
});
}
private async Task<JsonElement> ProcessTenantDomainRecheckAsync(CancellationToken cancellationToken)
private static async Task<JsonElement> ProcessTenantDomainRecheckAsync(
IServiceProvider scopedProvider,
CancellationToken cancellationToken)
{
var lifecycleService = serviceProvider.GetRequiredService<ITenantDomainLifecycleService>();
var lifecycleService = scopedProvider.GetRequiredService<ITenantDomainLifecycleService>();
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
return JsonSerializer.SerializeToElement(new
{
@@ -321,18 +380,19 @@ internal sealed class BackgroundJobService(
}
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
{
var since = DateTimeOffset.UtcNow.AddDays(-7);
var activeLearnerCount = await dbContext.PracticeSessions
var activeLearnerCount = await scopedDbContext.PracticeSessions
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
.Select(item => item.UserId)
.Distinct()
.CountAsync(cancellationToken);
var paidOrderCount = await dbContext.Orders
var paidOrderCount = await scopedDbContext.Orders
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
var revenueCents = await dbContext.Orders
var revenueCents = await scopedDbContext.Orders
.Where(item => item.TenantId == job.TenantId &&
(item.Status == OrderStatus.Paid ||
item.Status == OrderStatus.PartiallyRefunded ||