feat(platform): add CRM SMS and payment settings

This commit is contained in:
2026-07-29 15:41:27 +08:00
parent 6db200a2fc
commit ead7bdc2e2
25 changed files with 23250 additions and 140 deletions

View File

@@ -58,6 +58,9 @@ internal sealed class BackofficeService(
new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230),
new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240),
new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250),
new("platform_crm", "CRM 接入代管", BackendPermissionArea.Platform, null, 260),
new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270),
new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280),
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
];
@@ -89,6 +92,12 @@ internal sealed class BackofficeService(
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"),
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"),
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"),
new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"),
new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"),
new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"),
new(BackendPermissions.PlatformSmsWrite, "平台短信管理", BackendPermissionArea.Platform, "platform_sms"),
new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"),
new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -113,7 +122,10 @@ internal sealed class BackofficeService(
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50),
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60)
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60),
new("platform.crm", null, "CRM 接入", BackendPermissionArea.Platform, "/platform/crm", BackendPermissions.PlatformCrmRead, 70),
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/platform/sms", BackendPermissions.PlatformSmsRead, 80),
new("platform.payment", null, "支付设置", BackendPermissionArea.Platform, "/platform/payment-settings", BackendPermissions.PlatformPaymentRead, 90)
];
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(

View File

@@ -8,6 +8,8 @@ using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Growth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Bootstrap;
@@ -31,6 +33,9 @@ public static class DevelopmentPlatformAdminSeeder
if (dbContext.PlatformBackendUserRoles.Any())
{
EnsurePlatformPermissionCatalog(dbContext);
SeedDemoTenantCapabilities(dbContext);
dbContext.SaveChanges();
return false;
}
@@ -44,6 +49,7 @@ public static class DevelopmentPlatformAdminSeeder
.Select(permission => permission.Code)
.ToHashSet(StringComparer.Ordinal);
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
SeedDemoTenantCapabilities(dbContext);
dbContext.SaveChanges();
WriteFirstLoginInstructions(temporaryPassword);
return true;
@@ -57,6 +63,9 @@ public static class DevelopmentPlatformAdminSeeder
if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken))
{
EnsurePlatformPermissionCatalog(dbContext);
SeedDemoTenantCapabilities(dbContext);
await dbContext.SaveChangesAsync(cancellationToken);
return false;
}
@@ -72,6 +81,7 @@ public static class DevelopmentPlatformAdminSeeder
.ToArrayAsync(cancellationToken))
.ToHashSet(StringComparer.Ordinal);
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
SeedDemoTenantCapabilities(dbContext);
await dbContext.SaveChangesAsync(cancellationToken);
WriteFirstLoginInstructions(temporaryPassword);
return true;
@@ -166,6 +176,299 @@ public static class DevelopmentPlatformAdminSeeder
});
}
private static void EnsurePlatformPermissionCatalog(TikuDbContext dbContext)
{
var permissionCodes = BackendPermissions.Platform.ToArray();
var moduleCodes = permissionCodes
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingModuleCodes = dbContext.PermissionModules
.Where(module => moduleCodes.Contains(module.Code))
.Select(module => module.Code)
.ToHashSet(StringComparer.Ordinal);
foreach (var code in moduleCodes.Where(code => !existingModuleCodes.Contains(code)))
{
dbContext.PermissionModules.Add(new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
});
}
var existingPermissionCodes = dbContext.BackendPermissions
.Where(permission => permissionCodes.Contains(permission.Code))
.Select(permission => permission.Code)
.ToHashSet(StringComparer.Ordinal);
foreach (var code in permissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
{
dbContext.BackendPermissions.Add(new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
Description = "Built-in platform permission.",
IsSystem = true
});
}
var superAdminRoleId = dbContext.PlatformBackendRoles
.Where(role => role.Code == RoleCode)
.Select(role => (Guid?)role.Id)
.FirstOrDefault();
if (superAdminRoleId is null)
{
return;
}
var boundPermissionCodes = dbContext.PlatformBackendRolePermissions
.Where(binding => binding.RoleId == superAdminRoleId.Value)
.Select(binding => binding.PermissionCode)
.ToHashSet(StringComparer.Ordinal);
dbContext.PlatformBackendRolePermissions.AddRange(permissionCodes
.Where(code => !boundPermissionCodes.Contains(code))
.Select(code => new PlatformBackendRolePermission
{
RoleId = superAdminRoleId.Value,
PermissionCode = code
}));
}
private static void SeedDemoTenantCapabilities(TikuDbContext dbContext)
{
var tenantA = EnsureTenant(dbContext, "demo-crm-school", "演示题库机构 A");
var tenantB = EnsureTenant(dbContext, "demo-sms-campus", "演示题库机构 B");
EnsureCrmDemo(dbContext, tenantA);
EnsureSmsDemo(dbContext, tenantA, "aliyun", "登录验证码");
EnsureSmsDemo(dbContext, tenantB, "tencent", "营销通知");
EnsurePlatformPaymentDemo(dbContext, tenantA);
EnsureTenantPaymentDemo(dbContext, tenantA);
}
private static Tenant EnsureTenant(TikuDbContext dbContext, string slug, string name)
{
var tenant = dbContext.Tenants.SingleOrDefault(value => value.Slug == slug);
if (tenant is not null)
{
return tenant;
}
tenant = new Tenant
{
Slug = slug,
Name = name,
LegalName = $"{name}有限公司",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active,
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
};
dbContext.Tenants.Add(tenant);
return tenant;
}
private static void EnsureCrmDemo(TikuDbContext dbContext, Tenant tenant)
{
if (!dbContext.CrmConfigs.Any(value => value.TenantId == tenant.Id))
{
dbContext.CrmConfigs.Add(new CrmConfig
{
TenantId = tenant.Id,
Enabled = true,
Url = "https://crm.example.test/webhook/tiku",
SecretRef = "tenant_secret:crm:demo:redacted",
FormName = "学生留资表单",
ExamType = "ielts",
TimeoutSeconds = 10,
DelaySeconds = 0,
AssignmentMode = ReferralAssignmentMode.RoundRobin,
AssignmentPool = JsonSerializer.SerializeToElement(new[] { "顾问一", "顾问二" }),
AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3, source = "development_seed" })
});
}
if (!dbContext.CrmWebhookQueue.Any(value => value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead"))
{
dbContext.CrmWebhookQueue.Add(new CrmWebhookQueueItem
{
TenantId = tenant.Id,
RecordId = "CRM-DEMO-LEAD-001",
LeadId = "LEAD-001",
Source = "tenant.student.crm_push",
Provider = "generic",
Status = CrmWebhookQueueStatus.Failed,
Attempts = 2,
LastError = "demo timeout",
LastHttpCode = 504,
TargetUrl = "https://crm.example.test/webhook/tiku",
IdempotencyKey = "demo-crm-failed-lead",
Payload = JsonSerializer.SerializeToElement(new { student = "王同学", phoneMasked = "138****0001" })
});
}
}
private static void EnsureSmsDemo(TikuDbContext dbContext, Tenant tenant, string provider, string templateName)
{
var scene = templateName.Contains("登录", StringComparison.Ordinal) ? "login" : "marketing";
var channel = dbContext.SmsChannels.SingleOrDefault(value => value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene);
if (channel is null)
{
channel = new SmsChannel
{
TenantId = tenant.Id,
Provider = provider,
Name = $"{tenant.Name} {provider} 短信渠道",
Signature = "题库演示",
Scene = scene,
Status = TenantExternalProviderStatus.Active,
SecretRef = $"tenant_secret:sms:{provider}:redacted",
MonthlyQuota = 10000,
ConfigPublic = JsonSerializer.SerializeToElement(new { region = "cn", redacted = true }),
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
};
dbContext.SmsChannels.Add(channel);
}
var templateCode = $"demo_{provider}_{scene}";
var template = dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode);
if (template is null)
{
template = new SmsTemplate
{
TenantId = tenant.Id,
ChannelId = channel.Id,
Code = templateCode,
Name = templateName,
Type = scene == "login" ? SmsTemplateType.VerificationCode : SmsTemplateType.Marketing,
AuditStatus = SmsTemplateAuditStatus.Approved,
Status = SmsTemplateStatus.Active,
ProviderTemplateCode = $"TPL_{provider.ToUpperInvariant()}_DEMO",
Content = "您的验证码为 ${code},请勿泄露。",
ApprovedAt = DateTimeOffset.UtcNow,
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
};
dbContext.SmsTemplates.Add(template);
}
if (!dbContext.SmsSendLogs.Any(value => value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001"))
{
dbContext.SmsSendLogs.Add(new SmsSendLog
{
TenantId = tenant.Id,
ChannelId = channel.Id,
TemplateId = template.Id,
Provider = provider,
Scene = scene,
PhoneMasked = "138****0001",
Status = SmsSendLogStatus.Sent,
ProviderMessageId = $"demo-{provider}-{scene}-001",
SentAt = DateTimeOffset.UtcNow,
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
});
}
}
private static void EnsurePlatformPaymentDemo(TikuDbContext dbContext, Tenant tenant)
{
var app = dbContext.PlatformPaymentApps.SingleOrDefault(value => value.AppCode == "platform_saas_collect");
if (app is null)
{
app = new PlatformPaymentApp
{
AppCode = "platform_saas_collect",
AppName = "平台 SaaS 收款",
Status = PlatformPaymentAppStatus.Active,
SettlementMode = "PlatformCollect",
Description = "Development demo platform payment app.",
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
};
dbContext.PlatformPaymentApps.Add(app);
}
if (!dbContext.PlatformPaymentChannels.Any(value => value.AppId == app.Id && value.Provider == "manual"))
{
dbContext.PlatformPaymentChannels.Add(new PlatformPaymentChannel
{
AppId = app.Id,
Provider = "manual",
Mode = "PlatformCollect",
Status = PlatformPaymentChannelStatus.Active,
DisplayName = "线下人工确认",
SecretRef = "platform_payment:manual:redacted",
CallbackPath = "/api/platform-billing/payments/notify/manual",
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true })
});
}
if (!dbContext.PlatformBillingPayments.Any(value => value.PaymentNo == "PB-DEMO-MANUAL-001"))
{
var quote = new PlatformBillingQuote
{
TenantId = tenant.Id,
QuoteNo = "BQ-DEMO-001",
IdempotencyKey = "demo-platform-payment-quote",
Status = PlatformBillingQuoteStatus.Converted,
Purpose = PlatformBillingOrderPurpose.NewSubscription,
TotalAmountCents = 19900,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
var order = new PlatformBillingOrder
{
TenantId = tenant.Id,
QuoteId = quote.Id,
OrderNo = "BO-DEMO-001",
IdempotencyKey = "demo-platform-payment-order",
Purpose = PlatformBillingOrderPurpose.NewSubscription,
Status = PlatformBillingOrderStatus.PendingPayment,
TotalAmountCents = 19900,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7)
};
var payment = new PlatformBillingPayment
{
TenantId = tenant.Id,
OrderId = order.Id,
PaymentNo = "PB-DEMO-MANUAL-001",
IdempotencyKey = "demo-platform-payment-payment",
Provider = "manual",
Method = "bank_transfer",
Status = PlatformBillingPaymentStatus.Pending,
AmountCents = 19900
};
dbContext.PlatformBillingQuotes.Add(quote);
dbContext.PlatformBillingOrders.Add(order);
dbContext.PlatformBillingPayments.Add(payment);
dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
{
TenantId = tenant.Id,
PaymentId = payment.Id,
Provider = "manual",
ProviderEventId = "demo-platform-payment-created",
EventType = "payment_created",
Payload = JsonSerializer.SerializeToElement(new { demo = true, amountCents = 19900 })
});
}
}
private static void EnsureTenantPaymentDemo(TikuDbContext dbContext, Tenant tenant)
{
if (!dbContext.TenantExternalProviders.Any(value => value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && value.Provider == "manual"))
{
dbContext.TenantExternalProviders.Add(new TenantExternalProvider
{
TenantId = tenant.Id,
Capability = TenantExternalProviderCapability.Payment,
Provider = "manual",
Status = TenantExternalProviderStatus.Active,
DisplayName = "租户线下收款",
SecretRef = "tenant_payment:manual:redacted",
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true }),
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
});
}
}
private static string GenerateTemporaryPassword() =>
$"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";

View File

@@ -127,6 +127,10 @@ public static class DependencyInjection
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
services.AddScoped<IPlatformPaymentSettingsService, PlatformPaymentSettingsService>();
services.AddScoped<IPlatformTenantPaymentAdminService, PlatformTenantPaymentAdminService>();
services.AddScoped<ISaasCatalogAdminService, SaasCatalogAdminService>();
services.AddScoped<ITenantBillingService, TenantBillingService>();
services.AddScoped<IPlatformBillingAdminService, PlatformBillingAdminService>();

View File

@@ -158,3 +158,40 @@ internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEn
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PlatformPaymentAppConfiguration : IEntityTypeConfiguration<PlatformPaymentApp>
{
public void Configure(EntityTypeBuilder<PlatformPaymentApp> builder)
{
builder.ConfigureEntity("platform_payment_apps");
builder.ConfigureTimestamps();
builder.Property(entity => entity.AppCode).HasMaxLength(100);
builder.Property(entity => entity.AppName).HasMaxLength(200);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.SettlementMode).HasMaxLength(100);
builder.Property(entity => entity.Description).HasMaxLength(500);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.AppCode).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.AppCode });
}
}
internal sealed class PlatformPaymentChannelConfiguration : IEntityTypeConfiguration<PlatformPaymentChannel>
{
public void Configure(EntityTypeBuilder<PlatformPaymentChannel> builder)
{
builder.ConfigureEntity("platform_payment_channels");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasMaxLength(80);
builder.Property(entity => entity.Mode).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.DisplayName).HasMaxLength(200);
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.CallbackPath).HasMaxLength(500);
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.AppId, entity.Provider }).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.Priority, entity.Provider });
builder.HasOne<PlatformPaymentApp>().WithMany().HasForeignKey(entity => entity.AppId).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -187,6 +187,80 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<S
}
}
internal sealed class SmsChannelConfiguration : IEntityTypeConfiguration<SmsChannel>
{
public void Configure(EntityTypeBuilder<SmsChannel> builder)
{
builder.ConfigureTenantEntity("sms_channels");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasMaxLength(80);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Signature).HasMaxLength(100);
builder.Property(entity => entity.Scene).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.Scene }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.Priority });
builder.ToTable(table => table.HasCheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0"));
}
}
internal sealed class SmsTemplateConfiguration : IEntityTypeConfiguration<SmsTemplate>
{
public void Configure(EntityTypeBuilder<SmsTemplate> builder)
{
builder.ConfigureTenantEntity("sms_templates");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(120);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Type).HasSnakeCaseEnum();
builder.Property(entity => entity.AuditStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ProviderTemplateCode).HasMaxLength(120);
builder.Property(entity => entity.Content).HasMaxLength(1000);
builder.Property(entity => entity.Remark).HasMaxLength(500);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.Status });
builder.HasOne<SmsChannel>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ChannelId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class SmsSendLogConfiguration : IEntityTypeConfiguration<SmsSendLog>
{
public void Configure(EntityTypeBuilder<SmsSendLog> builder)
{
builder.ToTable("sms_send_logs");
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
builder.Property(entity => entity.Provider).HasMaxLength(80);
builder.Property(entity => entity.Scene).HasMaxLength(100);
builder.Property(entity => entity.PhoneMasked).HasMaxLength(40);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ProviderMessageId).HasMaxLength(200);
builder.Property(entity => entity.ErrorCode).HasMaxLength(120);
builder.Property(entity => entity.ErrorMessage).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.CreatedAt });
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<SmsChannel>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ChannelId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<SmsTemplate>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TemplateId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
{
public void Configure(EntityTypeBuilder<TenantClass> builder)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class PlatformTenantCapabilities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "platform_payment_apps",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
app_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
app_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
settlement_mode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_payment_apps", x => x.id);
});
migrationBuilder.CreateTable(
name: "sms_channels",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
signature = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
scene = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
priority = table.Column<int>(type: "integer", nullable: false),
monthly_quota = table.Column<int>(type: "integer", nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_sms_channels", x => x.id);
table.UniqueConstraint("ak_sms_channels_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0");
table.ForeignKey(
name: "fk_sms_channels_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "platform_payment_channels",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
app_id = table.Column<Guid>(type: "uuid", nullable: false),
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
mode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
callback_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
priority = table.Column<int>(type: "integer", nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_payment_channels", x => x.id);
table.ForeignKey(
name: "fk_platform_payment_channels_platform_payment_apps_app_id",
column: x => x.app_id,
principalTable: "platform_payment_apps",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sms_templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
channel_id = table.Column<Guid>(type: "uuid", nullable: false),
code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
audit_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
provider_template_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
content = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
remark = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
approved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
disabled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_sms_templates", x => x.id);
table.UniqueConstraint("ak_sms_templates_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_sms_templates_sms_channels_tenant_id_channel_id",
columns: x => new { x.tenant_id, x.channel_id },
principalTable: "sms_channels",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_sms_templates_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sms_send_logs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
channel_id = table.Column<Guid>(type: "uuid", nullable: true),
template_id = table.Column<Guid>(type: "uuid", nullable: true),
provider = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
scene = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
phone_masked = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
provider_message_id = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
error_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
error_message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
sent_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_sms_send_logs", x => x.id);
table.ForeignKey(
name: "fk_sms_send_logs_sms_channels_tenant_id_channel_id",
columns: x => new { x.tenant_id, x.channel_id },
principalTable: "sms_channels",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_sms_send_logs_sms_templates_tenant_id_template_id",
columns: x => new { x.tenant_id, x.template_id },
principalTable: "sms_templates",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_sms_send_logs_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_platform_payment_apps_app_code",
table: "platform_payment_apps",
column: "app_code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_payment_apps_status_app_code",
table: "platform_payment_apps",
columns: new[] { "status", "app_code" });
migrationBuilder.CreateIndex(
name: "ix_platform_payment_channels_app_id_provider",
table: "platform_payment_channels",
columns: new[] { "app_id", "provider" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_payment_channels_status_priority_provider",
table: "platform_payment_channels",
columns: new[] { "status", "priority", "provider" });
migrationBuilder.CreateIndex(
name: "ix_sms_channels_tenant_id_provider_scene",
table: "sms_channels",
columns: new[] { "tenant_id", "provider", "scene" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_sms_channels_tenant_id_status_priority",
table: "sms_channels",
columns: new[] { "tenant_id", "status", "priority" });
migrationBuilder.CreateIndex(
name: "ix_sms_send_logs_tenant_id_channel_id_created_at",
table: "sms_send_logs",
columns: new[] { "tenant_id", "channel_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_sms_send_logs_tenant_id_created_at",
table: "sms_send_logs",
columns: new[] { "tenant_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_sms_send_logs_tenant_id_template_id",
table: "sms_send_logs",
columns: new[] { "tenant_id", "template_id" });
migrationBuilder.CreateIndex(
name: "ix_sms_templates_tenant_id_channel_id_status",
table: "sms_templates",
columns: new[] { "tenant_id", "channel_id", "status" });
migrationBuilder.CreateIndex(
name: "ix_sms_templates_tenant_id_code",
table: "sms_templates",
columns: new[] { "tenant_id", "code" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "platform_payment_channels");
migrationBuilder.DropTable(
name: "sms_send_logs");
migrationBuilder.DropTable(
name: "platform_payment_apps");
migrationBuilder.DropTable(
name: "sms_templates");
migrationBuilder.DropTable(
name: "sms_channels");
}
}
}

View File

@@ -13180,6 +13180,161 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AppCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("app_code");
b.Property<string>("AppName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("app_name");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("description");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("SettlementMode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("settlement_mode");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_payment_apps");
b.HasIndex("AppCode")
.IsUnique()
.HasDatabaseName("ix_platform_payment_apps_app_code");
b.HasIndex("Status", "AppCode")
.HasDatabaseName("ix_platform_payment_apps_status_app_code");
b.ToTable("platform_payment_apps", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_id");
b.Property<string>("CallbackPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("callback_path");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("mode");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)")
.HasColumnName("provider");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_payment_channels");
b.HasIndex("AppId", "Provider")
.IsUnique()
.HasDatabaseName("ix_platform_payment_channels_app_id_provider");
b.HasIndex("Status", "Priority", "Provider")
.HasDatabaseName("ix_platform_payment_channels_status_priority_provider");
b.ToTable("platform_payment_channels", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.SaasFeature", b =>
{
b.Property<Guid>("Id")
@@ -14558,6 +14713,193 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsChannel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("MonthlyQuota")
.HasColumnType("integer")
.HasColumnName("monthly_quota");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)")
.HasColumnName("provider");
b.Property<string>("Scene")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("scene");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("signature");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_sms_channels");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_channels_tenant_id_id");
b.HasIndex("TenantId", "Provider", "Scene")
.IsUnique()
.HasDatabaseName("ix_sms_channels_tenant_id_provider_scene");
b.HasIndex("TenantId", "Status", "Priority")
.HasDatabaseName("ix_sms_channels_tenant_id_status_priority");
b.ToTable("sms_channels", null, t =>
{
t.HasCheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ChannelId")
.HasColumnType("uuid")
.HasColumnName("channel_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("ErrorCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("error_code");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("error_message");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("PhoneMasked")
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("phone_masked");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)")
.HasColumnName("provider");
b.Property<string>("ProviderMessageId")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("provider_message_id");
b.Property<string>("Scene")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("scene");
b.Property<DateTimeOffset?>("SentAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("sent_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid")
.HasColumnName("template_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_sms_send_logs");
b.HasIndex("TenantId", "CreatedAt")
.HasDatabaseName("ix_sms_send_logs_tenant_id_created_at");
b.HasIndex("TenantId", "TemplateId")
.HasDatabaseName("ix_sms_send_logs_tenant_id_template_id");
b.HasIndex("TenantId", "ChannelId", "CreatedAt")
.HasDatabaseName("ix_sms_send_logs_tenant_id_channel_id_created_at");
b.ToTable("sms_send_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
{
b.Property<Guid>("TenantId")
@@ -14602,6 +14944,114 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("ApprovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("approved_at");
b.Property<string>("AuditStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("audit_status");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid")
.HasColumnName("channel_id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("code");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("content");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("DisabledAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("disabled_at");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("ProviderTemplateCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("provider_template_code");
b.Property<string>("Remark")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("remark");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("submitted_at");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("type");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_sms_templates");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_templates_tenant_id_id");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_sms_templates_tenant_id_code");
b.HasIndex("TenantId", "ChannelId", "Status")
.HasDatabaseName("ix_sms_templates_tenant_id_channel_id_status");
b.ToTable("sms_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b =>
{
b.Property<Guid>("Id")
@@ -19008,6 +19458,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_platform_billing_refunds_platform_billing_payments_tenant_i~");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b =>
{
b.HasOne("Tiku.Domain.Platform.PlatformPaymentApp", null)
.WithMany()
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_payment_channels_platform_payment_apps_app_id");
});
modelBuilder.Entity("Tiku.Domain.Platform.SaasFeatureLimitDefinition", b =>
{
b.HasOne("Tiku.Domain.Platform.SaasFeature", null)
@@ -19313,6 +19773,40 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_auth_sessions_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsChannel", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sms_channels_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sms_send_logs_tenants_tenant_id");
b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null)
.WithMany()
.HasForeignKey("TenantId", "ChannelId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_sms_send_logs_sms_channels_tenant_id_channel_id");
b.HasOne("Tiku.Domain.Tenancy.SmsTemplate", null)
.WithMany()
.HasForeignKey("TenantId", "TemplateId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_sms_send_logs_sms_templates_tenant_id_template_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -19323,6 +19817,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_sms_send_rate_limits_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsTemplate", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sms_templates_tenants_tenant_id");
b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null)
.WithMany()
.HasForeignKey("TenantId", "ChannelId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sms_templates_sms_channels_tenant_id_channel_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -54,6 +54,9 @@ public sealed class TikuDbContext(
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
public DbSet<SmsChannel> SmsChannels => Set<SmsChannel>();
public DbSet<SmsTemplate> SmsTemplates => Set<SmsTemplate>();
public DbSet<SmsSendLog> SmsSendLogs => Set<SmsSendLog>();
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
@@ -184,6 +187,8 @@ public sealed class TikuDbContext(
public DbSet<PlatformAuditAlert> PlatformAuditAlerts => Set<PlatformAuditAlert>();
public DbSet<PlatformBillingDunningNotificationChannel> PlatformBillingDunningNotificationChannels => Set<PlatformBillingDunningNotificationChannel>();
public DbSet<PlatformBillingDunningNotificationEvent> PlatformBillingDunningNotificationEvents => Set<PlatformBillingDunningNotificationEvent>();
public DbSet<PlatformPaymentApp> PlatformPaymentApps => Set<PlatformPaymentApp>();
public DbSet<PlatformPaymentChannel> PlatformPaymentChannels => Set<PlatformPaymentChannel>();
public DbSet<SaasFeature> SaasFeatures => Set<SaasFeature>();
public DbSet<PermissionModule> PermissionModules => Set<PermissionModule>();
public DbSet<SaasOffering> SaasOfferings => Set<SaasOffering>();

View File

@@ -0,0 +1,527 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Growth;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformCrmAdminService
{
public Task<PlatformTenantCapabilityList<PlatformCrmConfigItem>> GetConfigsAsync(
PlatformCapabilityActor actor,
PlatformCapabilityQuery query,
CancellationToken cancellationToken = default) =>
ExecuteAsync("platform crm configs list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values =
from config in db.CrmConfigs.AsNoTracking()
join tenant in db.Tenants.AsNoTracking() on config.TenantId equals tenant.Id
select new { config, tenant.Name };
if (query.TenantId.HasValue) values = values.Where(value => value.config.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
var enabled = IsEnabledStatus(query.Status);
values = values.Where(value => value.config.Enabled == enabled);
}
var items = await values
.OrderByDescending(value => value.config.UpdatedAt)
.Take(Limit(query.Limit))
.Select(value => ToCrmConfigItem(value.config, value.Name))
.ToArrayAsync(token);
return new PlatformTenantCapabilityList<PlatformCrmConfigItem>(items);
}, cancellationToken);
public Task<PlatformCrmConfigItem> UpsertConfigAsync(
PlatformCapabilityActor actor,
UpsertPlatformCrmConfigCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("platform crm config upsert", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var tenantName = await db.Tenants
.Where(tenant => tenant.Id == command.TenantId)
.Select(tenant => tenant.Name)
.SingleOrDefaultAsync(token)
?? throw Error("Tenant was not found.", "tenant_not_found");
var item = command.Id.HasValue
? await db.CrmConfigs.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token)
: await db.CrmConfigs.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, token);
if (item is null)
{
item = new CrmConfig { TenantId = command.TenantId };
db.CrmConfigs.Add(item);
}
item.Enabled = command.Enabled;
item.Url = Normalize(command.Url);
item.SecretRef = Normalize(command.SecretRef);
item.FormName = Normalize(command.FormName);
item.ExamType = Normalize(command.ExamType);
item.TimeoutSeconds = command.TimeoutSeconds;
item.DelaySeconds = command.DelaySeconds;
item.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None);
item.AssignmentPool = command.AssignmentPool ?? JsonDefaults.Array();
item.AssignmentConfig = command.AssignmentConfig ?? JsonDefaults.Object();
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, new { item.Enabled, item.Url, item.AssignmentMode });
await db.SaveChangesAsync(token);
return ToCrmConfigItem(item, tenantName);
}, cancellationToken);
public Task<PlatformTenantCapabilityList<CrmWebhookQueueItem>> GetLeadsAsync(
PlatformCapabilityActor actor,
PlatformCapabilityQuery query,
CancellationToken cancellationToken = default) =>
ExecuteAsync("platform crm leads list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.CrmWebhookQueue.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending));
var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
return new PlatformTenantCapabilityList<CrmWebhookQueueItem>(items);
}, cancellationToken);
public Task<CrmWebhookQueueItem> RetryLeadAsync(
PlatformCapabilityActor actor,
PlatformCrmLeadRetryCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("platform crm lead retry", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.CrmWebhookQueue.SingleOrDefaultAsync(value => value.Id == command.QueueId, token)
?? throw Error("CRM queue item was not found.", "crm_queue_not_found");
if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying))
{
throw Error("Only failed CRM queue items can be retried.", "crm_queue_retry_invalid");
}
item.Status = CrmWebhookQueueStatus.Retrying;
item.NextAttemptAt = DateTimeOffset.UtcNow;
item.LastError = null;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, new { command.Note });
await db.SaveChangesAsync(token);
return item;
}, cancellationToken);
public Task<PlatformTenantCapabilityList<CrmWebhookLog>> GetLogsAsync(
PlatformCapabilityActor actor,
Guid? tenantId,
Guid? queueId,
int limit,
CancellationToken cancellationToken = default) =>
ExecuteAsync("platform crm logs list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.CrmWebhookLogs.AsNoTracking();
if (tenantId.HasValue) values = values.Where(value => value.TenantId == tenantId);
if (queueId.HasValue)
{
var queueRecordId = await db.CrmWebhookQueue.AsNoTracking()
.Where(value => value.Id == queueId.Value)
.Select(value => value.RecordId)
.SingleOrDefaultAsync(token);
values = values.Where(value => value.RecordId == queueRecordId);
}
var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(limit)).ToArrayAsync(token);
return new PlatformTenantCapabilityList<CrmWebhookLog>(items);
}, cancellationToken);
private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) =>
new(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, config.SecretRef, config.FormName, config.ExamType,
config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, config.AssignmentConfig, config.UpdatedAt);
private Task<TResult> ExecuteAsync<TResult>(string reason, Func<IServiceProvider, CancellationToken, Task<TResult>> operation, CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken);
private static bool IsEnabledStatus(string value) => value.Equals("active", StringComparison.OrdinalIgnoreCase) || value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常";
private static PlatformCapabilityException Error(string message, string code) => new(message, code);
private static int Limit(int value) => Math.Clamp(value, 1, 500);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(NormalizeEnum(value), true, out var parsed) ? parsed : fallback;
private static string? NormalizeEnum(string? value) => value?.Trim().Replace("-", "_", StringComparison.Ordinal);
private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) =>
db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) });
}
internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformSmsAdminService
{
public Task<PlatformTenantCapabilityList<PlatformSmsChannelItem>> GetChannelsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms channels list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
var sentCounts = await db.SmsSendLogs.AsNoTracking()
.Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && value.ChannelId.HasValue)
.GroupBy(value => value.ChannelId!.Value)
.Select(group => new { ChannelId = group.Key, Count = group.Count() })
.ToDictionaryAsync(value => value.ChannelId, value => value.Count, token);
var values =
from channel in db.SmsChannels.AsNoTracking()
join tenant in db.Tenants.AsNoTracking() on channel.TenantId equals tenant.Id
select new { channel, tenant.Name };
if (query.TenantId.HasValue) values = values.Where(value => value.channel.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active));
var rows = await values.OrderBy(value => value.channel.Priority).ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
return new PlatformTenantCapabilityList<PlatformSmsChannelItem>(rows.Select(value => ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray());
}, cancellationToken);
public Task<PlatformSmsChannelItem> UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms channel upsert", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var tenantName = await TenantNameAsync(db, command.TenantId, token);
var provider = NormalizeCode(command.Provider);
var scene = NormalizeCode(command.Scene);
var item = command.Id.HasValue
? await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token)
: await db.SmsChannels.SingleOrDefaultAsync(value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, token);
if (item is null)
{
item = new SmsChannel { TenantId = command.TenantId };
db.SmsChannels.Add(item);
}
item.Provider = provider;
item.Name = command.Name.Trim();
item.Signature = command.Signature.Trim();
item.Scene = scene;
item.Status = command.Status;
item.SecretRef = Normalize(command.SecretRef);
item.Priority = command.Priority ?? item.Priority;
item.MonthlyQuota = Math.Max(0, command.MonthlyQuota ?? item.MonthlyQuota);
item.ConfigPublic = command.ConfigPublic;
item.Metadata = command.Metadata;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, new { item.Provider, item.Scene, item.Status });
await db.SaveChangesAsync(token);
return ToChannelItem(item, tenantName, 0);
}, cancellationToken);
public Task<PlatformSmsChannelItem> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms channel disable", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == channelId, token)
?? throw Error("SMS channel was not found.", "sms_channel_not_found");
item.Status = TenantExternalProviderStatus.Disabled;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, new { item.Provider, item.Scene });
await db.SaveChangesAsync(token);
return ToChannelItem(item, await TenantNameAsync(db, item.TenantId, token), 0);
}, cancellationToken);
public Task<PlatformTenantCapabilityList<PlatformSmsTemplateItem>> GetTemplatesAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms templates list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var sentCounts = await db.SmsSendLogs.AsNoTracking()
.Where(value => value.TemplateId.HasValue)
.GroupBy(value => value.TemplateId!.Value)
.Select(group => new { TemplateId = group.Key, Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) })
.ToDictionaryAsync(value => value.TemplateId, token);
var values =
from template in db.SmsTemplates.AsNoTracking()
join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals new { channel.TenantId, ChannelId = channel.Id }
join tenant in db.Tenants.AsNoTracking() on template.TenantId equals tenant.Id
select new { template, channel.Name, TenantName = tenant.Name };
if (query.TenantId.HasValue) values = values.Where(value => value.template.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active));
var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
return new PlatformTenantCapabilityList<PlatformSmsTemplateItem>(rows.Select(value =>
{
var counts = sentCounts.GetValueOrDefault(value.template.Id);
return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, counts?.Failed ?? 0);
}).ToArray());
}, cancellationToken);
public Task<PlatformSmsTemplateItem> UpsertTemplateAsync(PlatformCapabilityActor actor, UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms template upsert", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var channel = await db.SmsChannels.AsNoTracking().SingleOrDefaultAsync(value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token)
?? throw Error("SMS channel was not found.", "sms_channel_not_found");
var item = command.Id.HasValue
? await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token)
: await db.SmsTemplates.SingleOrDefaultAsync(value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token);
if (item is null)
{
item = new SmsTemplate { TenantId = command.TenantId };
db.SmsTemplates.Add(item);
}
item.ChannelId = command.ChannelId;
item.Code = NormalizeCode(command.Code);
item.Name = command.Name.Trim();
item.Type = command.Type;
item.AuditStatus = command.AuditStatus;
item.Status = command.Status;
item.ProviderTemplateCode = Normalize(command.ProviderTemplateCode);
item.Content = command.Content.Trim();
item.Remark = Normalize(command.Remark);
item.Metadata = command.Metadata;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status });
await db.SaveChangesAsync(token);
return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channel.Name, 0, 0);
}, cancellationToken);
public Task<PlatformSmsTemplateItem> SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) =>
ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, "platform.sms.template.review_submitted", cancellationToken);
public Task<PlatformSmsTemplateItem> DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) =>
ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, "platform.sms.template.disabled", cancellationToken);
public Task<PlatformTenantCapabilityList<SmsSendLog>> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform sms logs list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.SmsSendLogs.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent));
return new PlatformTenantCapabilityList<SmsSendLog>(await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token));
}, cancellationToken);
private Task<PlatformSmsTemplateItem> ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, CancellationToken cancellationToken) =>
ExecuteAsync(action, async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == templateId, token)
?? throw Error("SMS template was not found.", "sms_template_not_found");
var channelName = await db.SmsChannels.AsNoTracking().Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId).Select(value => value.Name).SingleAsync(token);
if (auditStatus.HasValue)
{
item.AuditStatus = auditStatus.Value;
item.SubmittedAt = DateTimeOffset.UtcNow;
}
if (status.HasValue)
{
item.Status = status.Value;
item.DisabledAt = DateTimeOffset.UtcNow;
}
item.UpdatedAt = DateTimeOffset.UtcNow;
AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status });
await db.SaveChangesAsync(token);
return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channelName, 0, 0);
}, cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(string reason, Func<IServiceProvider, CancellationToken, Task<TResult>> operation, CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken);
private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) =>
new(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, channel.Signature, channel.Scene, channel.Status,
channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, channel.Metadata, channel.UpdatedAt);
private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, int sent, int failed) =>
new(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, template.Code, template.Name, template.Type,
template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, sent, failed, template.UpdatedAt);
private static async Task<string> TenantNameAsync(TikuDbContext db, Guid tenantId, CancellationToken token) =>
await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name).SingleOrDefaultAsync(token)
?? throw Error("Tenant was not found.", "tenant_not_found");
private static PlatformCapabilityException Error(string message, string code) => new(message, code);
private static int Limit(int value) => Math.Clamp(value, 1, 500);
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback;
private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) =>
db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) });
}
internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) : IPlatformPaymentSettingsService
{
public Task<IReadOnlyCollection<PlatformPaymentApp>> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformPaymentApp>>("platform payment apps list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.PlatformPaymentApps.AsNoTracking();
if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active));
return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<PlatformPaymentApp> UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform payment app upsert", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var code = NormalizeCode(command.AppCode);
var item = command.Id.HasValue
? await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token)
: await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.AppCode == code, token);
if (item is null)
{
item = new PlatformPaymentApp { AppCode = code };
db.PlatformPaymentApps.Add(item);
}
item.AppName = command.AppName.Trim();
item.Status = command.Status;
item.SettlementMode = command.SettlementMode.Trim();
item.Description = Normalize(command.Description);
item.Metadata = command.Metadata;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, new { item.AppCode, item.Status });
await db.SaveChangesAsync(token);
return item;
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformPaymentChannel>> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformPaymentChannel>>("platform payment channels list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.PlatformPaymentChannels.AsNoTracking();
if (appId.HasValue) values = values.Where(value => value.AppId == appId);
if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active));
return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<PlatformPaymentChannel> UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform payment channel upsert", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) throw Error("Platform payment app was not found.", "platform_payment_app_not_found");
var provider = NormalizeCode(command.Provider);
var item = command.Id.HasValue
? await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token)
: await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.AppId == command.AppId && value.Provider == provider, token);
if (item is null)
{
item = new PlatformPaymentChannel { AppId = command.AppId, Provider = provider };
db.PlatformPaymentChannels.Add(item);
}
item.Mode = command.Mode.Trim();
item.Status = command.Status;
item.DisplayName = command.DisplayName.Trim();
item.SecretRef = Normalize(command.SecretRef);
item.CallbackPath = Normalize(command.CallbackPath);
item.Priority = command.Priority ?? item.Priority;
item.ConfigPublic = command.ConfigPublic;
item.Metadata = command.Metadata;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, new { item.Provider, item.Status, item.SecretRef });
await db.SaveChangesAsync(token);
return item;
}, cancellationToken);
public Task<PlatformPaymentChannel> DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform payment channel disable", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == channelId, token)
?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found");
item.Status = PlatformPaymentChannelStatus.Disabled;
item.UpdatedAt = DateTimeOffset.UtcNow;
AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, new { item.Provider });
await db.SaveChangesAsync(token);
return item;
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformBillingPaymentEvent>> GetEventsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingPaymentEvent>>("platform payment events list", async (services, token) =>
await services.GetRequiredService<TikuDbContext>().PlatformBillingPaymentEvents.AsNoTracking()
.OrderByDescending(value => value.CreatedAt)
.Take(Limit(limit))
.ToArrayAsync(token), cancellationToken);
public Task<PlatformRebateSummary> GetRebateSummaryAsync(PlatformCapabilityActor actor, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform rebate summary", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var settlements = db.CommissionSettlements.AsNoTracking();
var gross = await settlements.SumAsync(value => (int?)value.GrossAmountCents, token) ?? 0;
var commission = await settlements.SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0;
var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid).SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0;
var pending = await settlements.Where(value => value.Status == CommissionSettlementStatus.Approved || value.Status == CommissionSettlementStatus.PendingReview).SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0;
var exceptions = await settlements.CountAsync(value => value.Status == CommissionSettlementStatus.Rejected || value.Status == CommissionSettlementStatus.Cancelled, token);
return new PlatformRebateSummary(gross, commission, pending, paid, exceptions);
}, cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(string reason, Func<IServiceProvider, CancellationToken, Task<TResult>> operation, CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken);
private static PlatformCapabilityException Error(string message, string code) => new(message, code);
private static int Limit(int value) => Math.Clamp(value, 1, 500);
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback;
private static void AddPlatformAudit(TikuDbContext db, PlatformCapabilityActor actor, string action, string targetType, Guid targetId, object details) =>
db.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) });
}
internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformTenantPaymentAdminService
{
public Task<PlatformTenantCapabilityList<TenantExternalProviderItem>> GetAppsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform tenant payment apps list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.TenantExternalProviders.AsNoTracking()
.Where(value => value.Capability == TenantExternalProviderCapability.Payment);
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active));
var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority).Take(Limit(query.Limit))
.Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, value.Metadata, value.CreatedAt, value.UpdatedAt))
.ToArrayAsync(token);
return new PlatformTenantCapabilityList<TenantExternalProviderItem>(items);
}, cancellationToken);
public Task<TenantExternalProviderItem> UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform tenant payment app upsert", async (services, token) =>
{
if (command.Capability != TenantExternalProviderCapability.Payment) throw Error("Tenant payment capability is required.", "tenant_payment_capability_required");
var item = await services.GetRequiredService<ITenantExternalProviderConfigService>().UpsertProviderAsync(tenantId, command, token);
services.GetRequiredService<TikuDbContext>().AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
ActorUserId = actor.UserId,
Action = "platform.tenant_payment.app.upserted",
TargetType = "tenant_external_providers",
TargetId = item.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new { item.Provider, item.Status, item.SecretRef })
});
await services.GetRequiredService<TikuDbContext>().SaveChangesAsync(token);
return item;
}, cancellationToken);
public Task<PlatformTenantCapabilityList<object>> GetEventsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync("platform tenant payment events list", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.PaymentEvents.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit))
.Select(value => new
{
value.Id,
value.TenantId,
value.PaymentId,
value.Provider,
value.EventType,
value.EventId,
value.SignatureValid,
value.ProcessedAt,
value.Error,
value.CreatedAt
})
.Cast<object>()
.ToArrayAsync(token);
return new PlatformTenantCapabilityList<object>(items);
}, cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(string reason, Func<IServiceProvider, CancellationToken, Task<TResult>> operation, CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken);
private static PlatformCapabilityException Error(string message, string code) => new(message, code);
private static int Limit(int value) => Math.Clamp(value, 1, 500);
private static T ParseEnum<T>(string? value, T fallback) where T : struct, Enum => Enum.TryParse<T>(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback;
}

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -68,8 +69,17 @@ internal sealed class PlatformBillingPaymentGateway(
}
else
{
account = await services.GetRequiredService<IPaymentProviderConfigService>()
.GetActiveAccountAsync(platformTenantId, normalized, token);
var channel = await dbContext.PlatformPaymentChannels.AsNoTracking()
.Where(value => value.Provider == normalized && value.Status == PlatformPaymentChannelStatus.Active)
.OrderBy(value => value.Priority)
.FirstOrDefaultAsync(token)
?? throw new PaymentProviderException("Platform payment channel is missing.", "platform_payment_channel_missing");
account = new PaymentProviderAccount(
platformTenantId,
normalized,
channel.Mode,
channel.ConfigPublic,
JsonDocument.Parse("{}").RootElement.Clone());
}
return await operation(resolvedProvider, account, token);