using System.Text.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class PlatformAdminService( ICurrentAccessContext currentAccessContext, ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService { public async Task GetOverviewAsync( PlatformAdminActor actor, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken); return await ExecuteSystemAsync("platform overview", async dbContext => { var row = await dbContext.Database.SqlQuery($""" SELECT (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount", (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount", (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount", (SELECT count(*)::integer FROM orders) AS "OrderCount", (SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount", (SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents", (SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount", (SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount", (SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount" """).SingleAsync(cancellationToken); return new PlatformOverview( row.TenantCount, row.ActiveTenantCount, row.SuspendedTenantCount, row.OrderCount, row.PaidOrderCount, row.RevenueCents, row.QuestionBankCount, row.QuestionCount, row.LearningActiveUserCount); }, cancellationToken); } private sealed class PlatformOverviewRow { public int TenantCount { get; init; } public int ActiveTenantCount { get; init; } public int SuspendedTenantCount { get; init; } public int OrderCount { get; init; } public int PaidOrderCount { get; init; } public int RevenueCents { get; init; } public int QuestionBankCount { get; init; } public int QuestionCount { get; init; } public int LearningActiveUserCount { get; init; } } public async Task GetTenantsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant list", async dbContext => { var tenants = dbContext.Tenants.AsNoTracking() .Where(tenant => tenant.Mode != TenantMode.PlatformOwned); if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); tenants = tenants.Where(tenant => tenant.Slug.Contains(search) || tenant.Name.Contains(search) || (tenant.LegalName != null && tenant.LegalName.Contains(search))); } if (!string.IsNullOrWhiteSpace(query.Status)) { tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status)); } var rows = await tenants .OrderByDescending(tenant => tenant.CreatedAt) .Take(Limit(query.Limit)) .Select(tenant => new { Tenant = tenant, DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id), SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions .Where(subscription => subscription.TenantId == tenant.Id) .OrderByDescending(subscription => subscription.CurrentPeriodEnd) .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) .FirstOrDefault() }) .ToArrayAsync(cancellationToken); return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); }, cancellationToken); } public async Task GetTenantDetailAsync( PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant detail", async dbContext => { var tenant = await dbContext.Tenants.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); var domains = await dbContext.TenantDomains.AsNoTracking() .Where(domain => domain.TenantId == tenantId) .OrderByDescending(domain => domain.IsPrimary) .ThenBy(domain => domain.Host) .ToArrayAsync(cancellationToken); var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => subscription.TenantId == tenantId) .OrderByDescending(subscription => subscription.CreatedAt) .ToArrayAsync(cancellationToken); var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking() .SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken); return new PlatformTenantDetail( ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd), domains.Select(ToDomainItem).ToArray(), subscriptions.Select(ToSubscriptionItem).ToArray(), billingProfile is null ? null : ToBillingProfileItem(billingProfile)); }, cancellationToken); } public async Task CreateTenantAsync( PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) => { await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var slug = NormalizeCode(command.Slug); if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken)) { throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists"); } var ownerEmail = Normalize(command.OwnerEmail); var ownerPhone = Normalize(command.OwnerPhone); var ownerIdentifier = ownerEmail ?? ownerPhone; if (ownerIdentifier is null) { throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required"); } if (await dbContext.Users.AnyAsync(user => (ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) || (ownerPhone != null && user.Phone == ownerPhone), cancellationToken)) { throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists"); } var tenant = new Tenant { Slug = slug, Name = command.Name.Trim(), LegalName = Normalize(command.LegalName), Status = command.Status, Mode = TenantMode.Saas, BillingStatus = command.BillingStatus, Metadata = JsonObjectOrDefault(command.Metadata) }; dbContext.Tenants.Add(tenant); var owner = new User { Email = ownerEmail, NormalizedEmail = ownerEmail?.ToUpperInvariant(), UserName = ownerIdentifier, NormalizedUserName = ownerIdentifier.ToUpperInvariant(), Phone = ownerPhone, PhoneNumber = ownerPhone, Name = command.OwnerName.Trim(), PrimaryRole = "tenant_owner", Status = UserStatus.Active, ForcePasswordChange = true, EmailConfirmed = ownerEmail is not null, PhoneNumberConfirmed = ownerPhone is not null }; var createOwner = await provider.GetRequiredService>() .CreateAsync(owner, command.TemporaryPassword); if (!createOwner.Succeeded) { throw new PlatformAdminException( string.Join("; ", createOwner.Errors.Select(error => error.Description)), "tenant_owner_password_invalid"); } tenant.OwnerUserId = owner.Id; dbContext.TenantMemberships.Add(new TenantMembership { TenantId = tenant.Id, UserId = owner.Id, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active }); dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy { TenantId = tenant.Id, AllowExternalStudentSelfRegistration = false }); await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken); AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return new PlatformTenantProvisioningResult( ToTenantItem(tenant, 0, null), owner.Id, ownerIdentifier, true); }, cancellationToken); } public async Task UpdateTenantStatusAsync( PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) => { var tenant = await dbContext.Tenants .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); var fromStatus = tenant.Status; var fromBilling = tenant.BillingStatus; tenant.Status = command.Status; tenant.BillingStatus = command.BillingStatus; AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new { FromStatus = fromStatus, ToStatus = tenant.Status, FromBillingStatus = fromBilling, ToBillingStatus = tenant.BillingStatus, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); await provider.GetRequiredService() .InvalidateAsync(tenant.Id, cancellationToken); var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); var expiresAt = await dbContext.TenantSaasSubscriptions .Where(subscription => subscription.TenantId == tenant.Id) .OrderByDescending(subscription => subscription.CurrentPeriodEnd) .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) .FirstOrDefaultAsync(cancellationToken); return ToTenantItem(tenant, domainCount, expiresAt); }, cancellationToken); } public async Task UpsertTenantBillingProfileAsync( PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext => { await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync( item => item.TenantId == command.TenantId, cancellationToken); if (profile is null) { profile = new TenantBillingProfile { TenantId = command.TenantId }; dbContext.TenantBillingProfiles.Add(profile); } profile.BillingName = Normalize(command.BillingName); profile.TaxId = Normalize(command.TaxId); profile.ContactName = Normalize(command.ContactName); profile.ContactPhone = Normalize(command.ContactPhone); profile.ContactEmail = Normalize(command.ContactEmail); profile.BillingAddress = Normalize(command.BillingAddress); profile.InvoiceTitle = Normalize(command.InvoiceTitle); profile.InvoiceType = command.InvoiceType; profile.BankName = Normalize(command.BankName); profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked); profile.Metadata = JsonObjectOrDefault(command.Metadata); AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType }); await dbContext.SaveChangesAsync(cancellationToken); return ToBillingProfileItem(profile); }, cancellationToken); } public async Task GetDomainsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform domain list", async dbContext => { var domains = dbContext.TenantDomains.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) { domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status)); } if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); domains = domains.Where(domain => domain.Host.Contains(search)); } return new PlatformDomainList(await domains .OrderByDescending(domain => domain.UpdatedAt) .Take(Limit(query.Limit)) .Select(domain => ToDomainItem(domain)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task RecheckDomainAsync( PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform domain recheck", async dbContext => { var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) ?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found"); domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending; domain.LastCheckedAt = DateTimeOffset.UtcNow; domain.LastFailureReason = null; dbContext.BackgroundJobs.Add(new BackgroundJob { TenantId = domain.TenantId, JobType = "tenant_domain_recheck", Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }), MaxRetries = 3 }); AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host }); await dbContext.SaveChangesAsync(cancellationToken); return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value); }, cancellationToken); } public async Task GetStaffAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); return await ExecuteSystemAsync("platform staff list", async dbContext => { var roleRows = await ( from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id select new { user, role.Code }) .ToArrayAsync(cancellationToken); var items = roleRows .GroupBy(row => row.user.Id) .Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())) .OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString()) .Take(Limit(query.Limit)) .ToArray(); return new PlatformStaffList(items); }, cancellationToken); } public async Task UpsertStaffAsync( PlatformAdminActor actor, UpsertPlatformStaffCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); return await ExecuteSystemAsync("platform staff upsert", async dbContext => { var user = command.UserId.HasValue ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken) : await dbContext.Users.SingleOrDefaultAsync(item => (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), cancellationToken); if (user is null) { user = new User { Email = Normalize(command.Email), NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(), UserName = Normalize(command.Email) ?? Normalize(command.Phone), NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(), Phone = Normalize(command.Phone), PhoneNumber = Normalize(command.Phone), Name = Normalize(command.Name), PrimaryRole = "platform_admin", Status = command.Status, ForcePasswordChange = true }; dbContext.Users.Add(user); } else { user.Email = Normalize(command.Email) ?? user.Email; user.NormalizedEmail = user.Email?.ToUpperInvariant(); user.Phone = Normalize(command.Phone) ?? user.Phone; user.PhoneNumber = user.Phone; user.Name = Normalize(command.Name) ?? user.Name; user.Status = command.Status; user.PrimaryRole = "platform_admin"; } var roleIds = command.RoleIds.Distinct().ToArray(); var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); if (roleCount != roleIds.Length) { throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found"); } await dbContext.SaveChangesAsync(cancellationToken); await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken); dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole { UserId = user.Id, RoleId = roleId })); AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); await dbContext.SaveChangesAsync(cancellationToken); var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking() .Where(role => roleIds.Contains(role.Id)) .Select(role => role.Code) .ToArrayAsync(cancellationToken); return ToStaffItem(user, roleCodes); }, cancellationToken); } public async Task UpdateStaffStatusAsync( PlatformAdminActor actor, UpdatePlatformStaffStatusCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); return await ExecuteSystemAsync("platform staff status update", async dbContext => { var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken) ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); var from = user.Status; user.Status = command.Status; AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); var roleCodes = await ( from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id where binding.UserId == user.Id select role.Code) .ToArrayAsync(cancellationToken); return ToStaffItem(user, roleCodes); }, cancellationToken); } public async Task GetAuditLogsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); return await ExecuteSystemAsync("platform audit log list", async dbContext => { var logs = dbContext.AuditLogs.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); } return new PlatformAuditLogList(await logs .OrderByDescending(log => log.CreatedAt) .Take(Limit(query.Limit)) .Select(log => new PlatformAuditLogItem( log.Id, log.TenantId, log.ActorUserId, log.Action, log.TargetType, log.TargetId, log.Details, log.IpAddress, log.UserAgent, log.CreatedAt)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task GetAuditAlertsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); return await ExecuteSystemAsync("platform audit alert list", async dbContext => { var alerts = dbContext.PlatformAuditAlerts.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) { alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status)); } return new PlatformAuditAlertList(await alerts .OrderByDescending(alert => alert.LastSeenAt) .Take(Limit(query.Limit)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task UpdateAuditAlertStatusAsync( PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); return await ExecuteSystemAsync("platform audit alert status update", async dbContext => { var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken) ?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found"); alert.Status = command.Status; alert.ResolutionNote = Normalize(command.ResolutionNote); if (command.Status == PlatformAuditAlertStatus.Acknowledged) { alert.AcknowledgedBy = actor.UserId; alert.AcknowledgedAt ??= DateTimeOffset.UtcNow; } else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored) { alert.ResolvedBy = actor.UserId; alert.ResolvedAt ??= DateTimeOffset.UtcNow; } AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote }); await dbContext.SaveChangesAsync(cancellationToken); return alert; }, cancellationToken); } public async Task GetBillingDunningChannelsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning channel list", async dbContext => { var channels = dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); channels = channels.Where(channel => channel.ChannelCode.Contains(search) || channel.Name.Contains(search)); } if (!string.IsNullOrWhiteSpace(query.Status)) { var enabled = ParseEnabledStatus(query.Status); channels = channels.Where(channel => channel.Enabled == enabled); } return new PlatformBillingDunningChannelList(await channels .OrderByDescending(channel => channel.Enabled) .ThenBy(channel => channel.MinReminderLevel) .ThenBy(channel => channel.ChannelCode) .Take(Limit(query.Limit)) .Select(channel => ToDunningChannelItem(channel)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task UpsertBillingDunningChannelAsync( PlatformAdminActor actor, UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext => { var code = NormalizeCode(command.ChannelCode); var channel = command.ChannelId.HasValue ? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) : await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); if (channel is null) { channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code }; dbContext.PlatformBillingDunningNotificationChannels.Add(channel); } channel.ChannelCode = code; channel.Name = command.Name.Trim(); channel.Description = Normalize(command.Description); channel.Enabled = command.Enabled; channel.Provider = command.Provider; channel.WebhookUrl = command.WebhookUrl.Trim(); channel.SecretRef = Normalize(command.SecretRef); channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]); channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]); channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20); channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray(); channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60); channel.Metadata = JsonObjectOrDefault(command.Metadata); AddAudit(dbContext, actor, "platform.billing_dunning_channel.upserted", channel.Id, new { channel.ChannelCode, channel.Name, channel.Enabled, channel.Provider, Webhook = MaskWebhook(channel.WebhookUrl), channel.SecretRef }); await dbContext.SaveChangesAsync(cancellationToken); return ToDunningChannelItem(channel); }, cancellationToken); } public async Task DisableBillingDunningChannelAsync( PlatformAdminActor actor, DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => { var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken) ?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found"); channel.Enabled = false; AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new { channel.ChannelCode, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); return ToDunningChannelItem(channel); }, cancellationToken); } public async Task GetBillingDunningEventsAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning event list", async dbContext => { var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) { events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); } return new PlatformBillingDunningEventList(await events .OrderByDescending(item => item.CreatedAt) .Take(Limit(query.Limit)) .Select(item => ToDunningEventItem(item)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task GetBillingDunningEventDetailAsync( PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning event detail", async dbContext => { var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); return ToDunningEventItem(item); }, cancellationToken); } public async Task RetryBillingDunningEventAsync( PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning event retry", async dbContext => { var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); item.Status = PlatformBillingDunningNotificationStatus.Pending; item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; AddAudit(dbContext, actor, "platform.billing_dunning_event.retry_requested", item.Id, new { item.TenantId, item.ChannelId, item.ReminderId, item.InvoiceId, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); return ToDunningEventItem(item); }, cancellationToken); } private async Task AssertPlatformPermissionAsync( PlatformAdminActor actor, string permissionCode, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode)) { throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied"); } } private Task ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); } private Task ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Platform, nameof(PlatformAdminService), reason, Guid.NewGuid().ToString("N"), IsGlobal: true), async (provider, _) => await operation(provider, provider.GetRequiredService()), cancellationToken); } private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) { var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken); if (!exists) { throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); } } private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) { dbContext.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = action.Split('.')[1], TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) }); } private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) => new( tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, subscriptionExpiresAt, domainCount, tenant.CreatedAt, tenant.UpdatedAt); private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) => new( domain.Id, domain.TenantId, domain.Host, domain.DomainType, domain.Status, domain.IsPrimary, domain.VerifiedAt, domain.DnsVerifiedAt, domain.TlsReadyAt, domain.LastCheckedAt, domain.LastFailureReason); private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) => new( subscription.Id, subscription.TenantId, subscription.BaseOfferingVersionId, subscription.Status, subscription.StartsAt, subscription.CurrentPeriodStart, subscription.CurrentPeriodEnd, subscription.CancelAtPeriodEnd, subscription.Metadata); private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) => new( profile.TenantId, profile.BillingName, profile.TaxId, profile.ContactName, MaskPhone(profile.ContactPhone), profile.ContactEmail, profile.BillingAddress, profile.InvoiceTitle, profile.InvoiceType, profile.BankName, profile.BankAccountMasked, profile.Metadata); private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) => new( channel.Id, channel.ChannelCode, channel.Name, channel.Description, channel.Enabled, channel.Provider, MaskWebhook(channel.WebhookUrl), channel.SecretRef, channel.ReminderTypes, channel.ReminderChannels, channel.MinReminderLevel, channel.TenantIds, channel.TimeoutSeconds, channel.Metadata, channel.CreatedAt, channel.UpdatedAt); private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) => new( item.Id, item.TenantId, item.ChannelId, item.ReminderId, item.InvoiceId, item.Provider, item.Status, item.Attempts, item.ScheduledAt, item.NextAttemptAt, item.LastAttemptAt, item.SentAt, item.LastError, item.LastHttpCode, item.LastResponseSummary, item.RequestPayload, item.Metadata, item.CreatedAt, item.UpdatedAt); private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) => new( user.Id, user.Name, MaskPhone(user.Phone), user.Email, user.Status, roleCodes, user.CreatedAt, user.UpdatedAt); private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200); private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant(); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static JsonElement JsonObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); private static string? MaskPhone(string? phone) { var value = Normalize(phone); return value is { Length: >= 7 } ? $"{value[..3]}****{value[^4..]}" : value; } private static string? MaskBankAccount(string? account) { var value = Normalize(account); return value is { Length: > 8 } ? $"****{value[^4..]}" : value; } private static string MaskWebhook(string webhookUrl) { var value = Normalize(webhookUrl) ?? string.Empty; if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) { return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}"; } return $"{uri.Scheme}://{uri.Host}/****"; } private static string[] NormalizeArray(IReadOnlyCollection values, string[] fallback) { var normalized = values .Select(Normalize) .Where(value => value is not null) .Select(value => value!) .Distinct(StringComparer.Ordinal) .ToArray(); return normalized.Length == 0 ? fallback : normalized; } private static bool ParseEnabledStatus(string status) { return NormalizeCode(status) switch { "enabled" or "active" or "true" => true, "disabled" or "inactive" or "false" => false, _ => throw InvalidStatus(status) }; } private static TenantStatus ParseTenantStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static TenantDomainStatus ParseDomainStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static async Task EnsureTenantOwnerRoleAsync( TikuDbContext dbContext, Guid tenantId, Guid ownerUserId, CancellationToken cancellationToken) { var existingFeatureCodes = await dbContext.SaasFeatures .Where(value => SaasFeatureCatalog.All.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(cancellationToken); dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All .Except(existingFeatureCodes, StringComparer.Ordinal) .Select((code, index) => new SaasFeature { Code = code, Name = code, Category = code.Split('.')[0], IsCore = code == SaasFeatureCatalog.CoreBackoffice, Status = SaasFeatureStatus.Active, SortOrder = index * 10 })); var moduleCodes = BackendPermissions.Tenant .Select(PermissionModuleCatalog.ResolvePermissionModuleCode) .Distinct(StringComparer.Ordinal) .ToArray(); var existingModuleCodes = await dbContext.PermissionModules .Where(value => moduleCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(cancellationToken); dbContext.PermissionModules.AddRange(moduleCodes .Except(existingModuleCodes, StringComparer.Ordinal) .Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Tenant, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] })); var existingPermissionCodes = await dbContext.BackendPermissions .Where(value => BackendPermissions.Tenant.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(cancellationToken); dbContext.BackendPermissions.AddRange(BackendPermissions.Tenant .Except(existingPermissionCodes, StringComparer.Ordinal) .Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Tenant, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true })); var role = new TenantBackendRole { TenantId = tenantId, Code = "tenant_owner", Name = "租户所有者", Status = BackendRoleStatus.Active, IsSystem = true, Description = "系统内置租户所有者角色", DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) }; dbContext.TenantBackendRoles.Add(role); dbContext.TenantBackendRolePermissions.AddRange(BackendPermissions.Tenant.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = role.Id, PermissionCode = code })); dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = ownerUserId, RoleId = role.Id }); } private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformAdminException InvalidStatus(string status) => new($"Unsupported status '{status}'.", "invalid_status"); }