511 lines
21 KiB
C#
511 lines
21 KiB
C#
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<bool> 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<string> permissionCodes,
|
|
IReadOnlySet<string> 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<User>(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.");
|
|
}
|
|
}
|