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

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

View File

@@ -1,12 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Tiku.Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

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

View File

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

View File

@@ -1,114 +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;
internal abstract class PeriodicWorkerService(
ILogger logger,
TimeSpan interval) : BackgroundService
{
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var processed = await ProcessAsync(stoppingToken);
if (processed > 0)
{
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
}
await Task.Delay(interval, stoppingToken);
}
}
protected static void InitializeSystem(IServiceProvider services, string reason) =>
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
}
internal sealed class TenantDomainWorker(
IServiceScopeFactory scopeFactory,
IOptions<DomainLifecycleOptions> options,
ILogger<TenantDomainWorker> logger)
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)))
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle worker");
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
.ProcessPendingAsync(cancellationToken);
}
}
internal sealed class SaasSubscriptionWorker(
IServiceScopeFactory scopeFactory,
ILogger<SaasSubscriptionWorker> logger)
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(60))
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle discovery worker");
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
.ProcessDueAsync(cancellationToken: cancellationToken);
}
}
internal sealed class FeatureUsageWorker(
IServiceScopeFactory scopeFactory,
IOptions<FeatureUsageReconciliationOptions> options,
ILogger<FeatureUsageWorker> logger)
: PeriodicWorkerService(logger, TimeSpan.FromMinutes(Math.Clamp(options.Value.IntervalMinutes, 1, 1440)))
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation worker");
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
.ProcessDueAsync(cancellationToken);
}
}
internal sealed class BackgroundJobsWorker(
IServiceScopeFactory scopeFactory,
MessagingOptions messagingOptions,
ILogger<BackgroundJobsWorker> logger)
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(2))
{
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
var workers = Enumerable.Range(0, 4).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");
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync(
$"{workerId}:{index}",
5,
includeImmediateJobs: !messagingOptions.IsConfigured,
cancellationToken: cancellationToken);
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -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"
}
}
}