refactor: consolidate backend into modular monolith

This commit is contained in:
2026-07-30 15:37:31 +08:00
parent 30fd159041
commit 7a73dcbd12
55 changed files with 20409 additions and 1728 deletions

View File

@@ -0,0 +1,154 @@
using Microsoft.Extensions.Options;
using Tiku.Application.Jobs;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.BackgroundProcessing;
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,
bool enabled) : BackgroundService
{
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!enabled)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
var processed = await ProcessAsync(stoppingToken);
if (processed > 0)
{
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
}
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
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 TenantDomainBackgroundService(
IServiceScopeFactory scopeFactory,
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 background service");
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
.ProcessPendingAsync(cancellationToken);
}
}
internal sealed class SaasSubscriptionBackgroundService(
IServiceScopeFactory scopeFactory,
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 background service");
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
.ProcessDueAsync(cancellationToken: cancellationToken);
}
}
internal sealed class FeatureUsageBackgroundService(
IServiceScopeFactory scopeFactory,
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 background service");
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
.ProcessDueAsync(cancellationToken);
}
}
internal sealed class BackgroundJobsBackgroundService(
IServiceScopeFactory scopeFactory,
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, 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 service");
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync(
$"{workerId}:{index}",
batchSize,
includeImmediateJobs: true,
cancellationToken: cancellationToken);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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": []