refactor: consolidate backend into modular monolith
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`TIKU-BACKEND.slnx` groups production projects under `src` and tests under `tests`. `Tiku.Api` contains controllers, middleware, authentication, and OpenAPI setup. `Tiku.Application` defines use cases and provider interfaces; `Tiku.Domain` owns entities and enums; `Tiku.Infrastructure` implements EF Core persistence and external providers. Use `Tiku.DbMigrator` for schema changes and `Tiku.Worker` for background processing. Tests live in `Tiku.UnitTests` and `Tiku.IntegrationTests`; architecture decisions and migration notes belong in `docs/`.
|
||||
`TIKU-BACKEND.slnx` groups production projects under `src` and tests under `tests`. `Tiku.Api` contains controllers, middleware, authentication, OpenAPI setup, and hosted background processing. `Tiku.Application` defines use cases and provider interfaces; `Tiku.Domain` owns entities and enums; `Tiku.Infrastructure` implements EF Core persistence and external providers. Use `Tiku.DbMigrator` for schema changes; PostgreSQL-backed background jobs run as hosted services in `Tiku.Api`. Tests live in `Tiku.UnitTests` and `Tiku.IntegrationTests`; architecture decisions and migration notes belong in `docs/`.
|
||||
|
||||
Keep dependencies pointed inward: Domain must remain infrastructure-free, Application expresses abstractions, and provider SDKs or secret access stay in Infrastructure.
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="3.0.17" />
|
||||
<PackageVersion Include="MassTransit" Version="8.5.10" />
|
||||
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.10" />
|
||||
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
|
||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||
|
||||
16
README.md
16
README.md
@@ -1,6 +1,6 @@
|
||||
# TIKU Backend
|
||||
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 后端,使用 EF Core 管理 PostgreSQL 数据,提供平台端、租户端和学生端 API,并由独立 Worker 处理后台任务。
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据,由同一个 API 进程提供平台端、租户端和学生端接口并处理后台任务。
|
||||
|
||||

|
||||
|
||||
@@ -10,26 +10,24 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 后端,使用 EF Core 管理 Post
|
||||
- Entity Framework Core 10 + Npgsql 10 + PostgreSQL
|
||||
- ASP.NET Core Identity + RSA JWT + 数据库存储的 Session
|
||||
- Scalar + OpenAPI(仅 Development 暴露)
|
||||
- Redis(分布式安全频控和生产输出缓存)
|
||||
- MassTransit 8 + RabbitMQ 4 + EF Core Outbox
|
||||
- Redis(安全频控、Feature 缓存和生产输出缓存)
|
||||
- PostgreSQL 后台任务队列、租约和重试
|
||||
- Serilog + OpenTelemetry
|
||||
- xUnit 单元测试和真实 PostgreSQL 集成测试
|
||||
|
||||
## 解决方案结构
|
||||
|
||||
```text
|
||||
Tiku.Api HTTP API、中间件、认证授权、OpenAPI/Scalar、静态管理端
|
||||
Tiku.Api HTTP API、中间件、认证授权、OpenAPI/Scalar 和 Hosted Service
|
||||
Tiku.Application 用例契约、应用服务接口和安全上下文
|
||||
Tiku.Domain 领域实体、枚举和值对象
|
||||
Tiku.Infrastructure EF Core、PostgreSQL、认证、消息和外部服务实现
|
||||
Tiku.Contracts API 与 Worker 之间的版本化消息契约
|
||||
Tiku.Infrastructure EF Core、PostgreSQL、认证、后台任务和外部服务实现
|
||||
Tiku.DbMigrator 数据库迁移、内置目录 seed 和平台管理员引导
|
||||
Tiku.Worker 域名、订阅、用量和后台任务处理
|
||||
Tiku.UnitTests 单元测试
|
||||
Tiku.IntegrationTests API、授权、EF 模型、迁移和真实 PostgreSQL 测试
|
||||
```
|
||||
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api`、`Worker` 和 `DbMigrator` 是组合根;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 是唯一运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -56,7 +54,7 @@ Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临
|
||||
## 运行时边界
|
||||
|
||||
- API 不自动执行数据库迁移;部署和本地初始化都使用 `Tiku.DbMigrator`。
|
||||
- Development 可不配置 Redis 和 RabbitMQ;Production 缺少任一依赖时 API 与 Worker 会拒绝启动。
|
||||
- Development 可不配置 Redis;Production 缺少 Redis 时 API 会拒绝启动。
|
||||
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code` 或 `tenantCode` 指定租户。
|
||||
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
|
||||
- 普通请求默认要求认证;匿名接口必须显式声明 `[AllowAnonymous]`。
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="Tiku.Api/Tiku.Api.csproj" />
|
||||
<Project Path="Tiku.Application/Tiku.Application.csproj" />
|
||||
<Project Path="Tiku.Contracts/Tiku.Contracts.csproj" />
|
||||
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj" />
|
||||
<Project Path="Tiku.Domain/Tiku.Domain.csproj" />
|
||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
||||
<Project Path="Tiku.Worker/Tiku.Worker.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="Tiku.IntegrationTests/Tiku.IntegrationTests.csproj" />
|
||||
|
||||
@@ -3,18 +3,38 @@ using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
namespace Tiku.Api.BackgroundProcessing;
|
||||
|
||||
internal abstract class PeriodicWorkerService(
|
||||
public sealed class BackgroundProcessingOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundProcessing";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int JobPollSeconds { get; set; } = 2;
|
||||
public int JobParallelism { get; set; } = 4;
|
||||
public int JobBatchSize { get; set; } = 5;
|
||||
|
||||
public static bool BeValid(BackgroundProcessingOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal abstract class PeriodicBackgroundService(
|
||||
ILogger logger,
|
||||
TimeSpan interval) : BackgroundService
|
||||
TimeSpan interval,
|
||||
bool enabled) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
@@ -24,6 +44,8 @@ internal abstract class PeriodicWorkerService(
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -32,83 +54,101 @@ internal abstract class PeriodicWorkerService(
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainWorker(
|
||||
internal sealed class TenantDomainBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> options,
|
||||
ILogger<TenantDomainWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)))
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<TenantDomainBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle worker");
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionWorker(
|
||||
internal sealed class SaasSubscriptionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SaasSubscriptionWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(60))
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<SaasSubscriptionBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle discovery worker");
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageWorker(
|
||||
internal sealed class FeatureUsageBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<FeatureUsageReconciliationOptions> options,
|
||||
ILogger<FeatureUsageWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromMinutes(Math.Clamp(options.Value.IntervalMinutes, 1, 1440)))
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<FeatureUsageBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation worker");
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsWorker(
|
||||
internal sealed class BackgroundJobsBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<BackgroundJobsWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(2))
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<BackgroundJobsBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
private readonly int parallelism = options.Value.JobParallelism;
|
||||
private readonly int batchSize = options.Value.JobBatchSize;
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, 4).Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
var workers = Enumerable.Range(0, parallelism)
|
||||
.Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
return (await Task.WhenAll(workers)).Sum();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease worker");
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
$"{workerId}:{index}",
|
||||
5,
|
||||
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||
batchSize,
|
||||
includeImmediateJobs: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Api.Caching;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Api.Observability;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
@@ -25,8 +26,6 @@ public static class DependencyInjection
|
||||
builder.Services.AddApiPresentation();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddSingleton<OutboxBacklogSnapshot>();
|
||||
builder.Services.AddHostedService<OutboxBacklogMonitor>();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||
@@ -76,29 +75,20 @@ public static class DependencyInjection
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||
builder.Services.AddOptions<MessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() ||
|
||||
(options.IsConfigured &&
|
||||
!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.")
|
||||
builder.Services.AddOptions<BackgroundProcessingOptions>()
|
||||
.Bind(builder.Configuration.GetSection(BackgroundProcessingOptions.SectionName))
|
||||
.Validate(BackgroundProcessingOptions.BeValid, "Background processing settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton(messaging);
|
||||
if (messaging.IsConfigured)
|
||||
{
|
||||
messaging.ConfigureConsumers = false;
|
||||
builder.Services.AddReliableMessaging(messaging);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
|
||||
}
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddHostedService<TenantDomainBackgroundService>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionBackgroundService>();
|
||||
builder.Services.AddHostedService<FeatureUsageBackgroundService>();
|
||||
builder.Services.AddHostedService<BackgroundJobsBackgroundService>();
|
||||
|
||||
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||
|
||||
@@ -28,7 +28,7 @@ internal static class ObservabilityExtensions
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Messaging", "Npgsql")
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
|
||||
return services;
|
||||
|
||||
@@ -4,9 +4,6 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Api.Observability;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -17,10 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
MessagingOptions messagingOptions,
|
||||
HealthCheckService healthCheckService,
|
||||
OutboxBacklogSnapshot outboxSnapshot) : ControllerBase
|
||||
IRedisSecurityStore redisSecurityStore) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -40,29 +34,12 @@ public sealed class HealthController(
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var rabbitHealth = await healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("ready"),
|
||||
cancellationToken);
|
||||
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
|
||||
var outboxPending = outboxSnapshot.Pending;
|
||||
var outboxOldestAgeSeconds = outboxSnapshot.OldestAgeSeconds;
|
||||
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
|
||||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
|
||||
var ready = database && redis && rabbitMq;
|
||||
var ready = database && redis;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||
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);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Npgsql;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Api.Observability;
|
||||
|
||||
public sealed class OutboxBacklogSnapshot
|
||||
{
|
||||
private static readonly Meter Meter = new("Tiku.Messaging", "1.0.0");
|
||||
private long pending;
|
||||
private double oldestAgeSeconds;
|
||||
|
||||
public OutboxBacklogSnapshot()
|
||||
{
|
||||
Meter.CreateObservableGauge("tiku.outbox.pending", () => Interlocked.Read(ref pending));
|
||||
Meter.CreateObservableGauge("tiku.outbox.oldest_age", () => Volatile.Read(ref oldestAgeSeconds), "s");
|
||||
}
|
||||
|
||||
internal long Pending => Interlocked.Read(ref pending);
|
||||
internal double OldestAgeSeconds => Volatile.Read(ref oldestAgeSeconds);
|
||||
|
||||
internal void Update(long count, DateTime? oldestSentTime)
|
||||
{
|
||||
Interlocked.Exchange(ref pending, count);
|
||||
Volatile.Write(ref oldestAgeSeconds, oldestSentTime is null
|
||||
? 0
|
||||
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(oldestSentTime.Value)).TotalSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OutboxBacklogMonitor(
|
||||
NpgsqlDataSource dataSource,
|
||||
OutboxBacklogSnapshot snapshot,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<OutboxBacklogMonitor> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (messagingOptions.IsConfigured)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(
|
||||
"SELECT count(*), min(sent_time) FROM outbox_message;");
|
||||
await using var reader = await command.ExecuteReaderAsync(stoppingToken);
|
||||
if (await reader.ReadAsync(stoppingToken))
|
||||
{
|
||||
snapshot.Update(reader.GetInt64(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Outbox backlog metric collection failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,31 @@
|
||||
"Redis": {
|
||||
"KeyPrefix": "tiku"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
"BackgroundProcessing": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
"JobBatchSize": 5
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
"BatchSize": 50,
|
||||
"DnsJsonEndpoint": "https://cloudflare-dns.com/dns-query",
|
||||
"VerificationRecordPrefix": "_tiku-verification",
|
||||
"AllowedCnameTargets": [],
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"PastDueGraceDays": 7
|
||||
},
|
||||
"FeatureUsageReconciliation": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"IntervalMinutes": 60
|
||||
},
|
||||
"BrowserAuth": {
|
||||
"AllowedOrigins": []
|
||||
|
||||
@@ -49,15 +49,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -13,11 +13,4 @@ public interface IRedisSecurityStore
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> PingAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace Tiku.Contracts;
|
||||
|
||||
public sealed record AuthorizationStateChangedV1(
|
||||
Guid EventId,
|
||||
Guid? TenantId,
|
||||
Guid? UserId,
|
||||
string ChangeKind,
|
||||
long Version,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record TenantCapabilityChangedV1(
|
||||
Guid EventId,
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
string ChangeKind,
|
||||
long Version,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record MembershipLifecycleChangedV1(
|
||||
Guid EventId,
|
||||
Guid TenantId,
|
||||
Guid UserId,
|
||||
string PreviousStatus,
|
||||
string CurrentStatus,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record BackgroundJobRequestedV1(
|
||||
Guid EventId,
|
||||
Guid JobId,
|
||||
Guid TenantId,
|
||||
string JobType,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationId);
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -47,8 +47,6 @@ using Tiku.Infrastructure.TenantAdmin;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using StackExchange.Redis;
|
||||
using MassTransit;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
@@ -93,7 +91,6 @@ public static class DependencyInjection
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
|
||||
services.AddScoped<ISecurityEventPublisher, NullSecurityEventPublisher>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||
@@ -151,12 +148,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantFeatureSnapshotProvider, TenantFeatureSnapshotProvider>();
|
||||
services.AddSingleton<TenantFeatureCacheInvalidator>();
|
||||
services.AddSingleton<ITenantFeatureCacheInvalidator>(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
|
||||
services.AddScoped<IFeatureAccessService, FeatureAccessService>();
|
||||
services.AddScoped<IFeatureUsageReconciliationService, FeatureUsageReconciliationService>();
|
||||
services.AddOptions<FeatureUsageReconciliationOptions>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddSingleton<IBackgroundJobDispatcher, NullBackgroundJobDispatcher>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
@@ -205,65 +200,4 @@ public static class DependencyInjection
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddReliableMessaging(
|
||||
this IServiceCollection services,
|
||||
MessagingOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!options.IsConfigured)
|
||||
{
|
||||
throw new ArgumentException("A valid RabbitMQ host URI is required.", nameof(options));
|
||||
}
|
||||
|
||||
services.AddMassTransit(registration =>
|
||||
{
|
||||
registration.SetKebabCaseEndpointNameFormatter();
|
||||
registration.ConfigureHealthCheckOptions(health =>
|
||||
{
|
||||
health.Name = "rabbitmq";
|
||||
health.Tags.Add("ready");
|
||||
});
|
||||
registration.AddEntityFrameworkOutbox<TikuDbContext>(outbox =>
|
||||
{
|
||||
outbox.UsePostgres();
|
||||
outbox.UseBusOutbox();
|
||||
outbox.QueryDelay = TimeSpan.FromSeconds(1);
|
||||
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
|
||||
});
|
||||
if (options.ConfigureConsumers)
|
||||
{
|
||||
registration.AddConsumer<SecurityStateChangedConsumer>(consumer =>
|
||||
{
|
||||
consumer.ConcurrentMessageLimit = 1;
|
||||
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;
|
||||
endpoint.ConcurrentMessageLimit = 1;
|
||||
endpoint.UseEntityFrameworkOutbox<TikuDbContext>(context);
|
||||
});
|
||||
}
|
||||
|
||||
registration.UsingRabbitMq((context, configurator) =>
|
||||
{
|
||||
configurator.Host(new Uri(options.Host), options.VirtualHost, host =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Username)) host.Username(options.Username);
|
||||
if (!string.IsNullOrWhiteSpace(options.Password)) host.Password(options.Password);
|
||||
});
|
||||
configurator.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||
services.AddScoped<IBackgroundJobDispatcher, MassTransitBackgroundJobDispatcher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ namespace Tiku.Infrastructure.Jobs;
|
||||
internal sealed class BackgroundJobService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
|
||||
IFeatureAccessService featureAccessService) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -71,15 +70,6 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (job.RunAfter is null && backgroundJobDispatcher.IsEnabled)
|
||||
{
|
||||
await backgroundJobDispatcher.DispatchAsync(
|
||||
job.Id,
|
||||
job.TenantId,
|
||||
job.JobType,
|
||||
job.Id.ToString("N"),
|
||||
cancellationToken);
|
||||
}
|
||||
return ToItem(job);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
public sealed class MessagingOptions
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
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";
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using MassTransit;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
public interface ISecurityEventPublisher
|
||||
{
|
||||
Task AuthorizationChangedAsync(
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
string changeKind,
|
||||
long version,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task CapabilityChangedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
string changeKind,
|
||||
long version,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task MembershipChangedAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
string previousStatus,
|
||||
string currentStatus,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class NullSecurityEventPublisher : ISecurityEventPublisher
|
||||
{
|
||||
public Task AuthorizationChangedAsync(
|
||||
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task CapabilityChangedAsync(
|
||||
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task MembershipChangedAsync(
|
||||
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class MassTransitSecurityEventPublisher(IPublishEndpoint publishEndpoint) : ISecurityEventPublisher
|
||||
{
|
||||
public Task AuthorizationChangedAsync(
|
||||
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new AuthorizationStateChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, changeKind, version,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
|
||||
public Task CapabilityChangedAsync(
|
||||
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new TenantCapabilityChangedV1(
|
||||
Guid.NewGuid(), tenantId, moduleCode, changeKind, version,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
|
||||
public Task MembershipChangedAsync(
|
||||
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new MembershipLifecycleChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, previousStatus, currentStatus,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Security;
|
||||
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>,
|
||||
IConsumer<MembershipLifecycleChangedV1>
|
||||
{
|
||||
public Task Consume(ConsumeContext<AuthorizationStateChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
"authorization", context.Message.TenantId, context.Message.UserId,
|
||||
context.Message.Version, context.CancellationToken);
|
||||
|
||||
public Task Consume(ConsumeContext<TenantCapabilityChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
$"capability-{context.Message.ModuleCode}", context.Message.TenantId, null,
|
||||
context.Message.Version, context.CancellationToken);
|
||||
|
||||
public Task Consume(ConsumeContext<MembershipLifecycleChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
"membership", context.Message.TenantId, context.Message.UserId,
|
||||
context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken);
|
||||
}
|
||||
19855
Tiku.Infrastructure/Persistence/Migrations/20260730071211_RemoveDistributedMessaging.Designer.cs
generated
Normal file
19855
Tiku.Infrastructure/Persistence/Migrations/20260730071211_RemoveDistributedMessaging.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveDistributedMessaging : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_message");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "inbox_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_state");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "inbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
consumer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
receive_count = table.Column<int>(type: "integer", nullable: false),
|
||||
received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_inbox_state", x => x.id);
|
||||
table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_state", x => x.outbox_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_message",
|
||||
columns: table => new
|
||||
{
|
||||
sequence_number = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
body = table.Column<string>(type: "text", nullable: false),
|
||||
content_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
conversation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
correlation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
destination_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
enqueue_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
fault_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
headers = table.Column<string>(type: "text", nullable: true),
|
||||
inbox_consumer_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
message_type = table.Column<string>(type: "text", nullable: false),
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
properties = table.Column<string>(type: "text", nullable: true),
|
||||
request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
response_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
sent_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
source_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_message", x => x.sequence_number);
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~",
|
||||
columns: x => new { x.inbox_message_id, x.inbox_consumer_id },
|
||||
principalTable: "inbox_state",
|
||||
principalColumns: new[] { "message_id", "consumer_id" });
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_outbox_state_outbox_id",
|
||||
column: x => x.outbox_id,
|
||||
principalTable: "outbox_state",
|
||||
principalColumn: "outbox_id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_inbox_state_delivered",
|
||||
table: "inbox_state",
|
||||
column: "delivered");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_enqueue_time",
|
||||
table: "outbox_message",
|
||||
column: "enqueue_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_expiration_time",
|
||||
table: "outbox_message",
|
||||
column: "expiration_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_outbox_id_sequence_number",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "outbox_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_state_created",
|
||||
table: "outbox_state",
|
||||
column: "created");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,224 +26,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("Consumed")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("consumed");
|
||||
|
||||
b.Property<Guid>("ConsumerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("consumer_id");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("delivered");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expiration_time");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("last_sequence_number");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("lock_id");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<int>("ReceiveCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("receive_count");
|
||||
|
||||
b.Property<DateTime>("Received")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("row_version");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_inbox_state");
|
||||
|
||||
b.HasAlternateKey("MessageId", "ConsumerId")
|
||||
.HasName("ak_inbox_state_message_id_consumer_id");
|
||||
|
||||
b.HasIndex("Delivered")
|
||||
.HasDatabaseName("ix_inbox_state_delivered");
|
||||
|
||||
b.ToTable("inbox_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.Property<long>("SequenceNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence_number");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("body");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("content_type");
|
||||
|
||||
b.Property<Guid?>("ConversationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("conversation_id");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("correlation_id");
|
||||
|
||||
b.Property<string>("DestinationAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("destination_address");
|
||||
|
||||
b.Property<DateTime?>("EnqueueTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("enqueue_time");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expiration_time");
|
||||
|
||||
b.Property<string>("FaultAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("fault_address");
|
||||
|
||||
b.Property<string>("Headers")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("headers");
|
||||
|
||||
b.Property<Guid?>("InboxConsumerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_consumer_id");
|
||||
|
||||
b.Property<Guid?>("InboxMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_message_id");
|
||||
|
||||
b.Property<Guid?>("InitiatorId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("initiator_id");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<string>("MessageType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("message_type");
|
||||
|
||||
b.Property<Guid?>("OutboxId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("outbox_id");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("properties");
|
||||
|
||||
b.Property<Guid?>("RequestId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("request_id");
|
||||
|
||||
b.Property<string>("ResponseAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("response_address");
|
||||
|
||||
b.Property<DateTime>("SentTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("sent_time");
|
||||
|
||||
b.Property<string>("SourceAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("source_address");
|
||||
|
||||
b.HasKey("SequenceNumber")
|
||||
.HasName("pk_outbox_message");
|
||||
|
||||
b.HasIndex("EnqueueTime")
|
||||
.HasDatabaseName("ix_outbox_message_enqueue_time");
|
||||
|
||||
b.HasIndex("ExpirationTime")
|
||||
.HasDatabaseName("ix_outbox_message_expiration_time");
|
||||
|
||||
b.HasIndex("OutboxId", "SequenceNumber")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_message_outbox_id_sequence_number");
|
||||
|
||||
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~");
|
||||
|
||||
b.ToTable("outbox_message", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
|
||||
{
|
||||
b.Property<Guid>("OutboxId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("outbox_id");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("delivered");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("last_sequence_number");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("lock_id");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("row_version");
|
||||
|
||||
b.HasKey("OutboxId")
|
||||
.HasName("pk_outbox_state");
|
||||
|
||||
b.HasIndex("Created")
|
||||
.HasDatabaseName("ix_outbox_state_created");
|
||||
|
||||
b.ToTable("outbox_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -16266,20 +16048,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenant_student_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OutboxId")
|
||||
.HasConstraintName("fk_outbox_message_outbox_state_outbox_id");
|
||||
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InboxMessageId", "InboxConsumerId")
|
||||
.HasPrincipalKey("MessageId", "ConsumerId")
|
||||
.HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
|
||||
@@ -16,8 +16,6 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using MassTransit;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -224,12 +222,6 @@ public sealed class TikuDbContext(
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
||||
modelBuilder.AddInboxStateEntity();
|
||||
modelBuilder.AddOutboxMessageEntity();
|
||||
modelBuilder.AddOutboxStateEntity();
|
||||
modelBuilder.Entity<InboxState>().ToTable("inbox_state");
|
||||
modelBuilder.Entity<OutboxMessage>().ToTable("outbox_message");
|
||||
modelBuilder.Entity<OutboxState>().ToTable("outbox_state");
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
ValidateTenantModel(modelBuilder);
|
||||
modelBuilder.UseSnakeCaseIdentifiers();
|
||||
|
||||
@@ -11,7 +11,6 @@ using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
@@ -248,14 +247,9 @@ internal sealed class PlatformAdminService(
|
||||
ToBillingStatus = tenant.BillingStatus,
|
||||
command.Reason
|
||||
});
|
||||
await provider.GetRequiredService<ISecurityEventPublisher>().AuthorizationChangedAsync(
|
||||
tenant.Id,
|
||||
null,
|
||||
"tenant_status_changed",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
$"tenant-status-{tenant.Id:N}",
|
||||
cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await provider.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(tenant.Id, cancellationToken);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSaasSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
|
||||
@@ -11,7 +11,6 @@ using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
@@ -6,7 +6,6 @@ using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformBilling;
|
||||
|
||||
@@ -142,13 +141,6 @@ internal sealed class PlatformBillingAdminService(
|
||||
await db.SaveChangesAsync(token);
|
||||
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(command.TenantId, token);
|
||||
await services.GetRequiredService<ISecurityEventPublisher>().CapabilityChangedAsync(
|
||||
command.TenantId,
|
||||
featureCode,
|
||||
"tenant_feature_override_changed",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
$"tenant-feature-override-{item.Id:N}",
|
||||
token);
|
||||
return item;
|
||||
}, cancellationToken);
|
||||
|
||||
|
||||
@@ -7,13 +7,11 @@ using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformBilling;
|
||||
|
||||
internal sealed class PlatformBillingSettlementService(
|
||||
TikuDbContext dbContext,
|
||||
ISecurityEventPublisher securityEventPublisher,
|
||||
ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService
|
||||
{
|
||||
public async Task<PlatformBillingPayment> MarkPaidAsync(
|
||||
@@ -80,10 +78,6 @@ internal sealed class PlatformBillingSettlementService(
|
||||
var subscription = await dbContext.TenantSaasSubscriptions
|
||||
.OrderByDescending(value => value.UpdatedAt)
|
||||
.FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken);
|
||||
var oldFeatures = subscription is null
|
||||
? []
|
||||
: await LoadSubscriptionFeaturesAsync(subscription, cancellationToken);
|
||||
|
||||
var now = paidAt;
|
||||
if (subscription is null)
|
||||
{
|
||||
@@ -204,41 +198,9 @@ internal sealed class PlatformBillingSettlementService(
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken);
|
||||
|
||||
var newFeatures = await LoadSubscriptionFeaturesAsync(subscription, cancellationToken);
|
||||
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var featureCode in changedFeatures)
|
||||
{
|
||||
await securityEventPublisher.CapabilityChangedAsync(
|
||||
order.TenantId,
|
||||
featureCode,
|
||||
"saas_subscription_changed",
|
||||
version,
|
||||
$"platform-billing-order-{order.Id:N}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
private async Task<string[]> LoadSubscriptionFeaturesAsync(TenantSaasSubscription subscription, CancellationToken cancellationToken)
|
||||
{
|
||||
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
|
||||
.Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id &&
|
||||
value.Status == TenantSaasSubscriptionItemStatus.Active)
|
||||
.Select(value => value.OfferingVersionId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
|
||||
{
|
||||
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
|
||||
}
|
||||
return await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
|
||||
.Where(value => versionIds.Contains(value.OfferingVersionId))
|
||||
.Select(value => value.FeatureCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await dbContext.TenantBillingProfiles.AsNoTracking()
|
||||
|
||||
@@ -8,7 +8,6 @@ using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformBilling;
|
||||
@@ -93,7 +92,6 @@ internal sealed class SaasSubscriptionLifecycleService(
|
||||
|
||||
var previousStatus = subscription.Status;
|
||||
var previousBaseVersionId = subscription.BaseOfferingVersionId;
|
||||
var oldFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
|
||||
var items = await dbContext.TenantSaasSubscriptionItems
|
||||
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
@@ -209,23 +207,6 @@ internal sealed class SaasSubscriptionLifecycleService(
|
||||
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(tenantId, cancellationToken);
|
||||
|
||||
var newFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
|
||||
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var eventPublisher = services.GetRequiredService<ISecurityEventPublisher>();
|
||||
var eventVersion = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var correlationId = $"saas-subscription-{subscription.Id:N}-lifecycle-{subscription.LifecycleVersion}";
|
||||
foreach (var featureCode in changedFeatures)
|
||||
{
|
||||
await eventPublisher.CapabilityChangedAsync(
|
||||
tenantId,
|
||||
featureCode,
|
||||
transition,
|
||||
eventVersion,
|
||||
correlationId,
|
||||
cancellationToken);
|
||||
}
|
||||
await services.GetRequiredService<ITenantRuntimeCacheInvalidator>()
|
||||
.InvalidateAsync(tenantId, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -251,26 +232,4 @@ internal sealed class SaasSubscriptionLifecycleService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string[]> LoadFeaturesAsync(
|
||||
TikuDbContext dbContext,
|
||||
TenantSaasSubscription subscription,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
|
||||
.Where(value =>
|
||||
value.TenantId == subscription.TenantId &&
|
||||
value.SubscriptionId == subscription.Id &&
|
||||
value.Status == TenantSaasSubscriptionItemStatus.Active)
|
||||
.Select(value => value.OfferingVersionId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
|
||||
{
|
||||
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
|
||||
}
|
||||
return await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
|
||||
.Where(value => versionIds.Contains(value.OfferingVersionId))
|
||||
.Select(value => value.FeatureCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,17 +96,6 @@ internal sealed class RedisSecurityStore(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{prefix}:auth-inv:{Normalize(realm)}:{tenantId?.ToString("N") ?? "-"}:{userId?.ToString("N") ?? "-"}";
|
||||
await connection.GetDatabase().StringSetAsync(key, version, TimeSpan.FromDays(2)).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
string.Concat(value.Trim().ToLowerInvariant().Select(character =>
|
||||
char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'));
|
||||
@@ -122,13 +111,6 @@ public sealed class NullRedisSecurityStore : IRedisSecurityStore
|
||||
Task.FromResult(new DistributedRateLimitResult(true));
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
|
||||
public Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed class RedisSecurityUnavailableException(Exception innerException)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
@@ -13,11 +11,8 @@ internal sealed class TenantFeatureCacheInvalidator(
|
||||
IMemoryCache memoryCache,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
|
||||
ILogger<TenantFeatureCacheInvalidator> logger) : ITenantFeatureCacheInvalidator, IHostedService
|
||||
ILogger<TenantFeatureCacheInvalidator> logger) : ITenantFeatureCacheInvalidator
|
||||
{
|
||||
private const string ChannelName = "tiku:tenant-feature-snapshot:invalidate:v1";
|
||||
private ISubscriber? subscriber;
|
||||
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RemoveMemory(tenantId);
|
||||
@@ -36,47 +31,6 @@ internal sealed class TenantFeatureCacheInvalidator(
|
||||
logger.LogWarning(exception, "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
|
||||
if (connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.GetSubscriber()
|
||||
.PublishAsync(RedisChannel.Literal(ChannelName), tenantId.ToString("N"))
|
||||
.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
logger.LogWarning(exception, "Tenant feature L1 invalidation broadcast failed for tenant {TenantId}.", tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
|
||||
if (connection is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber = connection.GetSubscriber();
|
||||
await subscriber.SubscribeAsync(RedisChannel.Literal(ChannelName), (_, value) =>
|
||||
{
|
||||
if (Guid.TryParseExact(value.ToString(), "N", out var tenantId))
|
||||
{
|
||||
RemoveMemory(tenantId);
|
||||
}
|
||||
}).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (subscriber is not null)
|
||||
{
|
||||
await subscriber.UnsubscribeAsync(RedisChannel.Literal(ChannelName)).WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveMemory(Guid tenantId)
|
||||
|
||||
@@ -15,7 +15,6 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||
@@ -30,7 +29,6 @@ public sealed class TenantAdminDirectService(
|
||||
INotificationProvider notificationProvider,
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore,
|
||||
ISecurityEventPublisher securityEventPublisher,
|
||||
IFeatureAccessService featureAccessService) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
@@ -1263,9 +1261,6 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await securityEventPublisher.MembershipChangedAsync(
|
||||
actor.TenantId, user.Id, previousStatus, status.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (wasStaffCounted && !willStaffBeCounted)
|
||||
{
|
||||
@@ -1322,9 +1317,6 @@ public sealed class TenantAdminDirectService(
|
||||
membership.Status = MembershipStatus.Disabled;
|
||||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
||||
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await securityEventPublisher.MembershipChangedAsync(
|
||||
actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Contracts\Tiku.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -19,9 +18,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
<PackageReference Include="MassTransit.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
|
||||
@@ -56,6 +56,7 @@ public sealed class ApiTestFactory(
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["BackgroundProcessing:Enabled"] = "false",
|
||||
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
|
||||
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
|
||||
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
|
||||
|
||||
@@ -7,17 +7,15 @@ 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 = 397;
|
||||
private const string ExpectedSha256 = "fe0636f609e86c8c7540d84914f8254106d20194ee5bc715d616c5a4f84c7a94";
|
||||
private const int ExpectedActionCount = 399;
|
||||
private const string ExpectedSha256 = "e4460d18dbd88cb8a4293c650688f03ddaa67e69a423e70d6675c635e237c316";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
@@ -71,30 +69,6 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("questions", SaasFeatureCatalog.PrivateQuestionBank)]
|
||||
[InlineData("vocabulary", SaasFeatureCatalog.Vocabulary)]
|
||||
|
||||
192
Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs
Normal file
192
Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
using System.Net;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class MonolithBackgroundProcessingTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string?> EnabledConfiguration =
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["BackgroundProcessing:Enabled"] = "true",
|
||||
["BackgroundProcessing:JobPollSeconds"] = "1",
|
||||
["BackgroundProcessing:JobParallelism"] = "2",
|
||||
["BackgroundProcessing:JobBatchSize"] = "2",
|
||||
["TenantDomains:Enabled"] = "false",
|
||||
["SaasSubscriptions:Enabled"] = "false",
|
||||
["FeatureUsageReconciliation:Enabled"] = "false"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Readiness_reports_only_postgres_and_redis_dependencies()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/health/ready");
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(document.RootElement.TryGetProperty("database", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("redis", out _));
|
||||
Assert.False(document.RootElement.TryGetProperty("rabbitMq", out _));
|
||||
Assert.False(document.RootElement.TryGetProperty("outbox", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_processing_registration_obeys_master_switch()
|
||||
{
|
||||
await using var disabledFactory = new ApiTestFactory();
|
||||
using var disabledClient = disabledFactory.CreateClient();
|
||||
var disabledNames = disabledFactory.Services.GetServices<IHostedService>()
|
||||
.Select(service => service.GetType().Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.False(disabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
|
||||
Assert.Contains("TenantDomainBackgroundService", disabledNames);
|
||||
Assert.Contains("SaasSubscriptionBackgroundService", disabledNames);
|
||||
Assert.Contains("FeatureUsageBackgroundService", disabledNames);
|
||||
Assert.Contains("BackgroundJobsBackgroundService", disabledNames);
|
||||
|
||||
await using var enabledFactory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
|
||||
using var enabledClient = enabledFactory.CreateClient();
|
||||
var enabledNames = enabledFactory.Services.GetServices<IHostedService>()
|
||||
.Select(service => service.GetType().Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(enabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
|
||||
Assert.Contains("TenantDomainBackgroundService", enabledNames);
|
||||
Assert.Contains("SaasSubscriptionBackgroundService", enabledNames);
|
||||
Assert.Contains("FeatureUsageBackgroundService", enabledNames);
|
||||
Assert.Contains("BackgroundJobsBackgroundService", enabledNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_host_processes_immediate_and_due_postgres_jobs()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
|
||||
using var client = factory.CreateClient();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Monolith Background Processing"
|
||||
});
|
||||
|
||||
BackgroundJobItem immediate;
|
||||
BackgroundJobItem delayed;
|
||||
using (var scope = factory.CreateSystemScope("Queue monolith background jobs"))
|
||||
{
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
immediate = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" })));
|
||||
delayed = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" }),
|
||||
DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||
}
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, immediate.Id));
|
||||
Assert.Equal(BackgroundJobStatus.Pending, await ReadStatusAsync(factory, delayed.Id));
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Make delayed monolith job due"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
await dbContext.BackgroundJobs
|
||||
.Where(job => job.Id == delayed.Id)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(job => job.RunAfter, DateTimeOffset.UtcNow.AddSeconds(-1)));
|
||||
}
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, delayed.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Postgres_leases_recover_expired_work_without_duplicate_claims()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Monolith Lease Recovery"
|
||||
});
|
||||
|
||||
Guid expiredJobId;
|
||||
Guid pendingJobId;
|
||||
using (var scope = factory.CreateSystemScope("Seed lease recovery jobs"))
|
||||
{
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
expiredJobId = (await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" })))).Id;
|
||||
pendingJobId = (await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" })))).Id;
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
await dbContext.BackgroundJobs
|
||||
.Where(job => job.Id == expiredJobId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(job => job.Status, BackgroundJobStatus.Processing)
|
||||
.SetProperty(job => job.LockedBy, "stopped-worker")
|
||||
.SetProperty(job => job.LockExpiresAt, DateTimeOffset.UtcNow.AddSeconds(-1)));
|
||||
}
|
||||
|
||||
var processed = await Task.WhenAll(
|
||||
ProcessPendingAsync(factory, "monolith-lease-a"),
|
||||
ProcessPendingAsync(factory, "monolith-lease-b"));
|
||||
|
||||
Assert.Equal(2, processed.Sum());
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, expiredJobId));
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, pendingJobId));
|
||||
}
|
||||
|
||||
private static async Task<int> ProcessPendingAsync(ApiTestFactory factory, string workerId)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope($"Process jobs with {workerId}");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(workerId, 1);
|
||||
}
|
||||
|
||||
private static async Task<BackgroundJobStatus> WaitForStatusAsync(ApiTestFactory factory, Guid jobId)
|
||||
{
|
||||
var timeout = DateTimeOffset.UtcNow.AddSeconds(10);
|
||||
while (DateTimeOffset.UtcNow < timeout)
|
||||
{
|
||||
var status = await ReadStatusAsync(factory, jobId);
|
||||
if (status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
return await ReadStatusAsync(factory, jobId);
|
||||
}
|
||||
|
||||
private static async Task<BackgroundJobStatus> ReadStatusAsync(ApiTestFactory factory, Guid jobId)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Read monolith background job status");
|
||||
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>()
|
||||
.BackgroundJobs.AsNoTracking()
|
||||
.Where(job => job.Id == jobId)
|
||||
.Select(job => job.Status)
|
||||
.SingleAsync();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ public sealed class PlatformAdminFrontendSeparationTests
|
||||
[InlineData("/platform-admin")]
|
||||
[InlineData("/platform-admin/")]
|
||||
[InlineData("/platform-admin/app.js")]
|
||||
public async Task WebApi_does_not_host_platform_admin_frontend(string path)
|
||||
public async Task Unauthenticated_platform_admin_paths_use_the_api_fallback_policy(string path)
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient(new()
|
||||
@@ -18,6 +18,6 @@ public sealed class PlatformAdminFrontendSeparationTests
|
||||
|
||||
using var response = await client.GetAsync(path);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_processes_content_export_and_statistics_aggregation()
|
||||
public async Task Background_service_processes_content_export_and_statistics_aggregation()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class ArchitectureBoundaryTests
|
||||
public void Auth_sessions_are_accessed_only_through_the_session_store()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure", "Tiku.Worker" };
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure" };
|
||||
var allowedFiles = new[]
|
||||
{
|
||||
"TikuDbContext.cs",
|
||||
@@ -228,7 +228,7 @@ public sealed class ArchitectureBoundaryTests
|
||||
public void Production_code_does_not_reference_removed_wechat_payment_sdk()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure", "Tiku.Worker" };
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure" };
|
||||
var forbidden = new[]
|
||||
{
|
||||
"SKIT.FlurlHttpClient.Wechat"
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
using MassTransit;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
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;
|
||||
|
||||
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()
|
||||
{
|
||||
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||
var redisConnection = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||
if (string.IsNullOrWhiteSpace(rabbitMqHost) || string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
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 redisEnvironment = $"consumer-{Guid.NewGuid():N}";
|
||||
var workerBuilder = Host.CreateApplicationBuilder();
|
||||
workerBuilder.Services.AddApplication();
|
||||
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
|
||||
workerBuilder.Services.AddRedisSecurity(redisConnection, redisEnvironment);
|
||||
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
|
||||
using var worker = workerBuilder.Build();
|
||||
await worker.StartAsync();
|
||||
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var messageId = Guid.NewGuid();
|
||||
const long version = 123456789;
|
||||
using (var scope = factory.CreateSystemScope("Publish duplicate inbox test message"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publishEndpoint = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
var message = new AuthorizationStateChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, "consumer_test", version,
|
||||
DateTimeOffset.UtcNow, Guid.NewGuid().ToString("N"));
|
||||
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
var redisKey = $"tiku:{redisEnvironment}:auth-inv:authorization:{tenantId:N}:{userId:N}";
|
||||
var consumed = false;
|
||||
for (var attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
var multiplexer = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||
if (await multiplexer.GetDatabase().StringGetAsync(redisKey) == version)
|
||||
{
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
Assert.True(consumed, "Worker did not consume the security event within 15 seconds.");
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify duplicate consumer inbox"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(await dbContext.Set<InboxState>()
|
||||
.Where(item => item.MessageId == messageId)
|
||||
.ToArrayAsync());
|
||||
}
|
||||
|
||||
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
|
||||
if (managementEndpoint is not null)
|
||||
{
|
||||
Assert.Equal(0, await GetQueueMessageCountAsync(
|
||||
managementEndpoint, "security-state-changed_error"));
|
||||
}
|
||||
|
||||
var redis = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||
await redis.GetDatabase().KeyDeleteAsync(redisKey);
|
||||
await worker.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RabbitMq_health_and_bus_outbox_follow_database_transaction()
|
||||
{
|
||||
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 ready = await client.GetAsync("/api/health/ready");
|
||||
using var readyJson = JsonDocument.Parse(await ready.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
|
||||
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("configured").GetBoolean());
|
||||
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("ready").GetBoolean());
|
||||
|
||||
using (var rollbackScope = factory.CreateSystemScope("Verify rolled back bus outbox"))
|
||||
{
|
||||
var dbContext = rollbackScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publisher = rollbackScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await publisher.AuthorizationChangedAsync(
|
||||
null, null, "rollback_test", 1, Guid.NewGuid().ToString("N"));
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
|
||||
using (var verification = factory.CreateSystemScope("Verify rolled back outbox is empty"))
|
||||
{
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Empty(await dbContext.Set<OutboxMessage>().ToArrayAsync());
|
||||
}
|
||||
|
||||
using (var commitScope = factory.CreateSystemScope("Verify committed bus outbox"))
|
||||
{
|
||||
var dbContext = commitScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var publisher = commitScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
await publisher.AuthorizationChangedAsync(
|
||||
null, null, "commit_test", 2, Guid.NewGuid().ToString("N"));
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
var drained = false;
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
using var verification = factory.CreateSystemScope("Wait for committed outbox delivery");
|
||||
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
|
||||
{
|
||||
drained = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
if (!drained)
|
||||
{
|
||||
using var diagnostics = factory.CreateSystemScope("Inspect undelivered outbox");
|
||||
var dbContext = diagnostics.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var messages = await dbContext.Set<OutboxMessage>().CountAsync();
|
||||
var states = await dbContext.Set<OutboxState>().CountAsync();
|
||||
Assert.Fail($"Committed MassTransit outbox was not delivered within 10 seconds. messages={messages}, states={states}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Api.ApiTestFactory CreateRabbitFactory(string rabbitMqHost) =>
|
||||
new(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["RabbitMq:Host"] = rabbitMqHost,
|
||||
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest"
|
||||
});
|
||||
|
||||
private static MessagingOptions CreateRabbitOptions(string rabbitMqHost, bool configureConsumers) => new()
|
||||
{
|
||||
Host = rabbitMqHost,
|
||||
Username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||
Password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest",
|
||||
ConfigureConsumers = configureConsumers
|
||||
};
|
||||
|
||||
private static async Task<bool> WaitForReadyAsync(HttpClient client, int attempts = 40)
|
||||
{
|
||||
for (var attempt = 0; attempt < attempts; attempt++)
|
||||
{
|
||||
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
await Task.Delay(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Uri? ResolveRabbitManagementEndpoint(string rabbitMqHost)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_MANAGEMENT");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return new Uri(configured);
|
||||
}
|
||||
|
||||
var broker = new Uri(rabbitMqHost);
|
||||
return broker.IsLoopback ? new Uri($"http://{broker.Host}:15672") : null;
|
||||
}
|
||||
|
||||
private static async Task<int> GetQueueMessageCountAsync(Uri managementEndpoint, string queueName)
|
||||
{
|
||||
using var client = new HttpClient { BaseAddress = managementEndpoint };
|
||||
var username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest";
|
||||
var password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest";
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Api\Tiku.Api.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Worker\Tiku.Worker.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Worker;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
var connectionString = builder.Configuration.GetConnectionString("Database")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException(
|
||||
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
|
||||
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
|
||||
"Redis is required in Production.")
|
||||
.ValidateOnStart();
|
||||
if (!string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||
}
|
||||
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||
builder.Services.AddOptions<MessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() ||
|
||||
(options.IsConfigured &&
|
||||
!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)
|
||||
{
|
||||
messaging.ConfigureConsumers = true;
|
||||
builder.Services.AddReliableMessaging(messaging);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
|
||||
}
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddHostedService<TenantDomainWorker>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionWorker>();
|
||||
builder.Services.AddHostedService<FeatureUsageWorker>();
|
||||
builder.Services.AddHostedService<BackgroundJobsWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Tiku.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Tiku.Worker-585d1830-c302-47fe-8009-595679bcc54c</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,96 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
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}";
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var domainCount = await ProcessTenantDomainsAsync(stoppingToken);
|
||||
var subscriptionCount = await ProcessSaasSubscriptionsAsync(stoppingToken);
|
||||
var usageReconciliationCount = await ProcessFeatureUsageReconciliationAsync(stoppingToken);
|
||||
var jobCount = await ProcessBackgroundJobsAsync(stoppingToken);
|
||||
if (domainCount > 0 || subscriptionCount > 0 || usageReconciliationCount > 0 || jobCount > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Worker processed {DomainCount} pending tenant domains, {SubscriptionCount} SaaS subscriptions, {UsageReconciliationCount} tenant usage reconciliations, and {JobCount} background jobs.",
|
||||
domainCount,
|
||||
subscriptionCount,
|
||||
usageReconciliationCount,
|
||||
jobCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Tenant domain lifecycle processing failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)),
|
||||
stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ProcessTenantDomainsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessBackgroundJobsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Background job lease worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
workerId,
|
||||
20,
|
||||
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||
cancellationToken: stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessSaasSubscriptionsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "SaaS subscription lifecycle discovery worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessFeatureUsageReconciliationAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Tenant feature usage reconciliation discovery worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
"BatchSize": 50,
|
||||
"DnsJsonEndpoint": "https://cloudflare-dns.com/dns-query",
|
||||
"VerificationRecordPrefix": "_tiku-verification",
|
||||
"AllowedCnameTargets": [],
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"PastDueGraceDays": 7
|
||||
},
|
||||
"FeatureUsageReconciliation": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"IntervalMinutes": 60
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,17 @@
|
||||
|
||||
| 文档 | 内容 | 适合谁 |
|
||||
| --- | --- | --- |
|
||||
| [本地开发与运行](quickstart.md) | PostgreSQL 初始化、启动 API/Worker、验证命令、常见问题 | 新开发者 |
|
||||
| [系统架构与业务边界](architecture/overview.md) | 项目依赖、运行时组件、当前业务模块、请求与消息链路 | 开发与评审人员 |
|
||||
| [本地开发与运行](quickstart.md) | PostgreSQL 初始化、启动 API、验证命令、常见问题 | 新开发者 |
|
||||
| [系统架构与业务边界](architecture/overview.md) | 项目依赖、运行时组件、当前业务模块和后台任务链路 | 开发与评审人员 |
|
||||
| [认证、授权与租户隔离](architecture/security-and-tenancy.md) | 登录、Session、JWT、Cookie/CSRF、Realm、RBAC、Capability、DataScope、租户隔离 | API 与安全开发者 |
|
||||
| [配置与后台任务](operations.md) | 环境配置、Production 启动门禁、Redis/RabbitMQ、Worker、健康检查 | 开发与运维人员 |
|
||||
| [配置与后台任务](operations.md) | 环境配置、Production 启动门禁、Redis、Hosted Service、健康检查 | 开发与运维人员 |
|
||||
|
||||
## 权威来源
|
||||
|
||||
- API 契约:`Tiku.Api/Controllers`、请求/响应 DTO 和运行时 OpenAPI。
|
||||
- 数据模型:`Tiku.Domain`、`Tiku.Infrastructure/Persistence/Configurations` 和 EF Core Migration。
|
||||
- 认证授权:`Tiku.Api/Configuration`、`Tiku.Api/Middleware`、`Tiku.Application/Security`、`Tiku.Infrastructure/Security`。
|
||||
- 后台任务:`Tiku.Worker`、`Tiku.Application/Jobs`、`Tiku.Infrastructure/Jobs` 和 `Tiku.Infrastructure/Messaging`。
|
||||
- 后台任务:`Tiku.Api/BackgroundProcessing`、`Tiku.Application/Jobs` 和 `Tiku.Infrastructure/Jobs`。
|
||||
- 外部服务:Application 接口与 Infrastructure 实现;运行时租户配置存储在 `TenantExternalProvider` 和 `TenantSecret`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -5,15 +5,12 @@
|
||||
## 分层与依赖
|
||||
|
||||
```text
|
||||
+------------------+
|
||||
| Tiku.Contracts |
|
||||
+--------^---------+
|
||||
|
|
||||
+-----------+ +---------------+---------------+
|
||||
| Tiku.Api | | Tiku.Worker / Tiku.DbMigrator |
|
||||
+-----+-----+ +---------------+---------------+
|
||||
+-------------------------+ +-----------------+
|
||||
| Tiku.Api | | Tiku.DbMigrator |
|
||||
| HTTP + Hosted Services | | Migrate + Seed |
|
||||
+------------+------------+ +--------+--------+
|
||||
| |
|
||||
+-------------+-------------+
|
||||
+--------------+--------------+
|
||||
v
|
||||
+---------------------+
|
||||
| Tiku.Infrastructure |
|
||||
@@ -30,9 +27,8 @@
|
||||
|
||||
- `Tiku.Domain` 保存领域实体、枚举和基础类型。除 Identity stores 抽象外,不依赖持久化或 Provider SDK。
|
||||
- `Tiku.Application` 定义用例契约、Provider 接口、安全上下文和业务目录,依赖 Domain。
|
||||
- `Tiku.Infrastructure` 实现 EF Core、PostgreSQL、Identity、外部 Provider、消息和后台任务,依赖 Application、Domain 与 Contracts。
|
||||
- `Tiku.Contracts` 保存 API 与 Worker 使用的版本化消息 DTO,不引用 HTTP、EF Core 或 Provider SDK。
|
||||
- `Tiku.Api`、`Tiku.Worker` 和 `Tiku.DbMigrator` 是独立运行入口。
|
||||
- `Tiku.Infrastructure` 实现 EF Core、PostgreSQL、Identity、外部 Provider 和后台任务,依赖 Application 与 Domain。
|
||||
- `Tiku.Api` 是唯一运行时入口,`Tiku.DbMigrator` 是部署时迁移和 seed 入口。
|
||||
|
||||
## 运行时组件
|
||||
|
||||
@@ -65,29 +61,29 @@ OpenAPI 和 Scalar 只在 Development 映射。平台管理端位于独立的 `T
|
||||
4. seed 内置 SaaS Feature、PermissionModule、BackendPermission 和 BackendMenu 目录。
|
||||
5. Development 全新数据库自动 seed 平台管理员;非 Development 仅在显式传入 `--bootstrap-platform-admin` 时创建管理员。
|
||||
|
||||
API 和 Worker 都不自动迁移数据库。
|
||||
API 不自动迁移数据库。
|
||||
|
||||
### Worker
|
||||
### API 后台处理
|
||||
|
||||
`Tiku.Worker` 当前注册四个独立 Hosted Service:
|
||||
`Tiku.Api` 在 `BackgroundProcessing:Enabled=true` 时注册四个 Hosted Service:
|
||||
|
||||
| Worker | 周期 | 当前职责 |
|
||||
| Hosted Service | 周期 | 当前职责 |
|
||||
| --- | --- | --- |
|
||||
| `TenantDomainWorker` | `TenantDomains:PollSeconds`,限制为 10~3600 秒 | 校验自定义域名 CNAME/TXT,调用网关 TLS 接口并失效租户缓存 |
|
||||
| `SaasSubscriptionWorker` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
|
||||
| `FeatureUsageWorker` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 1~1440 分钟 | 按真实业务数据校准租户 Feature 用量 |
|
||||
| `BackgroundJobsWorker` | 2 秒,4 个分区 | 租约处理 PostgreSQL 中的延时/待执行后台任务;未配置 RabbitMQ 时也处理即时任务 |
|
||||
| `TenantDomainBackgroundService` | `TenantDomains:PollSeconds`,限制为 10~3600 秒 | 校验自定义域名 CNAME/TXT,调用网关 TLS 接口并失效租户缓存 |
|
||||
| `SaasSubscriptionBackgroundService` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
|
||||
| `FeatureUsageBackgroundService` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 1~1440 分钟 | 按真实业务数据校准租户 Feature 用量 |
|
||||
| `BackgroundJobsBackgroundService` | 默认 2 秒、4 个分区 | 使用租约处理 PostgreSQL 中的即时、延时和待重试任务 |
|
||||
|
||||
后台任务当前支持 `content_import`、`content_export`、`statistics_aggregation`、`commerce_reconciliation` 和 `tenant_domain_recheck`。`asset_security_scan` 会明确失败,直到配置实际扫描 Provider;不能把它描述为已接通扫描服务。
|
||||
|
||||
配置 RabbitMQ 后,即时安全事件和后台任务请求使用 MassTransit;API 使用 EF Bus Outbox,Worker Consumer 使用 EF inbox/outbox。延时任务仍由 PostgreSQL `RunAfter` 和租约 Worker 处理。
|
||||
即时任务与 `RunAfter` 延时任务统一写入 `background_jobs`。Hosted Service 使用 `FOR UPDATE SKIP LOCKED` 认领任务,五分钟租约支持 API 重启后的恢复;当前生产部署按单 API 实例设计。
|
||||
|
||||
## 数据与持久化
|
||||
|
||||
- 数据库使用标准 PostgreSQL,普通 schema 由 EF Core entity、Fluent Configuration 和 Migration 管理。
|
||||
- 当前模型启用 `citext`、`ltree` 和 `pg_trgm` 扩展,并统一映射为 `snake_case`。
|
||||
- Data Protection key ring 由 API 持久化到 PostgreSQL;非 Development 必须使用 X509 证书保护。
|
||||
- MassTransit inbox/outbox 表与业务表处于同一 `TikuDbContext`。
|
||||
- 后台任务状态、执行时间、重试和结果由 `background_jobs` 持久化。
|
||||
- PostgreSQL 不启用 RLS;租户隔离由应用和数据库多层共同保证,详见[认证、授权与租户隔离](security-and-tenancy.md)。
|
||||
|
||||
## 当前业务模块
|
||||
|
||||
@@ -108,17 +108,14 @@ Development 默认平台 Host 是 `localhost` 和 `127.0.0.1`。Production 启
|
||||
|
||||
这些 guard 由 Migration helper 统一安装和移除,不允许在多份 Migration 中复制 SQL。
|
||||
|
||||
## System Scope 与可靠事件
|
||||
## System Scope 与后台处理
|
||||
|
||||
跨租户 Worker、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope,并提供明确原因。业务代码不得直接关闭 Query Filter。
|
||||
跨租户 Hosted Service、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope,并提供明确原因。业务代码不得直接关闭 Query Filter。
|
||||
|
||||
配置 RabbitMQ 时:
|
||||
|
||||
- API 使用 EF Bus Outbox,把业务写入、审计和消息放在同一数据库事务边界。
|
||||
- Worker Consumer 使用 EF inbox/outbox 和有限即时重试。
|
||||
- Session、成员、租户和套餐状态始终从 PostgreSQL 重新校验,不等待消息消费后才失效。
|
||||
- 延时/定时重试使用 PostgreSQL `RunAfter`,不依赖 RabbitMQ delayed-message 插件。
|
||||
- Session、成员、租户和套餐状态始终从 PostgreSQL 重新校验。
|
||||
- 租户、套餐和 Feature 变更在数据库提交后直接失效当前 API 进程与 Redis 中的相关缓存。
|
||||
- 后台任务在业务事务提交后持久化到 PostgreSQL,Hosted Service 使用租约执行;延时和重试由 `RunAfter` 控制。
|
||||
|
||||
## 安全配置门禁
|
||||
|
||||
Production 还会在启动时验证 Redis、RabbitMQ、Data Protection 证书、租户 Secret master key、短信 pepper、CORS 和外部服务配置。完整配置入口见[配置与后台任务](../operations.md)。
|
||||
Production 还会在启动时验证 Redis、Data Protection 证书、租户 Secret master key、短信 pepper、CORS 和外部服务配置。完整配置入口见[配置与后台任务](../operations.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1080" viewBox="0 0 1600 1080" role="img" aria-labelledby="title desc">
|
||||
<title id="title">TIKU Backend 当前技术架构图</title>
|
||||
<desc id="desc">TIKU Backend 是 ASP.NET Core 模块化单体。API、Worker 和 DbMigrator 共享 Application 契约、Infrastructure 实现以及 PostgreSQL、Redis、RabbitMQ 和外部服务。</desc>
|
||||
<desc id="desc">TIKU Backend 是 ASP.NET Core 模块化单体。API 承载 HTTP 与后台 Hosted Service,DbMigrator 负责迁移,运行时依赖 PostgreSQL、Redis 和外部服务。</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0 0L10 5L0 10Z" fill="#334155"/>
|
||||
@@ -148,18 +148,18 @@
|
||||
<text class="edge-label" x="844" y="418">实现 Application 接口并操作 Domain</text>
|
||||
|
||||
<!-- Processes -->
|
||||
<text class="group-title" x="1318" y="129">独立运行入口</text>
|
||||
<text class="group-title" x="1318" y="129">单体后台处理与迁移</text>
|
||||
<rect class="group" x="1310" y="145" width="250" height="570" rx="4"/>
|
||||
|
||||
<rect class="node-green" x="1336" y="180" width="198" height="192" rx="6"/>
|
||||
<circle class="badge" cx="1368" cy="211" r="18"/>
|
||||
<text class="badge-text" x="1368" y="211">W</text>
|
||||
<text class="node-title" x="1398" y="207">Tiku.Worker</text>
|
||||
<text class="badge-text" x="1368" y="211">BG</text>
|
||||
<text class="node-title" x="1398" y="207">API Hosted Services</text>
|
||||
<text class="node-text" x="1354" y="242">域名 DNS / TLS 生命周期</text>
|
||||
<text class="node-text" x="1354" y="269">SaaS 订阅生命周期</text>
|
||||
<text class="node-text" x="1354" y="296">Feature 用量校准</text>
|
||||
<text class="node-text" x="1354" y="323">后台任务租约执行</text>
|
||||
<text class="node-text" x="1354" y="350">RabbitMQ 消费者</text>
|
||||
<text class="node-text" x="1354" y="350">与 HTTP 共用 API 进程</text>
|
||||
|
||||
<rect class="node-amber" x="1336" y="408" width="198" height="144" rx="6"/>
|
||||
<circle class="badge" cx="1368" cy="439" r="18"/>
|
||||
@@ -177,7 +177,7 @@
|
||||
<path class="edge-dash" d="M1336 480H1242"/>
|
||||
|
||||
<!-- Data and external systems -->
|
||||
<text class="group-title" x="48" y="773">数据、消息与外部服务</text>
|
||||
<text class="group-title" x="48" y="773">数据与外部服务</text>
|
||||
<rect class="group" x="40" y="790" width="1520" height="212" rx="4"/>
|
||||
|
||||
<rect class="node-violet" x="70" y="826" width="270" height="138" rx="6"/>
|
||||
@@ -185,24 +185,24 @@
|
||||
<text class="badge-text" x="105" y="859">PG</text>
|
||||
<text class="node-title" x="139" y="854">PostgreSQL</text>
|
||||
<text class="node-text" x="92" y="892">EF Core 实体 / Identity / AuthSession</text>
|
||||
<text class="node-text" x="92" y="917">业务数据 / BackgroundJob / Outbox</text>
|
||||
<text class="node-text" x="92" y="917">业务数据 / BackgroundJob / Session</text>
|
||||
<text class="node-tiny" x="92" y="944">Query Filter · 写入拦截 · 数据库 Guard</text>
|
||||
|
||||
<rect class="node-violet" x="372" y="826" width="205" height="138" rx="6"/>
|
||||
<circle class="badge" cx="407" cy="859" r="20"/>
|
||||
<text class="badge-text" x="407" y="859">R</text>
|
||||
<text class="node-title" x="441" y="854">Redis</text>
|
||||
<text class="node-text" x="394" y="892">安全状态与失效广播</text>
|
||||
<text class="node-text" x="394" y="917">分布式缓存</text>
|
||||
<text class="node-text" x="394" y="892">安全频控</text>
|
||||
<text class="node-text" x="394" y="917">Feature / Output Cache</text>
|
||||
<text class="node-tiny" x="394" y="944">Production 必需</text>
|
||||
|
||||
<rect class="node-violet" x="609" y="826" width="225" height="138" rx="6"/>
|
||||
<circle class="badge" cx="644" cy="859" r="20"/>
|
||||
<text class="badge-text" x="644" y="859">MQ</text>
|
||||
<text class="node-title" x="678" y="854">RabbitMQ</text>
|
||||
<text class="node-text" x="631" y="892">MassTransit + EF Outbox</text>
|
||||
<text class="node-text" x="631" y="917">安全状态 / 后台任务消息</text>
|
||||
<text class="node-tiny" x="631" y="944">API 发布 · Worker 消费</text>
|
||||
<text class="badge-text" x="644" y="859">JOB</text>
|
||||
<text class="node-title" x="678" y="854">PostgreSQL 任务租约</text>
|
||||
<text class="node-text" x="631" y="892">即时任务 / RunAfter</text>
|
||||
<text class="node-text" x="631" y="917">SKIP LOCKED / 重试</text>
|
||||
<text class="node-tiny" x="631" y="944">API Hosted Service 执行</text>
|
||||
|
||||
<rect class="node-blue" x="866" y="826" width="205" height="138" rx="6"/>
|
||||
<text class="node-title" x="890" y="856">对象存储</text>
|
||||
@@ -232,5 +232,5 @@
|
||||
|
||||
<!-- Project dependency direction -->
|
||||
<rect class="boundary" x="40" y="1025" width="1520" height="38" rx="4"/>
|
||||
<text class="boundary-text" x="61" y="1050">项目依赖方向:Api → Application + Infrastructure | Worker → Application + Infrastructure | DbMigrator → Infrastructure | Infrastructure → Application + Domain + Contracts | Application → Domain</text>
|
||||
<text class="boundary-text" x="61" y="1050">项目依赖方向:Api → Application + Infrastructure | DbMigrator → Infrastructure | Infrastructure → Application + Domain | Application → Domain</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,16 +1,15 @@
|
||||
# 配置与后台任务
|
||||
|
||||
本文列出 API、DbMigrator 和 Worker 当前实际读取的配置。敏感值应通过环境变量、Secret Manager 或部署平台密钥注入,不能提交到仓库。
|
||||
本文列出 API 和 DbMigrator 当前实际读取的配置。敏感值应通过环境变量、Secret Manager 或部署平台密钥注入,不能提交到仓库。
|
||||
|
||||
## 进程与依赖
|
||||
|
||||
| 进程 | PostgreSQL | Redis | RabbitMQ | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Tiku.Api` | 必需 | Development 可选;Production 必需 | Development 可选;Production 必需 | 提供 HTTP API、静态管理端、认证和 Outbox 发布 |
|
||||
| `Tiku.Worker` | 必需 | Development 可选;Production 必需 | Development 可选;Production 必需 | 消费消息并轮询后台任务 |
|
||||
| `Tiku.DbMigrator` | 必需 | 不需要 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导 |
|
||||
| 进程 | PostgreSQL | Redis | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Tiku.Api` | 必需 | Development 可选;Production 必需 | 提供 HTTP API、认证、缓存和 Hosted Service 后台处理 |
|
||||
| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导 |
|
||||
|
||||
Development 未配置 Redis 时,安全服务使用进程内/数据库防线;未配置 RabbitMQ 时,Worker 从 PostgreSQL 处理即时和延时任务。Production 不允许这两个降级模式。
|
||||
Development 未配置 Redis 时,安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
|
||||
|
||||
## 数据库
|
||||
|
||||
@@ -49,46 +48,22 @@ dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
|
||||
|
||||
连接串读取 `ConnectionStrings:Redis` 或 `REDIS_URL`。当前用途:
|
||||
|
||||
- 密码、短信发送和短信校验的跨实例安全窗口计数;
|
||||
- 安全状态和租户 Feature 缓存失效;
|
||||
- 密码、短信发送和短信校验的安全窗口计数;
|
||||
- 租户 Feature 分布式缓存;
|
||||
- Production 的 ASP.NET Core Output Cache。
|
||||
|
||||
Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`。Redis 不是用户、Session、权限、套餐或用量的权威数据源。
|
||||
|
||||
## RabbitMQ 与 Outbox
|
||||
|
||||
配置节:
|
||||
|
||||
```json
|
||||
{
|
||||
"RabbitMq": {
|
||||
"Host": "rabbitmq://localhost",
|
||||
"VirtualHost": "/",
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本地可用环境变量形式覆盖,例如 `RabbitMq__Host`。Production 必须同时提供有效 Host、Username 和 Password。
|
||||
|
||||
当前消息配置:
|
||||
|
||||
- kebab-case endpoint 名称;
|
||||
- PostgreSQL EF Bus Outbox,1 秒查询间隔;
|
||||
- Consumer 端 EF inbox/outbox;
|
||||
- Consumer 单并发、prefetch 1;
|
||||
- 1、5、15 秒有限即时重试;
|
||||
- 不使用 RabbitMQ delayed-message 插件,延时任务保留在 PostgreSQL。
|
||||
|
||||
API 只发布消息,不注册 Consumer;Worker 注册 `SecurityStateChangedConsumer` 和 `BackgroundJobRequestedConsumer`。
|
||||
|
||||
## Worker 配置
|
||||
## 后台处理配置
|
||||
|
||||
```json
|
||||
{
|
||||
"BackgroundProcessing": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
"JobBatchSize": 5
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
@@ -114,7 +89,7 @@ API 只发布消息,不注册 Consumer;Worker 注册 `SecurityStateChangedCo
|
||||
|
||||
域名只有在 `AllowedCnameTargets`、DNS JSON endpoint、Gateway URL 和 API key 配置完成后,才可能从 Pending/Failed 进入 Active。仅 DNS 验证成功不代表 TLS 已就绪。
|
||||
|
||||
后台任务状态和 `RunAfter` 存在 PostgreSQL。Worker 使用租约并发处理;同一即时任务在启用 RabbitMQ 后不会同时进入消息 Consumer 和数据库即时轮询路径。
|
||||
后台任务状态和 `RunAfter` 存在 PostgreSQL。API Hosted Service 使用 `FOR UPDATE SKIP LOCKED`、五分钟租约和有限重试处理即时、延时及失败待重试任务。`BackgroundProcessing:Enabled=false` 会关闭全部四个后台循环,通常只用于测试或维护。
|
||||
|
||||
## 安全与网络配置
|
||||
|
||||
@@ -152,9 +127,14 @@ Production 启动至少需要核对:
|
||||
## 健康检查与观测
|
||||
|
||||
- `GET /api/health`:轻量 liveness,只说明 API 进程可响应。
|
||||
- `GET /api/health/ready`:检查 PostgreSQL、已配置 Redis、已配置 RabbitMQ,并报告 Outbox pending、最老消息年龄和告警阈值;依赖未就绪时返回 503。
|
||||
- API 每 30 秒采样一次 Outbox backlog,并暴露 `tiku.outbox.pending` 与 `tiku.outbox.oldest_age` meter。
|
||||
- `GET /api/health/ready`:检查 PostgreSQL 和已配置 Redis;依赖未就绪时返回 503。
|
||||
- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client 和数据库观测数据。
|
||||
- Serilog 输出结构化请求日志;数据库性能拦截器记录慢查询指标。
|
||||
|
||||
Readiness 为绿色不等于认证授权、跨租户隔离或 Broker 恢复演练已通过,发布仍需执行对应集成测试。
|
||||
Readiness 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。
|
||||
|
||||
## 从 RabbitMQ 版本切换
|
||||
|
||||
移除消息表的 Migration 与旧 API/Worker 不兼容。发布时使用维护窗口:停止旧 API 和 Worker,确认 RabbitMQ Consumer 已退出并备份 PostgreSQL,运行 `Tiku.DbMigrator`,再部署新 API。未消费的后台任务消息可丢弃,因为对应任务记录已经写入 `background_jobs`;安全消息不保存授权真相。
|
||||
|
||||
切换时不强制重置 `processing` 任务。旧租约最多五分钟后由新 API 接管。回滚需要先停止新 API,执行 Migration Down 重建空 inbox/outbox 表,再恢复 RabbitMQ 配置和旧 API/Worker;历史消息不会恢复。
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
可选:
|
||||
|
||||
- Redis 7;
|
||||
- RabbitMQ 4。
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
@@ -89,29 +88,25 @@ dotnet run --project Tiku.Api
|
||||
|
||||
OpenAPI 和 Scalar 仅在 Development 映射。接口路径、输入字段、响应模型和授权要求以这里生成的文档为准。
|
||||
|
||||
## 6. 可选:启动 Redis 和 RabbitMQ
|
||||
## 6. 可选:启动 Redis
|
||||
|
||||
本地单实例开发可以不配置这两个依赖。需要验证分布式安全频控、Output Cache、消息和 Outbox 时,先启动本地服务,再设置:
|
||||
本地单实例开发可以不配置 Redis。需要验证安全频控、Feature 缓存和 Output Cache 时,先启动本地服务,再设置:
|
||||
|
||||
```bash
|
||||
export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
|
||||
export RabbitMq__Host='rabbitmq://localhost'
|
||||
export RabbitMq__VirtualHost='/'
|
||||
export RabbitMq__Username='guest'
|
||||
export RabbitMq__Password='guest'
|
||||
```
|
||||
|
||||
RabbitMQ 使用 4.x,当前代码不依赖 delayed-message 插件。延时任务由 PostgreSQL `RunAfter` 调度。
|
||||
Production 必须配置 Redis;PostgreSQL 仍是用户、Session、权限、套餐和用量的权威数据源。
|
||||
|
||||
## 7. 可选:启动 Worker
|
||||
## 7. 后台处理
|
||||
|
||||
需要处理域名、订阅、用量或后台任务时,在另一个终端使用相同配置启动:
|
||||
API 默认在同一进程启动域名、订阅、用量和后台任务 Hosted Service。需要临时关闭时配置:
|
||||
|
||||
```bash
|
||||
dotnet run --project Tiku.Worker
|
||||
export BackgroundProcessing__Enabled=false
|
||||
```
|
||||
|
||||
Worker 会立即开始轮询。域名 DNS/TLS 流程只有在 `TenantDomains` 的 CNAME target 和 Gateway 配置完整后才能激活自定义域名。
|
||||
后台任务状态、租约、重试和 `RunAfter` 存在 PostgreSQL。域名 DNS/TLS 流程只有在 `TenantDomains` 的 CNAME target 和 Gateway 配置完整后才能激活自定义域名。
|
||||
|
||||
## 8. 开发验证
|
||||
|
||||
@@ -139,7 +134,7 @@ pg_isready -h 127.0.0.1 -p 5432
|
||||
psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;'
|
||||
```
|
||||
|
||||
确认当前终端的 `DATABASE_URL` 指向真实存在的数据库,并且 API、DbMigrator 和 Worker 使用同一连接配置。
|
||||
确认当前终端的 `DATABASE_URL` 指向真实存在的数据库,并且 API 与 DbMigrator 使用同一连接配置。
|
||||
|
||||
### 无法创建 PostgreSQL 扩展
|
||||
|
||||
@@ -151,7 +146,7 @@ psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;'
|
||||
|
||||
### Readiness 返回 503
|
||||
|
||||
检查响应中的 `database`、`redis.ready` 和 `rabbitMq.ready`。只配置了 Redis/RabbitMQ 连接串但服务未启动时,readiness 会按已配置依赖检查并返回 503。
|
||||
检查响应中的 `database` 和 `redis.ready`。配置了 Redis 连接串但服务未启动时,readiness 会返回 503。
|
||||
|
||||
### API 出现 HTTPS 重定向警告
|
||||
|
||||
|
||||
Reference in New Issue
Block a user