using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; 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; public static class DevelopmentPlatformAdminSeeder { public const string Email = "admin@tiku.local"; public const string RoleCode = PlatformAdminBootstrapper.SuperAdminRoleCode; public static void Configure(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseSeeding((context, _) => Seed((TikuDbContext)context)) .UseAsyncSeeding((context, _, cancellationToken) => SeedAsync((TikuDbContext)context, cancellationToken)); } public static bool Seed(TikuDbContext dbContext) { ReloadPostgresTypes(dbContext); if (dbContext.PlatformBackendUserRoles.Any()) { EnsurePlatformPermissionCatalog(dbContext); SeedDemoTenantCapabilities(dbContext); dbContext.SaveChanges(); return false; } var temporaryPassword = GenerateTemporaryPassword(); EnsureEmailIsAvailable(dbContext.Users.Any(user => user.NormalizedEmail == Email.ToUpperInvariant() || user.NormalizedUserName == Email.ToUpperInvariant())); var permissionCodes = BackendPermissions.Platform.ToArray(); var existingPermissionCodes = dbContext.BackendPermissions .Where(permission => permissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToHashSet(StringComparer.Ordinal); AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes); SeedDemoTenantCapabilities(dbContext); dbContext.SaveChanges(); WriteFirstLoginInstructions(temporaryPassword); return true; } public static async Task SeedAsync( TikuDbContext dbContext, CancellationToken cancellationToken = default) { await ReloadPostgresTypesAsync(dbContext, cancellationToken); if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken)) { EnsurePlatformPermissionCatalog(dbContext); SeedDemoTenantCapabilities(dbContext); await dbContext.SaveChangesAsync(cancellationToken); return false; } var temporaryPassword = GenerateTemporaryPassword(); var normalizedEmail = Email.ToUpperInvariant(); EnsureEmailIsAvailable(await dbContext.Users.AnyAsync(user => user.NormalizedEmail == normalizedEmail || user.NormalizedUserName == normalizedEmail, cancellationToken)); var permissionCodes = BackendPermissions.Platform.ToArray(); var existingPermissionCodes = (await dbContext.BackendPermissions .Where(permission => permissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToArrayAsync(cancellationToken)) .ToHashSet(StringComparer.Ordinal); AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes); SeedDemoTenantCapabilities(dbContext); await dbContext.SaveChangesAsync(cancellationToken); WriteFirstLoginInstructions(temporaryPassword); return true; } private static void AddSeedGraph( TikuDbContext dbContext, string temporaryPassword, IReadOnlyCollection permissionCodes, IReadOnlySet existingPermissionCodes) { var normalizedEmail = Email.ToUpperInvariant(); var user = new User { Email = Email, NormalizedEmail = normalizedEmail, UserName = Email, NormalizedUserName = normalizedEmail, Name = "Local Platform Administrator", EmailConfirmed = true, Status = UserStatus.Active, ForcePasswordChange = true }; var passwordHasher = new PasswordHasher(Options.Create(new PasswordHasherOptions { IterationCount = 210_000 })); user.PasswordHash = passwordHasher.HashPassword(user, temporaryPassword); var role = new PlatformBackendRole { Code = RoleCode, Name = "Platform Super Administrator", Description = "Built-in Development administrator created by EF Core data seeding.", Status = BackendRoleStatus.Active, IsSystem = true }; dbContext.Users.Add(user); dbContext.PlatformBackendRoles.Add(role); var platformModuleCodes = permissionCodes .Select(PermissionModuleCatalog.ResolvePermissionModuleCode) .Distinct(StringComparer.Ordinal) .ToArray(); var existingModuleCodes = dbContext.PermissionModules .Where(module => platformModuleCodes.Contains(module.Code)) .Select(module => module.Code) .ToArray(); dbContext.PermissionModules.AddRange(platformModuleCodes .Except(existingModuleCodes, StringComparer.Ordinal) .Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Platform })); dbContext.BackendPermissions.AddRange( permissionCodes .Where(code => !existingPermissionCodes.Contains(code)) .Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), Description = "Built-in platform permission.", IsSystem = true })); dbContext.PlatformBackendRolePermissions.AddRange( permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = role.Id, PermissionCode = code })); dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole { UserId = user.Id, RoleId = role.Id }); dbContext.AuditLogs.Add(new AuditLog { ActorUserId = user.Id, Action = "platform.bootstrap_admin.created", TargetType = "users", TargetId = user.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { user.Email, RoleCode, ForcePasswordChange = true, Source = "ef_core_use_seeding" }) }); } 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"; private static void ReloadPostgresTypes(TikuDbContext dbContext) { if (dbContext.Database.IsNpgsql()) { ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes(); } } private static async Task ReloadPostgresTypesAsync( TikuDbContext dbContext, CancellationToken cancellationToken) { if (dbContext.Database.IsNpgsql()) { await ((NpgsqlConnection)dbContext.Database.GetDbConnection()) .ReloadTypesAsync(cancellationToken); } } private static void EnsureEmailIsAvailable(bool isAssigned) { if (isAssigned) { throw new InvalidOperationException( $"Cannot create the Development platform administrator because '{Email}' is already assigned."); } } private static void WriteFirstLoginInstructions(string temporaryPassword) { Console.WriteLine("Development platform administrator created by EF Core data seeding."); Console.WriteLine($" Account: {Email}"); Console.WriteLine($" Temporary password: {temporaryPassword}"); Console.WriteLine(" Change the temporary password at first sign-in. This password is shown only once."); } }