diff --git a/AGENTS.md b/AGENTS.md
index 00842ad..b09c16e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1a3166f..7603f22 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -34,9 +34,6 @@
-
-
-
diff --git a/README.md b/README.md
index 993dcfe..e7ea9b2 100644
--- a/README.md
+++ b/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]`。
diff --git a/TIKU-BACKEND.slnx b/TIKU-BACKEND.slnx
index 6ee7353..957777a 100644
--- a/TIKU-BACKEND.slnx
+++ b/TIKU-BACKEND.slnx
@@ -2,11 +2,9 @@
-
-
diff --git a/Tiku.Worker/WorkerServices.cs b/Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
similarity index 52%
rename from Tiku.Worker/WorkerServices.cs
rename to Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
index 60a0ddb..80a38f8 100644
--- a/Tiku.Worker/WorkerServices.cs
+++ b/Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
@@ -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 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,9 +54,15 @@ 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;
+ }
}
-
- await Task.Delay(interval, stoppingToken);
}
}
@@ -42,73 +70,85 @@ internal abstract class PeriodicWorkerService(
services.GetRequiredService().InitializeSystem(null, reason);
}
-internal sealed class TenantDomainWorker(
+internal sealed class TenantDomainBackgroundService(
IServiceScopeFactory scopeFactory,
- IOptions options,
- ILogger logger)
- : PeriodicWorkerService(logger, TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)))
+ IOptions domainOptions,
+ IOptions backgroundOptions,
+ ILogger logger)
+ : PeriodicBackgroundService(
+ logger,
+ TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
+ backgroundOptions.Value.Enabled)
{
protected override async Task 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()
.ProcessPendingAsync(cancellationToken);
}
}
-internal sealed class SaasSubscriptionWorker(
+internal sealed class SaasSubscriptionBackgroundService(
IServiceScopeFactory scopeFactory,
- ILogger logger)
- : PeriodicWorkerService(logger, TimeSpan.FromSeconds(60))
+ IOptions options,
+ ILogger logger)
+ : PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
{
protected override async Task 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()
.ProcessDueAsync(cancellationToken: cancellationToken);
}
}
-internal sealed class FeatureUsageWorker(
+internal sealed class FeatureUsageBackgroundService(
IServiceScopeFactory scopeFactory,
- IOptions options,
- ILogger logger)
- : PeriodicWorkerService(logger, TimeSpan.FromMinutes(Math.Clamp(options.Value.IntervalMinutes, 1, 1440)))
+ IOptions featureOptions,
+ IOptions backgroundOptions,
+ ILogger logger)
+ : PeriodicBackgroundService(
+ logger,
+ TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
+ backgroundOptions.Value.Enabled)
{
protected override async Task 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()
.ProcessDueAsync(cancellationToken);
}
}
-internal sealed class BackgroundJobsWorker(
+internal sealed class BackgroundJobsBackgroundService(
IServiceScopeFactory scopeFactory,
- MessagingOptions messagingOptions,
- ILogger logger)
- : PeriodicWorkerService(logger, TimeSpan.FromSeconds(2))
+ IOptions options,
+ ILogger 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 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 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()
.ProcessPendingAsync(
$"{workerId}:{index}",
- 5,
- includeImmediateJobs: !messagingOptions.IsConfigured,
+ batchSize,
+ includeImmediateJobs: true,
cancellationToken: cancellationToken);
}
}
diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs
index 4eba680..127a9b2 100644
--- a/Tiku.Api/Configuration/DependencyInjection.cs
+++ b/Tiku.Api/Configuration/DependencyInjection.cs
@@ -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();
- builder.Services.AddHostedService();
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(options => options.Level = CompressionLevel.Fastest);
builder.Services.Configure(options => options.Level = CompressionLevel.Fastest);
- var messaging = builder.Configuration.GetSection("RabbitMq").Get() ?? new MessagingOptions();
- builder.Services.AddOptions()
- .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()
+ .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()
+ .Bind(builder.Configuration.GetSection("TenantDomains"));
+ builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("SaasSubscriptions"));
+ builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs
index 69d4b27..1f4b59e 100644
--- a/Tiku.Api/Configuration/ObservabilityExtensions.cs
+++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs
@@ -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;
diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs
index e9f9ebf..96470be 100644
--- a/Tiku.Api/Controllers/HealthController.cs
+++ b/Tiku.Api/Controllers/HealthController.cs
@@ -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);
diff --git a/Tiku.Api/Observability/OutboxBacklogMonitor.cs b/Tiku.Api/Observability/OutboxBacklogMonitor.cs
deleted file mode 100644
index 33a30b4..0000000
--- a/Tiku.Api/Observability/OutboxBacklogMonitor.cs
+++ /dev/null
@@ -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 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);
- }
- }
-}
diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json
index 233fe1e..439d2cf 100644
--- a/Tiku.Api/appsettings.json
+++ b/Tiku.Api/appsettings.json
@@ -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": []
diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs
index a05a868..0eda3c4 100644
--- a/Tiku.Application/Jobs/BackgroundJobModels.cs
+++ b/Tiku.Application/Jobs/BackgroundJobModels.cs
@@ -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);
-}
diff --git a/Tiku.Application/Security/ConsumerAuthorizationMetadata.cs b/Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
deleted file mode 100644
index 86adefa..0000000
--- a/Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
+++ /dev/null
@@ -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; }
-}
diff --git a/Tiku.Application/Security/IRedisSecurityStore.cs b/Tiku.Application/Security/IRedisSecurityStore.cs
index 07981c9..5e0701b 100644
--- a/Tiku.Application/Security/IRedisSecurityStore.cs
+++ b/Tiku.Application/Security/IRedisSecurityStore.cs
@@ -13,11 +13,4 @@ public interface IRedisSecurityStore
CancellationToken cancellationToken = default);
Task PingAsync(CancellationToken cancellationToken = default);
-
- Task SetInvalidationVersionAsync(
- string realm,
- Guid? tenantId,
- Guid? userId,
- long version,
- CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Contracts/SecurityEvents.cs b/Tiku.Contracts/SecurityEvents.cs
deleted file mode 100644
index 79f95f4..0000000
--- a/Tiku.Contracts/SecurityEvents.cs
+++ /dev/null
@@ -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);
diff --git a/Tiku.Contracts/Tiku.Contracts.csproj b/Tiku.Contracts/Tiku.Contracts.csproj
deleted file mode 100644
index 6c3a887..0000000
--- a/Tiku.Contracts/Tiku.Contracts.csproj
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- net10.0
- enable
- enable
-
-
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 5fc7c24..7c6896e 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -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(options => options.IterationCount = 210_000);
services.AddScoped();
services.AddSingleton();
- services.AddScoped();
services.AddMemoryCache();
services.AddScoped();
services.AddScoped();
@@ -151,12 +148,10 @@ public static class DependencyInjection
services.AddScoped();
services.AddSingleton();
services.AddSingleton(provider => provider.GetRequiredService());
- services.AddHostedService(provider => provider.GetRequiredService());
services.AddScoped();
services.AddScoped();
services.AddOptions();
services.AddScoped();
- services.AddSingleton();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -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(outbox =>
- {
- outbox.UsePostgres();
- outbox.UseBusOutbox();
- outbox.QueryDelay = TimeSpan.FromSeconds(1);
- outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
- });
- if (options.ConfigureConsumers)
- {
- registration.AddConsumer(consumer =>
- {
- consumer.ConcurrentMessageLimit = 1;
- consumer.UseMessageRetry(retry => retry.Intervals(
- TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
- });
- registration.AddConsumer(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(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();
- services.AddScoped();
- return services;
- }
}
diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
index 0adae11..b142f5a 100644
--- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
+++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
@@ -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);
}
diff --git a/Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs b/Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
deleted file mode 100644
index 38641d9..0000000
--- a/Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
+++ /dev/null
@@ -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);
-}
diff --git a/Tiku.Infrastructure/Messaging/BackgroundJobRequestedConsumer.cs b/Tiku.Infrastructure/Messaging/BackgroundJobRequestedConsumer.cs
deleted file mode 100644
index 4e0183c..0000000
--- a/Tiku.Infrastructure/Messaging/BackgroundJobRequestedConsumer.cs
+++ /dev/null
@@ -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
-{
- public async Task Consume(ConsumeContext 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);
- }
-}
diff --git a/Tiku.Infrastructure/Messaging/MessagingOptions.cs b/Tiku.Infrastructure/Messaging/MessagingOptions.cs
deleted file mode 100644
index 9b6ae33..0000000
--- a/Tiku.Infrastructure/Messaging/MessagingOptions.cs
+++ /dev/null
@@ -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";
-}
diff --git a/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs b/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
deleted file mode 100644
index 9bc0ad8..0000000
--- a/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
+++ /dev/null
@@ -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);
-}
diff --git a/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs b/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs
deleted file mode 100644
index d380f8a..0000000
--- a/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs
+++ /dev/null
@@ -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,
- IConsumer,
- IConsumer
-{
- public Task Consume(ConsumeContext context) =>
- redisSecurityStore.SetInvalidationVersionAsync(
- "authorization", context.Message.TenantId, context.Message.UserId,
- context.Message.Version, context.CancellationToken);
-
- public Task Consume(ConsumeContext context) =>
- redisSecurityStore.SetInvalidationVersionAsync(
- $"capability-{context.Message.ModuleCode}", context.Message.TenantId, null,
- context.Message.Version, context.CancellationToken);
-
- public Task Consume(ConsumeContext context) =>
- redisSecurityStore.SetInvalidationVersionAsync(
- "membership", context.Message.TenantId, context.Message.UserId,
- context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken);
-}
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260730071211_RemoveDistributedMessaging.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260730071211_RemoveDistributedMessaging.Designer.cs
new file mode 100644
index 0000000..5c09ed0
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260730071211_RemoveDistributedMessaging.Designer.cs
@@ -0,0 +1,19855 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260730071211_RemoveDistributedMessaging")]
+ partial class RemoveDistributedMessaging
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_claims_user_id");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pk_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_logins_user_id");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pk_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_module_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_module_nodes_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "ParentId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_region_id");
+
+ b.ToTable("module_nodes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid")
+ .HasColumnName("question_id");
+
+ b.Property("TaxonomyNodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_node_id");
+
+ b.Property("TaxonomyOwnerTenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_owner_tenant_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id")
+ .HasName("pk_question_taxonomy_assignments");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_question_taxonomy_assignments_tenant_id_id");
+
+ b.HasIndex("TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .HasDatabaseName("ix_question_taxonomy_assignments_taxonomy_owner_tenant_id_taxo~");
+
+ b.HasIndex("TenantId", "QuestionId", "TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .IsUnique()
+ .HasDatabaseName("ix_question_taxonomy_assignments_tenant_id_question_id_taxonom~");
+
+ b.ToTable("question_taxonomy_assignments", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Region", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("code");
+
+ b.Property("Config")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("config")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FullName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("full_name");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsHot")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hot");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Pinyin")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("pinyin");
+
+ b.Property("ShortName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("short_name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_regions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_regions_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_regions_tenant_id_legacy_id");
+
+ b.ToTable("regions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Color")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsPrimarySchoolModule")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary_school_module");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Route")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("route");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TextColor")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("text_color");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_region_modules");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_region_modules_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_region_modules_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_region_modules_tenant_id_region_id");
+
+ b.ToTable("region_modules", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.School", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ProfessionalExamDate")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("professional_exam_date");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_schools");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_schools_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_schools_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_schools_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_schools_tenant_id_region_id");
+
+ b.ToTable("schools", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("FieldKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("field_key");
+
+ b.Property("FieldName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("field_name");
+
+ b.Property("FieldType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("field_type");
+
+ b.Property("IsFilter")
+ .HasColumnType("boolean")
+ .HasColumnName("is_filter");
+
+ b.Property("IsRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("is_required");
+
+ b.Property("IsTrend")
+ .HasColumnType("boolean")
+ .HasColumnName("is_trend");
+
+ b.Property("IsVisible")
+ .HasColumnType("boolean")
+ .HasColumnName("is_visible");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Options")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("options")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("Placeholder")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("placeholder");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Unit")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("unit");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_fields");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_fields_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId", "FieldKey")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key");
+
+ b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder")
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order");
+
+ b.ToTable("scoreline_fields", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FieldValues")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("field_values")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("major_name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SchoolName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("school_name");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_records");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_records_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_school_id");
+
+ b.HasIndex("TenantId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year");
+
+ b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
+
+ b.HasIndex("TenantId", "Year", "SchoolName", "MajorName", "Id")
+ .IsDescending(false, true, false, false, false)
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year_school_name_major_name_id");
+
+ b.ToTable("scoreline_records", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorLegacyIds")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("major_legacy_ids")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("Stats")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("stats")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_subjects");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_subjects_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_subjects_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_subjects_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_subjects_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_subjects_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_subjects_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_subjects_tenant_id_school_id");
+
+ b.ToTable("subjects", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.TaxonomyNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Depth")
+ .HasColumnType("integer")
+ .HasColumnName("depth");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("node_type");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("ParentOwnerTenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_owner_tenant_id");
+
+ b.Property("Path")
+ .HasColumnType("ltree")
+ .HasColumnName("path");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_taxonomy_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_taxonomy_nodes_tenant_id_id");
+
+ b.HasIndex("ParentOwnerTenantId", "ParentId")
+ .HasDatabaseName("ix_taxonomy_nodes_parent_owner_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique()
+ .HasDatabaseName("ix_taxonomy_nodes_tenant_id_code");
+
+ b.ToTable("taxonomy_nodes", null, t =>
+ {
+ t.HasCheckConstraint("ck_taxonomy_nodes_parent_pair", "(parent_owner_tenant_id is null) = (parent_id is null)");
+ });
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.ActivationCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AgentUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_user_id");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("citext")
+ .HasColumnName("code");
+
+ b.Property("CouponCode")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("coupon_code");
+
+ b.Property("CouponRedemptionId")
+ .HasColumnType("uuid")
+ .HasColumnName("coupon_redemption_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property