feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -16,7 +16,7 @@ namespace Tiku.Infrastructure.Jobs;
internal sealed class BackgroundJobService(
TikuDbContext dbContext,
ITenantExecutionScope tenantExecutionScope,
ICapabilityAccessEvaluator capabilityAccessEvaluator,
IFeatureAccessService featureAccessService,
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
@@ -26,13 +26,29 @@ internal sealed class BackgroundJobService(
CancellationToken cancellationToken = default)
{
var normalizedJobType = NormalizeJobType(command.JobType);
if (!await capabilityAccessEvaluator.IsAllowedAsync(
if (!(await featureAccessService.EvaluateAsync(
command.TenantId,
ResolveCapabilityModule(normalizedJobType),
CapabilityOperation.Write,
cancellationToken))
ResolveRequiredFeature(normalizedJobType, command.Payload),
FeatureAccessOperation.Write,
cancellationToken)).Allowed)
{
throw new InvalidOperationException("Tenant capability does not allow this background job.");
throw new InvalidOperationException("Tenant feature entitlement does not allow this background job.");
}
var quotaMetric = ResolveQuotaMetric(normalizedJobType);
var quotaConsumed = false;
if (quotaMetric is not null)
{
var quota = (await featureAccessService.GetQuotaSummaryAsync(command.TenantId, cancellationToken))
.SingleOrDefault(value => value.MetricCode == quotaMetric);
if (quota is not null)
{
quotaConsumed = await featureAccessService.TryConsumeQuotaAsync(
command.TenantId, quotaMetric, 1, cancellationToken);
if (!quotaConsumed)
{
throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted");
}
}
}
var job = new BackgroundJob
{
@@ -43,6 +59,18 @@ internal sealed class BackgroundJobService(
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
};
dbContext.BackgroundJobs.Add(job);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
if (quotaConsumed && quotaMetric is not null)
{
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
}
throw;
}
if (job.RunAfter is null && backgroundJobDispatcher.IsEnabled)
{
await backgroundJobDispatcher.DispatchAsync(
@@ -52,10 +80,16 @@ internal sealed class BackgroundJobService(
job.Id.ToString("N"),
cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(job);
}
private static string? ResolveQuotaMetric(string jobType) => jobType switch
{
"content_import" => SaasQuotaMetricCatalog.ImportCount,
"content_export" => SaasQuotaMetricCatalog.ExportCount,
_ => null
};
public async Task<int> ProcessPendingAsync(
string workerId,
int batchSize,
@@ -118,15 +152,15 @@ internal sealed class BackgroundJobService(
{
return false;
}
if (!await capabilityAccessEvaluator.IsAllowedAsync(
if (!(await featureAccessService.EvaluateAsync(
job.TenantId,
ResolveCapabilityModule(job.JobType),
CapabilityOperation.Write,
cancellationToken))
ResolveRequiredFeature(job.JobType, job.Payload),
FeatureAccessOperation.Write,
cancellationToken)).Allowed)
{
job.Status = BackgroundJobStatus.Failed;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = "Tenant capability was revoked before job execution.";
job.LastError = "Tenant feature entitlement was revoked before job execution.";
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
@@ -205,7 +239,7 @@ internal sealed class BackgroundJobService(
"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(scopedDbContext, job, cancellationToken),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedProvider, 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}'.")
@@ -380,6 +414,7 @@ internal sealed class BackgroundJobService(
}
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
IServiceProvider scopedProvider,
TikuDbContext scopedDbContext,
BackgroundJob job,
CancellationToken cancellationToken)
@@ -398,12 +433,22 @@ internal sealed class BackgroundJobService(
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
revenueCents,
quotaUsage
});
}
@@ -412,13 +457,14 @@ internal sealed class BackgroundJobService(
return jobType.Trim().ToLowerInvariant();
}
private static string ResolveCapabilityModule(string jobType) => jobType switch
private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch
{
"content_export" or "content_import" or "asset_security_scan" => "content",
"statistics_aggregation" => "dashboard",
"commerce_reconciliation" => "commerce",
"tenant_domain_recheck" => "settings",
_ => "job"
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
?? throw new InvalidOperationException("Background content import type is not supported."),
"content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank,
"commerce_reconciliation" => SaasFeatureCatalog.StudentStore,
"statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice,
_ => SaasFeatureCatalog.CoreBackoffice
};
private static string NormalizeProvider(string? provider)