forked from gongxuegit/tiku-backend.net
feat(platform): add CRM SMS and payment settings
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
20068
Tiku.Infrastructure/Persistence/Migrations/20260729071227_PlatformTenantCapabilities.Designer.cs
generated
Normal file
20068
Tiku.Infrastructure/Persistence/Migrations/20260729071227_PlatformTenantCapabilities.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user