feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user