diff --git a/Directory.Packages.props b/Directory.Packages.props
index d27b894..39e483b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,6 +33,8 @@
+
+
diff --git a/Tiku.Application/Commerce/PaymentProviderContracts.cs b/Tiku.Application/Commerce/PaymentProviderContracts.cs
new file mode 100644
index 0000000..42e579f
--- /dev/null
+++ b/Tiku.Application/Commerce/PaymentProviderContracts.cs
@@ -0,0 +1,105 @@
+using System.Text.Json;
+
+namespace Tiku.Application.Commerce;
+
+public static class PaymentProviders
+{
+ public const string WechatPay = "wechat_pay";
+ public const string Alipay = "alipay";
+ public const string Manual = "manual";
+}
+
+public sealed record PaymentProviderAccount(
+ Guid TenantId,
+ string Provider,
+ string Mode,
+ JsonElement ConfigPublic,
+ JsonElement SecretPayload);
+
+public sealed record CreatePaymentProviderRequest(
+ Guid TenantId,
+ string OrderNo,
+ string Subject,
+ int AmountCents,
+ string Method,
+ string? OpenId,
+ string? ReturnUrl,
+ string? QuitUrl,
+ string NotifyUrl,
+ JsonElement Metadata);
+
+public sealed record CreatePaymentProviderResult(
+ string Provider,
+ string Method,
+ string Status,
+ string? ProviderTradeNo,
+ JsonElement ClientPayload,
+ JsonElement RawPayload);
+
+public sealed record PaymentNotificationRequest(
+ Guid TenantId,
+ string Provider,
+ IReadOnlyDictionary Headers,
+ string RawBody,
+ JsonElement Body);
+
+public sealed record PaymentNotificationResult(
+ string Provider,
+ string EventType,
+ string EventId,
+ string OrderNo,
+ string? ProviderTradeNo,
+ int AmountCents,
+ bool Paid,
+ bool SignatureValid,
+ DateTimeOffset? PaidAt,
+ JsonElement RawPayload);
+
+public interface IPaymentProvider
+{
+ string Provider { get; }
+
+ Task CreatePaymentAsync(
+ PaymentProviderAccount account,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task ParsePaymentNotificationAsync(
+ PaymentProviderAccount account,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IPaymentProviderGateway
+{
+ Task CreatePaymentAsync(
+ string provider,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task ParsePaymentNotificationAsync(
+ string provider,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IPaymentProviderConfigService
+{
+ Task GetActiveAccountAsync(
+ Guid tenantId,
+ string provider,
+ CancellationToken cancellationToken = default);
+}
+
+public interface ITenantSecretService
+{
+ Task GetActiveSecretPayloadAsync(
+ Guid tenantId,
+ string secretRef,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class PaymentProviderException(string message, string code) : Exception(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
index ab1a0e2..74e9bed 100644
--- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
+++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
@@ -11,6 +11,18 @@ public sealed class TenantAuthProvider : AuditableTenantEntity
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
}
+public sealed class TenantSecret : AuditableTenantEntity
+{
+ public string Purpose { get; set; } = string.Empty;
+ public string Provider { get; set; } = string.Empty;
+ public string SecretKey { get; set; } = string.Empty;
+ public string SecretRef { get; set; } = string.Empty;
+ public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active;
+ public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
+ public DateTimeOffset? RotatedAt { get; set; }
+ public DateTimeOffset? ExpiresAt { get; set; }
+}
+
public sealed class SmsVerificationCode : Entity
{
public Guid TenantId { get; set; }
@@ -140,6 +152,7 @@ public sealed class TenantStudentFollowup : AuditableTenantEntity
}
public enum TenantAuthProviderStatus { Active, Disabled, Testing }
+public enum TenantSecretStatus { Active, Disabled, Rotating }
public enum SmsPurpose { Login, BindPhone, ResetPassword }
public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked }
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
diff --git a/Tiku.Infrastructure/Commerce/AlipayProvider.cs b/Tiku.Infrastructure/Commerce/AlipayProvider.cs
new file mode 100644
index 0000000..92e0172
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/AlipayProvider.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using Aop.Api;
+using Tiku.Application.Commerce;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class AlipayProvider : IPaymentProvider
+{
+ public string Provider => PaymentProviders.Alipay;
+
+ public Task CreatePaymentAsync(
+ PaymentProviderAccount account,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ _ = BuildClient(account);
+ throw new PaymentProviderException(
+ "Alipay SDK is configured; checkout call is enabled in the commerce checkout batch.",
+ "alipay_checkout_not_enabled");
+ }
+
+ public Task ParsePaymentNotificationAsync(
+ PaymentProviderAccount account,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ _ = BuildClient(account);
+ throw new PaymentProviderException(
+ "Alipay SDK is configured; notification parsing is enabled in the notification batch.",
+ "alipay_notification_not_enabled");
+ }
+
+ private static DefaultAopClient BuildClient(PaymentProviderAccount account)
+ {
+ return new DefaultAopClient(
+ Required(account.ConfigPublic, "gatewayUrl", "gateway"),
+ Required(account.ConfigPublic, "appId"),
+ Required(account.SecretPayload, "privateKey", "appPrivateKey"),
+ "json",
+ "1.0",
+ "RSA2",
+ Required(account.ConfigPublic, "alipayPublicKey"),
+ "UTF-8",
+ false);
+ }
+
+ private static string Required(JsonElement element, params string[] keys)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var key in keys)
+ {
+ if (element.TryGetProperty(key, out var property) &&
+ property.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ return property.GetString()!;
+ }
+ }
+ }
+
+ throw new PaymentProviderException(
+ "Alipay provider configuration is incomplete.",
+ "alipay_config_incomplete");
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs
new file mode 100644
index 0000000..c1f98bd
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using Tiku.Application.Commerce;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class ManualPaymentProvider : IPaymentProvider
+{
+ public string Provider => PaymentProviders.Manual;
+
+ public Task CreatePaymentAsync(
+ PaymentProviderAccount account,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var payload = JsonSerializer.SerializeToElement(new
+ {
+ request.OrderNo,
+ request.AmountCents,
+ request.Subject,
+ Message = "manual_payment_requires_tenant_admin_confirmation"
+ });
+
+ return Task.FromResult(new CreatePaymentProviderResult(
+ Provider,
+ request.Method,
+ "pending",
+ null,
+ payload,
+ payload));
+ }
+
+ public Task ParsePaymentNotificationAsync(
+ PaymentProviderAccount account,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ throw new PaymentProviderException(
+ "Manual payment does not accept provider notifications.",
+ "manual_payment_notification_not_supported");
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs
new file mode 100644
index 0000000..637e03d
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs
@@ -0,0 +1,80 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Tiku.Application.Commerce;
+using Tiku.Domain.Commerce;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class PaymentProviderConfigService(
+ TikuDbContext dbContext,
+ ITenantSecretService tenantSecretService) : IPaymentProviderConfigService
+{
+ public async Task GetActiveAccountAsync(
+ Guid tenantId,
+ string provider,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedProvider = NormalizeProvider(provider);
+ var account = await dbContext.TenantPaymentAccounts
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.Provider == normalizedProvider &&
+ item.Status == TenantPaymentAccountStatus.Active)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (account is null)
+ {
+ throw new PaymentProviderException(
+ "Payment provider account is not configured.",
+ "payment_provider_not_configured");
+ }
+
+ var secretPayload = normalizedProvider == PaymentProviders.Manual
+ ? JsonDocument.Parse("{}").RootElement.Clone()
+ : await tenantSecretService.GetActiveSecretPayloadAsync(
+ tenantId,
+ GetString(account.ConfigPublic, "secretRef", "secret_ref") ?? string.Empty,
+ cancellationToken);
+
+ return new PaymentProviderAccount(
+ account.TenantId,
+ normalizedProvider,
+ account.Mode.ToString(),
+ account.ConfigPublic,
+ secretPayload);
+ }
+
+ private static string NormalizeProvider(string provider)
+ {
+ var normalized = provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
+
+ return normalized switch
+ {
+ "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
+ "ali_pay" => PaymentProviders.Alipay,
+ "" => throw new PaymentProviderException("Payment provider is required.", "payment_provider_required"),
+ _ => normalized
+ };
+ }
+
+ private static string? GetString(JsonElement element, params string[] keys)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ foreach (var key in keys)
+ {
+ if (element.TryGetProperty(key, out var property) &&
+ property.ValueKind == JsonValueKind.String)
+ {
+ return property.GetString();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs b/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs
new file mode 100644
index 0000000..9e5bd8f
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs
@@ -0,0 +1,45 @@
+using Tiku.Application.Commerce;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class PaymentProviderGateway(
+ IEnumerable providers,
+ IPaymentProviderConfigService configService) : IPaymentProviderGateway
+{
+ public async Task CreatePaymentAsync(
+ string provider,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var resolvedProvider = ResolveProvider(provider);
+ var account = await configService.GetActiveAccountAsync(
+ request.TenantId,
+ resolvedProvider.Provider,
+ cancellationToken);
+
+ return await resolvedProvider.CreatePaymentAsync(account, request, cancellationToken);
+ }
+
+ public async Task ParsePaymentNotificationAsync(
+ string provider,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var resolvedProvider = ResolveProvider(provider);
+ var account = await configService.GetActiveAccountAsync(
+ request.TenantId,
+ resolvedProvider.Provider,
+ cancellationToken);
+
+ return await resolvedProvider.ParsePaymentNotificationAsync(account, request, cancellationToken);
+ }
+
+ private IPaymentProvider ResolveProvider(string provider)
+ {
+ var normalized = provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
+ return providers.FirstOrDefault(item => item.Provider == normalized)
+ ?? throw new PaymentProviderException(
+ "Payment provider is not supported.",
+ "payment_provider_not_supported");
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/TenantSecretService.cs b/Tiku.Infrastructure/Commerce/TenantSecretService.cs
new file mode 100644
index 0000000..c8937ec
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/TenantSecretService.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Tiku.Application.Commerce;
+using Tiku.Domain.Tenancy;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecretService
+{
+ public async Task GetActiveSecretPayloadAsync(
+ Guid tenantId,
+ string secretRef,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(secretRef))
+ {
+ throw new PaymentProviderException(
+ "Payment provider secret is not configured.",
+ "payment_secret_not_configured");
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var secret = await dbContext.TenantSecrets
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.SecretRef == secretRef &&
+ item.Status == TenantSecretStatus.Active &&
+ (item.ExpiresAt == null || item.ExpiresAt > now))
+ .Select(item => item.SecretPayload)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
+ {
+ throw new PaymentProviderException(
+ "Payment provider secret is not configured.",
+ "payment_secret_not_configured");
+ }
+
+ return secret;
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs
new file mode 100644
index 0000000..380b235
--- /dev/null
+++ b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using SKIT.FlurlHttpClient.Wechat.TenpayV3;
+using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
+using Tiku.Application.Commerce;
+
+namespace Tiku.Infrastructure.Commerce;
+
+internal sealed class WechatPayProvider : IPaymentProvider
+{
+ public string Provider => PaymentProviders.WechatPay;
+
+ public Task CreatePaymentAsync(
+ PaymentProviderAccount account,
+ CreatePaymentProviderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ _ = BuildClient(account);
+ throw new PaymentProviderException(
+ "WeChat Pay SDK is configured; checkout call is enabled in the commerce checkout batch.",
+ "wechat_pay_checkout_not_enabled");
+ }
+
+ public Task ParsePaymentNotificationAsync(
+ PaymentProviderAccount account,
+ PaymentNotificationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ _ = BuildClient(account);
+ throw new PaymentProviderException(
+ "WeChat Pay SDK is configured; notification parsing is enabled in the notification batch.",
+ "wechat_pay_notification_not_enabled");
+ }
+
+ private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
+ {
+ var options = new WechatTenpayClientOptions
+ {
+ MerchantId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
+ MerchantV3Secret = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
+ MerchantCertificateSerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
+ MerchantCertificatePrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
+ PlatformAuthScheme = PlatformAuthScheme.PublicKey,
+ PlatformPublicKeyManager = new InMemoryPublicKeyManager()
+ };
+
+ return WechatTenpayClientBuilder.Create(options).Build();
+ }
+
+ private static string Required(JsonElement element, params string[] keys)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var key in keys)
+ {
+ if (element.TryGetProperty(key, out var property) &&
+ property.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ return property.GetString()!;
+ }
+ }
+ }
+
+ throw new PaymentProviderException(
+ "WeChat Pay provider configuration is incomplete.",
+ "wechat_pay_config_incomplete");
+ }
+}
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 2cb8d3b..ca6b278 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -4,6 +4,7 @@ using Npgsql;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Catalog;
+using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
@@ -15,6 +16,7 @@ using Tiku.Application.TenantAdmin;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog;
+using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Persistence;
@@ -61,6 +63,12 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddOptions()
.Validate(
AliyunOssOptions.BeValid,
diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs
index 9865b40..c730668 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs
@@ -21,6 +21,24 @@ internal sealed class TenantAuthProviderConfiguration : IEntityTypeConfiguration
}
}
+internal sealed class TenantSecretConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("tenant_secrets");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Purpose).HasMaxLength(80);
+ builder.Property(entity => entity.Provider).HasMaxLength(50);
+ builder.Property(entity => entity.SecretKey).HasMaxLength(120);
+ builder.Property(entity => entity.SecretRef).HasMaxLength(300);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.SecretPayload).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.SecretRef }).IsUnique();
+ builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.SecretKey }).IsUnique();
+ builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.Status });
+ }
+}
+
internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260726112706_AddPaymentSdkAndTenantSecretFoundation.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260726112706_AddPaymentSdkAndTenantSecretFoundation.Designer.cs
new file mode 100644
index 0000000..4fc5fe7
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260726112706_AddPaymentSdkAndTenantSecretFoundation.Designer.cs
@@ -0,0 +1,15660 @@
+//
+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("20260726112706_AddPaymentSdkAndTenantSecretFoundation")]
+ partial class AddPaymentSdkAndTenantSecretFoundation
+ {
+ ///
+ 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.UseIdentityByDefaultColumns(modelBuilder);
+
+ 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.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.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.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("Days")
+ .HasColumnType("integer")
+ .HasColumnName("days");
+
+ b.Property("IsUsed")
+ .HasColumnType("boolean")
+ .HasColumnName("is_used");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Remark")
+ .HasColumnType("text")
+ .HasColumnName("remark");
+
+ b.Property("SaleType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("sale_type");
+
+ b.Property("SoldTo")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("sold_to");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UnitPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("unit_price_cents");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UsedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("used_by");
+
+ b.Property("UsedRegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("used_region_id");
+
+ b.HasKey("Id")
+ .HasName("pk_activation_codes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_activation_codes_tenant_id_id");
+
+ b.HasIndex("AgentUserId")
+ .HasDatabaseName("ix_activation_codes_agent_user_id");
+
+ b.HasIndex("UsedBy")
+ .HasDatabaseName("ix_activation_codes_used_by");
+
+ b.HasIndex("TenantId", "BatchId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_batch_id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique()
+ .HasDatabaseName("ix_activation_codes_tenant_id_code");
+
+ b.HasIndex("TenantId", "CouponRedemptionId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_coupon_redemption_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_activation_codes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "UsedRegionId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_used_region_id");
+
+ b.ToTable("activation_codes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CodeBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CampaignName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("campaign_name");
+
+ b.Property("Channel")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("channel");
+
+ b.Property("CommissionRate")
+ .HasPrecision(6, 4)
+ .HasColumnType("numeric(6,4)")
+ .HasColumnName("commission_rate");
+
+ b.Property("CostPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("cost_price_cents");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("Days")
+ .HasColumnType("integer")
+ .HasColumnName("days");
+
+ b.Property("DefaultUnitPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("default_unit_price_cents");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("issued_at");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyRegionId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_region_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Remark")
+ .HasColumnType("text")
+ .HasColumnName("remark");
+
+ b.Property("SaleType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("sale_type");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TotalCount")
+ .HasColumnType("integer")
+ .HasColumnName("total_count");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_code_batches");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_code_batches_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_code_batches_tenant_id_legacy_id");
+
+ b.ToTable("code_batches", null, t =>
+ {
+ t.HasCheckConstraint("ck_code_batches_amounts", "default_unit_price_cents >= 0 and cost_price_cents >= 0");
+
+ t.HasCheckConstraint("ck_code_batches_counts", "total_count >= 0");
+ });
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AmountCents")
+ .HasColumnType("integer")
+ .HasColumnName("amount_cents");
+
+ b.Property("BillDate")
+ .HasColumnType("date")
+ .HasColumnName("bill_date");
+
+ b.Property("BillType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("bill_type");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("completed_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("DuplicateCount")
+ .HasColumnType("integer")
+ .HasColumnName("duplicate_count");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("FeeCents")
+ .HasColumnType("integer")
+ .HasColumnName("fee_cents");
+
+ b.Property("IgnoredCount")
+ .HasColumnType("integer")
+ .HasColumnName("ignored_count");
+
+ b.Property("MatchedCount")
+ .HasColumnType("integer")
+ .HasColumnName("matched_count");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("MismatchCount")
+ .HasColumnType("integer")
+ .HasColumnName("mismatch_count");
+
+ b.Property("MissingLocalCount")
+ .HasColumnType("integer")
+ .HasColumnName("missing_local_count");
+
+ b.Property("MissingProviderCount")
+ .HasColumnType("integer")
+ .HasColumnName("missing_provider_count");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("provider");
+
+ b.Property("RefundAmountCents")
+ .HasColumnType("integer")
+ .HasColumnName("refund_amount_cents");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("source");
+
+ b.Property("SourceHash")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("source_hash");
+
+ b.Property("SourceName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("source_name");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TotalCount")
+ .HasColumnType("integer")
+ .HasColumnName("total_count");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_commerce_reconciliation_batches");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_commerce_reconciliation_batches_tenant_id_id");
+
+ b.HasIndex("CreatedBy")
+ .HasDatabaseName("ix_commerce_reconciliation_batches_created_by");
+
+ b.HasIndex("TenantId", "Status", "CreatedAt")
+ .HasDatabaseName("ix_commerce_reconciliation_batches_tenant_id_status_created_at");
+
+ b.HasIndex("TenantId", "Provider", "BillDate", "CreatedAt")
+ .HasDatabaseName("ix_commerce_reconciliation_batches_tenant_id_provider_bill_dat~");
+
+ b.ToTable("commerce_reconciliation_batches", null, t =>
+ {
+ t.HasCheckConstraint("ck_commerce_reconciliation_batches_amounts", "amount_cents >= 0 and refund_amount_cents >= 0");
+
+ t.HasCheckConstraint("ck_commerce_reconciliation_batches_counts", "total_count >= 0 and matched_count >= 0 and mismatch_count >= 0 and missing_local_count >= 0 and missing_provider_count >= 0 and duplicate_count >= 0 and ignored_count >= 0");
+ });
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssue", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AmountCents")
+ .HasColumnType("integer")
+ .HasColumnName("amount_cents");
+
+ b.Property("AssignedTo")
+ .HasColumnType("uuid")
+ .HasColumnName("assigned_to");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("DueAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("due_at");
+
+ b.Property("IssueCode")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("issue_code");
+
+ b.Property("IssueNo")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("issue_no");
+
+ b.Property("ItemId")
+ .HasColumnType("uuid")
+ .HasColumnName("item_id");
+
+ b.Property("MatchStatus")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("match_status");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid")
+ .HasColumnName("order_id");
+
+ b.Property("OrderNo")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("order_no");
+
+ b.Property("PaymentId")
+ .HasColumnType("uuid")
+ .HasColumnName("payment_id");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("provider");
+
+ b.Property("ProviderRefundNo")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("provider_refund_no");
+
+ b.Property("ProviderTradeNo")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("provider_trade_no");
+
+ b.Property("RefundAmountCents")
+ .HasColumnType("integer")
+ .HasColumnName("refund_amount_cents");
+
+ b.Property("RefundNo")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("refund_no");
+
+ b.Property("RefundRequestId")
+ .HasColumnType("uuid")
+ .HasColumnName("refund_request_id");
+
+ b.Property("ResolutionNote")
+ .HasColumnType("text")
+ .HasColumnName("resolution_note");
+
+ b.Property("ResolutionType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("resolution_type");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("resolved_at");
+
+ b.Property("ResolvedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("resolved_by");
+
+ b.Property("Severity")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("severity");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("Summary")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("summary");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TransactionType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("transaction_type");
+
+ b.Property