forked from xiongyuxing/tiku-backend.net
feat(security): complete capability messaging workflows
This commit is contained in:
@@ -51,6 +51,9 @@ public static class DependencyInjection
|
||||
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||
!string.IsNullOrWhiteSpace(options.Password)),
|
||||
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||
.Validate(
|
||||
options => options.OutboxBacklogAlertCount > 0 && options.OutboxOldestMessageAlertSeconds > 0,
|
||||
"RabbitMQ outbox alert thresholds must be positive.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton(messaging);
|
||||
if (messaging.IsConfigured)
|
||||
|
||||
@@ -140,6 +140,27 @@ public sealed class UpsertPlatformSubscriptionDto
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class ReplacePlatformPlanModulesDto
|
||||
{
|
||||
[Required]
|
||||
public IReadOnlyCollection<string> ModuleCodes { get; set; } = [];
|
||||
|
||||
public ReplacePlatformPlanModulesCommand ToCommand(string planCode) => new(planCode, ModuleCodes);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformTenantModuleOverrideDto
|
||||
{
|
||||
public TenantModuleOverrideMode Mode { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 1)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public UpsertPlatformTenantModuleOverrideCommand ToCommand(Guid tenantId, string moduleCode) =>
|
||||
new(tenantId, moduleCode, Mode, ExpiresAt, Reason);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformStaffDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
@@ -45,6 +45,16 @@ public sealed class HealthController(
|
||||
var outboxPending = database
|
||||
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
||||
: -1;
|
||||
var outboxOldestSentTime = database
|
||||
? await dbContext.Set<OutboxMessage>()
|
||||
.Select(message => (DateTime?)message.SentTime)
|
||||
.MinAsync(cancellationToken)
|
||||
: null;
|
||||
var outboxOldestAgeSeconds = outboxOldestSentTime is null
|
||||
? 0
|
||||
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(outboxOldestSentTime.Value)).TotalSeconds);
|
||||
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
|
||||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
|
||||
var ready = database && redis && rabbitMq;
|
||||
var response = new
|
||||
{
|
||||
@@ -52,7 +62,14 @@ public sealed class HealthController(
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||
outbox = new { pending = outboxPending },
|
||||
outbox = new
|
||||
{
|
||||
pending = outboxPending,
|
||||
oldestAgeSeconds = Math.Round(outboxOldestAgeSeconds, 1),
|
||||
alert = outboxAlert,
|
||||
backlogAlertCount = messagingOptions.OutboxBacklogAlertCount,
|
||||
oldestMessageAlertSeconds = messagingOptions.OutboxOldestMessageAlertSeconds
|
||||
},
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
|
||||
@@ -89,6 +89,19 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planCode}/modules")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("替换 SaaS 套餐模块权益")]
|
||||
[ProducesResponseType<PlatformPlanModuleEntitlements>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformPlanModuleEntitlements>> ReplacePlanModules(
|
||||
string planCode,
|
||||
ReplacePlatformPlanModulesDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.ReplacePlanModulesAsync(
|
||||
ResolveActor(), request.ToCommand(planCode), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建或调整租户订阅")]
|
||||
@@ -100,6 +113,20 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/module-overrides/{moduleCode}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("设置租户模块覆盖")]
|
||||
[ProducesResponseType<PlatformTenantModuleOverrideItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantModuleOverrideItem>> UpsertTenantModuleOverride(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
UpsertPlatformTenantModuleOverrideDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertTenantModuleOverrideAsync(
|
||||
ResolveActor(), request.ToCommand(tenantId, moduleCode), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("domains")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询租户域名状态")]
|
||||
|
||||
@@ -72,7 +72,9 @@
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
},
|
||||
"BrowserAuth": {
|
||||
"AllowedOrigins": []
|
||||
|
||||
@@ -33,6 +33,14 @@ public interface IBackgroundJobService
|
||||
Task<int> ProcessPendingAsync(
|
||||
string workerId,
|
||||
int batchSize,
|
||||
bool includeImmediateJobs = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ProcessRequestedAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string workerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||
@@ -41,3 +49,15 @@ public interface IBackgroundJobService
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IBackgroundJobDispatcher
|
||||
{
|
||||
bool IsEnabled { get; }
|
||||
|
||||
Task DispatchAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,28 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
|
||||
|
||||
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
||||
|
||||
public sealed record PlatformPlanModuleEntitlements(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record ReplacePlatformPlanModulesCommand(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record PlatformTenantModuleOverrideItem(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
public sealed record UpsertPlatformTenantModuleOverrideCommand(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string Reason);
|
||||
|
||||
public sealed record UpsertPlatformSubscriptionCommand(
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
@@ -239,7 +261,9 @@ public interface IPlatformAdminService
|
||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(PlatformAdminActor actor, UpsertPlatformTenantModuleOverrideCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
15
Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
Normal file
15
Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class ConsumerAuthorizationMetadataAttribute(
|
||||
string realm,
|
||||
string module,
|
||||
CapabilityOperation operation,
|
||||
string auditAction) : Attribute
|
||||
{
|
||||
public string Realm { get; } = realm;
|
||||
public string Module { get; } = module;
|
||||
public CapabilityOperation Operation { get; } = operation;
|
||||
public string AuditAction { get; } = auditAction;
|
||||
public bool RequiresSystemScope { get; init; }
|
||||
}
|
||||
@@ -14,7 +14,8 @@ public sealed record SystemScopeRequest(
|
||||
SystemScopeCallerType CallerType,
|
||||
string Caller,
|
||||
string Reason,
|
||||
string CorrelationId);
|
||||
string CorrelationId,
|
||||
bool IsGlobal = false);
|
||||
|
||||
public interface ITenantExecutionScope
|
||||
{
|
||||
|
||||
23
Tiku.Application/Security/ProductModuleCatalog.cs
Normal file
23
Tiku.Application/Security/ProductModuleCatalog.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class ProductModuleCatalog
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> All =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["dashboard"] = "Dashboard",
|
||||
["staff"] = "Staff",
|
||||
["role"] = "Roles",
|
||||
["student"] = "Students",
|
||||
["content"] = "Content",
|
||||
["settings"] = "Settings",
|
||||
["provider"] = "Providers",
|
||||
["commerce"] = "Commerce",
|
||||
["crm"] = "CRM",
|
||||
["commission"] = "Commission",
|
||||
["job"] = "Background Jobs"
|
||||
};
|
||||
|
||||
public static bool Contains(string moduleCode) =>
|
||||
All.ContainsKey(moduleCode.Trim().ToLowerInvariant());
|
||||
}
|
||||
@@ -128,6 +128,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddSingleton<IBackgroundJobDispatcher, NullBackgroundJobDispatcher>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
@@ -209,6 +210,12 @@ public static class DependencyInjection
|
||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||
});
|
||||
registration.AddConsumer<BackgroundJobRequestedConsumer>(consumer =>
|
||||
{
|
||||
consumer.ConcurrentMessageLimit = 1;
|
||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||
});
|
||||
registration.AddConfigureEndpointsCallback((context, _, endpoint) =>
|
||||
{
|
||||
endpoint.PrefetchCount = 1;
|
||||
@@ -228,6 +235,7 @@ public static class DependencyInjection
|
||||
});
|
||||
});
|
||||
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||
services.AddScoped<IBackgroundJobDispatcher, MassTransitBackgroundJobDispatcher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
40
Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
Normal file
40
Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
internal sealed class NullBackgroundJobDispatcher : IBackgroundJobDispatcher
|
||||
{
|
||||
public bool IsEnabled => false;
|
||||
|
||||
public Task DispatchAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class MassTransitBackgroundJobDispatcher(IPublishEndpoint publishEndpoint) :
|
||||
IBackgroundJobDispatcher
|
||||
{
|
||||
public bool IsEnabled => true;
|
||||
|
||||
public Task DispatchAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(
|
||||
new BackgroundJobRequestedV1(
|
||||
Guid.NewGuid(),
|
||||
jobId,
|
||||
tenantId,
|
||||
jobType,
|
||||
DateTimeOffset.UtcNow,
|
||||
correlationId),
|
||||
context => context.MessageId = jobId,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
[ConsumerAuthorizationMetadata(
|
||||
"tenant", "dynamic-job-module", CapabilityOperation.Write, "background_job.execute",
|
||||
RequiresSystemScope = true)]
|
||||
internal sealed class BackgroundJobRequestedConsumer(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ITenantContextInitializer tenantContextInitializer) : IConsumer<BackgroundJobRequestedV1>
|
||||
{
|
||||
public async Task Consume(ConsumeContext<BackgroundJobRequestedV1> context)
|
||||
{
|
||||
var message = context.Message;
|
||||
tenantContextInitializer.InitializeSystem(
|
||||
message.TenantId,
|
||||
$"RabbitMQ background job {message.JobType}");
|
||||
await backgroundJobService.ProcessRequestedAsync(
|
||||
message.JobId,
|
||||
message.TenantId,
|
||||
message.JobType,
|
||||
$"rabbitmq:{Environment.MachineName}",
|
||||
context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public sealed class MessagingOptions
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool ConfigureConsumers { get; set; }
|
||||
public int OutboxBacklogAlertCount { get; set; } = 1000;
|
||||
public int OutboxOldestMessageAlertSeconds { get; set; } = 300;
|
||||
|
||||
public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
||||
|
||||
@@ -4,6 +4,8 @@ using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
[ConsumerAuthorizationMetadata(
|
||||
"system", "security-state", CapabilityOperation.Read, "security.invalidation.apply")]
|
||||
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
||||
IConsumer<AuthorizationStateChangedV1>,
|
||||
IConsumer<TenantCapabilityChangedV1>,
|
||||
|
||||
18569
Tiku.Infrastructure/Persistence/Migrations/20260729025218_EnforceProductModuleCatalog.Designer.cs
generated
Normal file
18569
Tiku.Infrastructure/Persistence/Migrations/20260729025218_EnforceProductModuleCatalog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EnforceProductModuleCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO product_modules
|
||||
(id, code, name, status, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
('10000000-0000-0000-0000-000000000001', 'dashboard', 'Dashboard', 'active', 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000002', 'staff', 'Staff', 'active', 20, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000003', 'role', 'Roles', 'active', 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000004', 'student', 'Students', 'active', 40, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000005', 'content', 'Content', 'active', 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000006', 'settings', 'Settings', 'active', 60, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000007', 'provider', 'Providers', 'active', 70, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000008', 'commerce', 'Commerce', 'active', 80, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000009', 'crm', 'CRM', 'active', 90, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000a', 'commission', 'Commission', 'active', 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000b', 'job', 'Background Jobs', 'active', 110, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = 'active',
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO plan_module_entitlements (id, plan_code, module_code, enabled)
|
||||
SELECT
|
||||
md5('plan-module:' || plan.code || ':' || module.code)::uuid,
|
||||
plan.code,
|
||||
module.code,
|
||||
TRUE
|
||||
FROM platform_saas_plans AS plan
|
||||
CROSS JOIN product_modules AS module
|
||||
WHERE module.code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM plan_module_entitlements AS existing
|
||||
WHERE existing.plan_code = plan.code)
|
||||
ON CONFLICT (plan_code, module_code) DO NOTHING;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM product_modules
|
||||
WHERE code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job');
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,124 @@ internal sealed class PlatformAdminService(
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(
|
||||
PlatformAdminActor actor,
|
||||
ReplacePlatformPlanModulesCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform plan module entitlements replace", async (provider, dbContext) =>
|
||||
{
|
||||
var planCode = NormalizeCode(command.PlanCode);
|
||||
var plan = await dbContext.PlatformSaasPlans.SingleOrDefaultAsync(
|
||||
item => item.Code == planCode,
|
||||
cancellationToken) ?? throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
|
||||
var moduleCodes = command.ModuleCodes
|
||||
.Select(NormalizeCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var invalidModules = moduleCodes.Where(module => !ProductModuleCatalog.Contains(module)).ToArray();
|
||||
if (invalidModules.Length > 0)
|
||||
{
|
||||
throw new PlatformAdminException("One or more product modules are invalid.", "product_module_invalid");
|
||||
}
|
||||
|
||||
var existing = await dbContext.PlanModuleEntitlements
|
||||
.Where(item => item.PlanCode == planCode)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var changedModules = existing.Select(item => item.ModuleCode)
|
||||
.Concat(moduleCodes)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
dbContext.PlanModuleEntitlements.RemoveRange(existing);
|
||||
dbContext.PlanModuleEntitlements.AddRange(moduleCodes.Select(moduleCode => new PlanModuleEntitlement
|
||||
{
|
||||
PlanCode = planCode,
|
||||
ModuleCode = moduleCode,
|
||||
Enabled = true
|
||||
}));
|
||||
AddAudit(dbContext, actor, "platform.plan.modules.replaced", plan.Id, new
|
||||
{
|
||||
planCode,
|
||||
previousModuleCodes = existing.Select(item => item.ModuleCode).Order(StringComparer.Ordinal),
|
||||
moduleCodes
|
||||
});
|
||||
|
||||
var tenantIds = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(item => item.PlanCode == planCode)
|
||||
.Select(item => item.TenantId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var publisher = provider.GetRequiredService<ISecurityEventPublisher>();
|
||||
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
foreach (var moduleCode in changedModules)
|
||||
{
|
||||
await publisher.CapabilityChangedAsync(
|
||||
tenantId, moduleCode, "plan_entitlements_changed", version,
|
||||
$"plan-modules-{plan.Id:N}", cancellationToken);
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformPlanModuleEntitlements(planCode, moduleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformTenantModuleOverrideCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant module override upsert", async (provider, dbContext) =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
var moduleCode = NormalizeCode(command.ModuleCode);
|
||||
if (!ProductModuleCatalog.Contains(moduleCode))
|
||||
{
|
||||
throw new PlatformAdminException("Product module was not found.", "product_module_not_found");
|
||||
}
|
||||
var reason = command.Reason.Trim();
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new PlatformAdminException("Module override reason is required.", "module_override_reason_required");
|
||||
}
|
||||
|
||||
var item = await dbContext.TenantModuleOverrides.SingleOrDefaultAsync(
|
||||
value => value.TenantId == command.TenantId && value.ModuleCode == moduleCode,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new TenantModuleOverride { TenantId = command.TenantId, ModuleCode = moduleCode };
|
||||
dbContext.TenantModuleOverrides.Add(item);
|
||||
}
|
||||
var previousMode = item.Mode;
|
||||
item.Mode = command.Mode;
|
||||
item.ExpiresAt = command.ExpiresAt;
|
||||
item.Reason = reason;
|
||||
AddAudit(dbContext, actor, "platform.tenant.module_override.updated", command.TenantId, new
|
||||
{
|
||||
moduleCode,
|
||||
previousMode,
|
||||
item.Mode,
|
||||
item.ExpiresAt,
|
||||
reason
|
||||
});
|
||||
await provider.GetRequiredService<ISecurityEventPublisher>().CapabilityChangedAsync(
|
||||
command.TenantId,
|
||||
moduleCode,
|
||||
"tenant_module_override_changed",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
$"tenant-module-override-{item.Id:N}",
|
||||
cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformTenantModuleOverrideItem(
|
||||
item.TenantId, item.ModuleCode, item.Mode, item.ExpiresAt, item.Reason);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
@@ -768,7 +886,8 @@ internal sealed class PlatformAdminService(
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(PlatformAdminService),
|
||||
reason,
|
||||
Guid.NewGuid().ToString("N")),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
IsGlobal: true),
|
||||
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,15 @@ internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapa
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||
if (!ProductModuleCatalog.Contains(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
||||
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
||||
if (!moduleExists)
|
||||
{
|
||||
// Compatibility while the fixed module catalog is introduced module-by-module.
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
|
||||
@@ -82,6 +82,22 @@ public sealed class TenantExecutionScope(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId);
|
||||
if (request.TargetTenantId is null && !request.IsGlobal)
|
||||
{
|
||||
throw new ArgumentException("System scope requires a target tenant or an explicit global declaration.", nameof(request));
|
||||
}
|
||||
if (request.TargetTenantId is not null && request.IsGlobal)
|
||||
{
|
||||
throw new ArgumentException("A tenant-targeted system scope cannot also be global.", nameof(request));
|
||||
}
|
||||
if (request.IsGlobal && request.CallerType is SystemScopeCallerType.Worker or SystemScopeCallerType.PublicQuestionBank)
|
||||
{
|
||||
throw new ArgumentException("Worker and public question bank scopes must target a tenant.", nameof(request));
|
||||
}
|
||||
if (!Enum.IsDefined(request.CallerType))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Unknown system scope caller type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAuditAsync(
|
||||
@@ -105,6 +121,7 @@ public sealed class TenantExecutionScope(
|
||||
request.Caller,
|
||||
request.Reason,
|
||||
request.CorrelationId,
|
||||
request.IsGlobal,
|
||||
startedAt,
|
||||
elapsedMilliseconds,
|
||||
failureType
|
||||
|
||||
@@ -16,6 +16,8 @@ using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.IntegrationTests.Infrastructure;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -134,6 +136,54 @@ public sealed class ApiTestFactory(
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Integration test fixture seeding");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var tenants = entities.OfType<Tenant>().Where(tenant => tenant.Mode == TenantMode.Saas).ToArray();
|
||||
var hasExplicitCapabilitySetup = entities.Any(entity =>
|
||||
entity is PlatformSaasPlan or ProductModule or PlanModuleEntitlement or TenantSubscription);
|
||||
if (tenants.Length > 0 && !hasExplicitCapabilitySetup)
|
||||
{
|
||||
const string integrationPlanCode = "integration-full-access";
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == integrationPlanCode))
|
||||
{
|
||||
dbContext.PlatformSaasPlans.Add(new PlatformSaasPlan
|
||||
{
|
||||
Code = integrationPlanCode,
|
||||
Name = "Integration Full Access"
|
||||
});
|
||||
}
|
||||
|
||||
var existingModules = await dbContext.ProductModules
|
||||
.Select(module => module.Code)
|
||||
.ToArrayAsync();
|
||||
foreach (var module in ProductModuleCatalog.All.Where(module => !existingModules.Contains(module.Key)))
|
||||
{
|
||||
dbContext.ProductModules.Add(new ProductModule { Code = module.Key, Name = module.Value });
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var entitledModules = await dbContext.PlanModuleEntitlements
|
||||
.Where(entitlement => entitlement.PlanCode == integrationPlanCode)
|
||||
.Select(entitlement => entitlement.ModuleCode)
|
||||
.ToArrayAsync();
|
||||
foreach (var moduleCode in ProductModuleCatalog.All.Keys.Except(entitledModules, StringComparer.Ordinal))
|
||||
{
|
||||
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
|
||||
{
|
||||
PlanCode = integrationPlanCode,
|
||||
ModuleCode = moduleCode
|
||||
});
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
entities = entities.Concat(tenants.Select(tenant => new TenantSubscription
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
PlanCode = integrationPlanCode,
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
ExpiresAt = now.AddYears(1)
|
||||
})).ToArray();
|
||||
}
|
||||
dbContext.AddRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -7,14 +7,17 @@ using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MassTransit;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 330;
|
||||
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
|
||||
private const int ExpectedActionCount = 332;
|
||||
private const string ExpectedSha256 = "a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
@@ -61,6 +64,30 @@ public sealed class AuthorizationManifestTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_consumers_have_reviewed_authorization_and_audit_metadata()
|
||||
{
|
||||
var consumers = typeof(MessagingOptions).Assembly.GetTypes()
|
||||
.Where(type => !type.IsAbstract && type.GetInterfaces().Any(candidate =>
|
||||
candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IConsumer<>)))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(2, consumers.Length);
|
||||
foreach (var consumer in consumers)
|
||||
{
|
||||
var metadata = consumer.GetCustomAttribute<ConsumerAuthorizationMetadataAttribute>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Contains(metadata.Realm, new[] { "tenant", "platform", "system" });
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Module));
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
|
||||
if (consumer.Name == "BackgroundJobRequestedConsumer")
|
||||
{
|
||||
Assert.Equal(CapabilityOperation.Write, metadata.Operation);
|
||||
Assert.True(metadata.RequiresSystemScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(Type controller, MethodInfo action)
|
||||
{
|
||||
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -9,6 +13,48 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class CapabilityAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Background_job_rechecks_capability_after_enqueue_before_execution()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Job Capability Tenant" },
|
||||
new PlatformSaasPlan { Code = "job-capability-test", Name = "Job Capability Test" },
|
||||
new PlanModuleEntitlement { PlanCode = "job-capability-test", ModuleCode = "content" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "job-capability-test",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
});
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify job capability at consumption");
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"content_export",
|
||||
JsonSerializer.SerializeToElement(new { exportType = "capability-test" })));
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.TenantModuleOverrides.Add(new TenantModuleOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ModuleCode = "content",
|
||||
Mode = TenantModuleOverrideMode.Disabled,
|
||||
Reason = "Integration test revocation"
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Assert.True(await jobs.ProcessRequestedAsync(
|
||||
job.Id, tenantId, job.JobType, "capability-test-worker"));
|
||||
var stored = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
|
||||
Assert.Equal(BackgroundJobStatus.Failed, stored.Status);
|
||||
Assert.Equal("Tenant capability was revoked before job execution.", stored.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
||||
{
|
||||
@@ -17,7 +63,6 @@ public sealed class CapabilityAuthorizationTests
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
|
||||
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
|
||||
new ProductModule { Code = "content", Name = "Content" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
|
||||
@@ -62,6 +62,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
var overview = await client.GetAsync("/api/platform-admin/overview");
|
||||
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
||||
var planModules = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/plans/standard/modules",
|
||||
new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] });
|
||||
var subscription = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/subscriptions",
|
||||
new UpsertPlatformSubscriptionDto
|
||||
@@ -73,6 +76,13 @@ public sealed class PlatformAdminEndpointTests
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
AmountCents = 99900
|
||||
});
|
||||
var moduleOverride = await client.PutAsJsonAsync(
|
||||
$"/api/platform-admin/tenants/{tenantId}/module-overrides/content",
|
||||
new UpsertPlatformTenantModuleOverrideDto
|
||||
{
|
||||
Mode = TenantModuleOverrideMode.Disabled,
|
||||
Reason = "integration test capability revocation"
|
||||
});
|
||||
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
||||
var suspended = await client.PatchAsJsonAsync(
|
||||
"/api/platform-admin/tenants/status",
|
||||
@@ -90,7 +100,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
||||
@@ -103,6 +115,12 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.status_changed"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.plan.modules.replaced"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.module_override.updated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var otherTenantId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
Tenant(otherTenantId, "other"),
|
||||
new QuestionBank
|
||||
@@ -62,6 +63,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var versionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
|
||||
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
|
||||
@@ -130,6 +132,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var questionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Question
|
||||
{
|
||||
@@ -154,6 +157,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var questionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Question
|
||||
{
|
||||
@@ -199,6 +203,16 @@ public sealed class QuestionBankEndpointTests
|
||||
};
|
||||
}
|
||||
|
||||
private static Tenant PlatformTenant() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = $"platform-{Guid.NewGuid():N}",
|
||||
Name = "Platform Question Bank",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.PlatformOwned,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
|
||||
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
@@ -4,11 +4,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StackExchange.Redis;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Contracts;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -17,6 +22,199 @@ namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class MassTransitOutboxTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bus_outbox_drains_after_real_broker_restart()
|
||||
{
|
||||
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||
var containerName = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_CONTAINER");
|
||||
if (string.IsNullOrWhiteSpace(rabbitMqHost) ||
|
||||
Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_RESTART") != "1" ||
|
||||
string.IsNullOrWhiteSpace(containerName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||
using var client = factory.CreateClient();
|
||||
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready before restart drill.");
|
||||
|
||||
await RunDockerAsync("stop", containerName);
|
||||
try
|
||||
{
|
||||
Assert.True(await WaitForBrokerPortClosedAsync(new Uri(rabbitMqHost)),
|
||||
"RabbitMQ AMQP port remained reachable after stopping the test container.");
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Commit outbox while RabbitMQ is stopped"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await publisher.AuthorizationChangedAsync(
|
||||
null, null, "broker_restart_test", 3, Guid.NewGuid().ToString("N"));
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
using var verification = factory.CreateSystemScope("Verify restart outbox backlog");
|
||||
var verificationDbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.NotEmpty(await verificationDbContext.Set<OutboxMessage>().ToArrayAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RunDockerAsync("start", containerName);
|
||||
}
|
||||
|
||||
Assert.True(await WaitForReadyAsync(client, 240),
|
||||
"RabbitMQ did not become ready within 60 seconds after restart.");
|
||||
var drained = false;
|
||||
for (var attempt = 0; attempt < 240; attempt++)
|
||||
{
|
||||
using var verification = factory.CreateSystemScope("Wait for post-restart outbox drain");
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
|
||||
{
|
||||
drained = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
Assert.True(drained, "Bus outbox did not drain within 60 seconds after RabbitMQ restart.");
|
||||
}
|
||||
|
||||
private static async Task RunDockerAsync(string operation, string containerName)
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
ArgumentList = { operation, containerName },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
}) ?? throw new InvalidOperationException("Failed to start Docker CLI for RabbitMQ restart drill.");
|
||||
var standardOutput = await process.StandardOutput.ReadToEndAsync();
|
||||
var standardError = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"docker {operation} failed for the RabbitMQ test container: {standardError}{standardOutput}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForBrokerPortClosedAsync(Uri broker)
|
||||
{
|
||||
var port = broker.IsDefaultPort ? 5672 : broker.Port;
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
using var tcpClient = new TcpClient();
|
||||
try
|
||||
{
|
||||
await tcpClient.ConnectAsync(broker.Host, port).WaitAsync(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or TimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_job_request_is_transactional_consumed_once_and_keeps_database_status_view()
|
||||
{
|
||||
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||
if (string.IsNullOrWhiteSpace(rabbitMqHost))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||
using var client = factory.CreateClient();
|
||||
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds.");
|
||||
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "RabbitMQ Background Job Tenant"
|
||||
});
|
||||
|
||||
var workerBuilder = Host.CreateApplicationBuilder();
|
||||
workerBuilder.Services.AddApplication();
|
||||
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
|
||||
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
|
||||
using var worker = workerBuilder.Build();
|
||||
await worker.StartAsync();
|
||||
|
||||
Guid rolledBackJobId;
|
||||
using (var scope = factory.CreateSystemScope("Roll back background job request"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
|
||||
rolledBackJobId = job.Id;
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify rolled back background job request"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.False(await dbContext.BackgroundJobs.AnyAsync(item => item.Id == rolledBackJobId));
|
||||
Assert.False(await dbContext.Set<OutboxMessage>().AnyAsync(
|
||||
item => item.MessageId == rolledBackJobId));
|
||||
}
|
||||
|
||||
BackgroundJobItem committedJob;
|
||||
using (var scope = factory.CreateSystemScope("Commit background job request"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
committedJob = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
BackgroundJobStatus? status = null;
|
||||
for (var attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
using var verification = factory.CreateSystemScope("Wait for RabbitMQ background job consumer");
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
status = await dbContext.BackgroundJobs
|
||||
.Where(item => item.Id == committedJob.Id)
|
||||
.Select(item => (BackgroundJobStatus?)item.Status)
|
||||
.SingleAsync();
|
||||
if (status == BackgroundJobStatus.Succeeded) break;
|
||||
await Task.Delay(250);
|
||||
}
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, status);
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify background job inbox and status view"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(await dbContext.Set<InboxState>()
|
||||
.Where(item => item.MessageId == committedJob.Id)
|
||||
.ToArrayAsync());
|
||||
var jobs = verification.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var statusView = await jobs.ListAsync(tenantId, "tenant_domain_recheck");
|
||||
Assert.Contains(statusView, item =>
|
||||
item.Id == committedJob.Id && item.Status == BackgroundJobStatus.Succeeded);
|
||||
}
|
||||
|
||||
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
|
||||
if (managementEndpoint is not null)
|
||||
{
|
||||
Assert.Equal(0, await GetQueueMessageCountAsync(
|
||||
managementEndpoint, "background-job-requested_error"));
|
||||
}
|
||||
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
|
||||
{
|
||||
@@ -177,9 +375,9 @@ public sealed class MassTransitOutboxTests
|
||||
ConfigureConsumers = configureConsumers
|
||||
};
|
||||
|
||||
private static async Task<bool> WaitForReadyAsync(HttpClient client)
|
||||
private static async Task<bool> WaitForReadyAsync(HttpClient client, int attempts = 40)
|
||||
{
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
for (var attempt = 0; attempt < attempts; attempt++)
|
||||
{
|
||||
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
@@ -210,6 +408,10 @@ public sealed class MassTransitOutboxTests
|
||||
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("messages").GetInt32();
|
||||
|
||||
@@ -16,10 +16,20 @@ public sealed class SystemScopeAuditTests
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
|
||||
(_, _) => Task.CompletedTask));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "missing target", "job-2"),
|
||||
(_, _) => Task.CompletedTask));
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "System Scope Tenant"
|
||||
});
|
||||
await executionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
||||
new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
||||
(_, _) => Task.CompletedTask);
|
||||
|
||||
using var verification = factory.CreateSystemScope("Verify execution scope audit");
|
||||
|
||||
@@ -37,6 +37,9 @@ builder.Services.AddOptions<MessagingOptions>()
|
||||
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||
!string.IsNullOrWhiteSpace(options.Password)),
|
||||
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||
.Validate(
|
||||
options => options.OutboxBacklogAlertCount > 0 && options.OutboxOldestMessageAlertSeconds > 0,
|
||||
"RabbitMQ outbox alert thresholds must be positive.")
|
||||
.ValidateOnStart();
|
||||
if (messaging.IsConfigured)
|
||||
{
|
||||
|
||||
@@ -2,12 +2,14 @@ using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
public class Worker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> options,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
@@ -60,6 +62,10 @@ public class Worker(
|
||||
.InitializeSystem(null, "Background job lease worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(workerId, 20, stoppingToken);
|
||||
.ProcessPendingAsync(
|
||||
workerId,
|
||||
20,
|
||||
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||
cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 认证与授权待补强清单
|
||||
|
||||
> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。生产网关 ACL、RabbitMQ 4.x 重启/积压演练和按 job type 迁移旧轮询 Worker 仍属于部署验收项。
|
||||
> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、固定目录数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox 与即时 BackgroundJob Consumer、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。本地 RabbitMQ 4.3.4 已验证停机期间事务提交、outbox 积压及重启补发;生产网关 ACL 和生产 Broker 演练仍属于部署验收项。
|
||||
|
||||
当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
||||
|
||||
@@ -49,7 +49,7 @@ Tenant Active
|
||||
+ DataScope / Resource Scope
|
||||
```
|
||||
|
||||
实现说明:`ProductModule`、`PlanModuleEntitlement`、`TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler。模块目录采用渐进启用:只有进入固定目录的模块才强制套餐校验,避免迁移时误封未建档模块。
|
||||
实现说明:固定 `ProductModuleCatalog`、`PlanModuleEntitlement`、可审计 `TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler;未知或缺失目录模块 fail-closed。平台接口可替换套餐模块权益和设置带原因的租户覆盖,变更在同一事务写审计与 outbox 事件。迁移仅为从未配置权益的存量套餐补齐固定目录,已有显式权益不会被扩权。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -80,7 +80,7 @@ Tenant Active
|
||||
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
||||
- 跨租户写操作必须落 `AuditLog`。
|
||||
|
||||
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
||||
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;没有租户目标时必须显式声明 `IsGlobal`,且 Worker/公共题库不能创建全局 scope。旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
||||
|
||||
验收:
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ DataScope:
|
||||
- `Tiku.Contracts` 只包含版本化 DTO,不引用 EF、HTTP 或 Provider SDK。
|
||||
- API 使用 MassTransit EF Bus Outbox,Worker consumer 使用 EF inbox/outbox;业务变更、审计和消息由同一 DbContext 提交。
|
||||
- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知;成员、租户、Session 或套餐失效不等待 consumer。
|
||||
- 即时 `BackgroundJob` 由 `BackgroundJobRequestedV1` Consumer 执行;延时任务和失败后的定时重试继续由数据库调度器处理,同一即时任务不会同时进入两种消费路径。业务 handler 必须使用受审计 System Scope 提供的 scoped `DbContext`。
|
||||
- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。
|
||||
|
||||
## 审计与错误
|
||||
@@ -137,6 +138,7 @@ DataScope:
|
||||
- CORS 明确 Origin。
|
||||
- Redis 7.2+ 连接串;Production 缺失时拒绝启动。
|
||||
- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。
|
||||
- 默认镜像不依赖 `x-delayed-message` 插件;Consumer 使用有限即时重试,延时业务重试落回 PostgreSQL `RunAfter`。
|
||||
- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。
|
||||
- Secret encryption key。
|
||||
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
|
||||
|
||||
@@ -5,5 +5,5 @@ Controller 授权面由 `AuthorizationManifestTests` 按 HTTP method、route、c
|
||||
|
||||
该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope,不能用摘要替代运行时校验。
|
||||
|
||||
- Action 数量:330
|
||||
- SHA-256:`ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5`
|
||||
- Action 数量:332
|
||||
- SHA-256:`a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f`
|
||||
|
||||
@@ -11,7 +11,18 @@ export RabbitMq__Username='guest'
|
||||
export RabbitMq__Password='guest'
|
||||
```
|
||||
|
||||
RabbitMQ 使用 MassTransit 8.5.10 和 PostgreSQL EF Bus/Consumer Outbox。`GET /api/health` 是 liveness,`GET /api/health/ready` 检查 PostgreSQL、已配置的 Redis、RabbitMQ bus health 和 outbox backlog;服务健康不等于认证授权验收完成。
|
||||
RabbitMQ 使用 MassTransit 8.5.10 和 PostgreSQL EF Bus/Consumer Outbox。`GET /api/health` 是 liveness,`GET /api/health/ready` 检查 PostgreSQL、已配置的 Redis、RabbitMQ bus health,并返回 outbox pending、最老消息时长和阈值告警;服务健康不等于认证授权验收完成。
|
||||
官方 RabbitMQ 4.x 镜像无需安装 delayed-message 插件;不要配置 `UseDelayedRedelivery`,延时后台任务由 PostgreSQL `RunAfter` 调度。
|
||||
|
||||
本地 Broker 重启/outbox 恢复演练(仅对明确指定的测试容器执行 stop/start):
|
||||
|
||||
```bash
|
||||
TIKU_TEST_RABBITMQ=rabbitmq://localhost \
|
||||
TIKU_TEST_RABBITMQ_RESTART=1 \
|
||||
TIKU_TEST_RABBITMQ_CONTAINER=tiku-rabbitmq \
|
||||
dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj \
|
||||
--filter 'FullyQualifiedName~Bus_outbox_drains_after_real_broker_restart'
|
||||
```
|
||||
|
||||
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user