using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; internal abstract partial class PlatformAdministrationServiceBase { protected 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"); } protected static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) { return exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: { } constraintName } && constraintName.StartsWith( "ix_platform_operation_idempotencies_actor_user_id_scope_", StringComparison.Ordinal); } protected async Task ProvisioningReplayResultAsync( TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) { var tenant = await dbContext.Tenants.AsNoTracking() .SingleAsync(value => value.Id == tenantId, cancellationToken); var ownerId = tenant.OwnerUserId ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken); var primaryDomain = await dbContext.TenantDomains.AsNoTracking() .SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken); var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == tenant.Id) .OrderByDescending(value => value.CurrentPeriodEnd) .Select(value => (DateTimeOffset?)value.CurrentPeriodEnd) .FirstOrDefaultAsync(cancellationToken); return new PlatformTenantProvisioningResult( ToTenantItem(tenant, 0, subscriptionExpiresAt), ownerId, owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(), owner.ForcePasswordChange, ToDomainItemWithInstructions(primaryDomain), await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken), true); } protected static async Task OwnerActivationReplayResultAsync( TikuDbContext dbContext, Guid activationId, CancellationToken cancellationToken) { var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() .SingleAsync(value => value.Id == activationId, cancellationToken); return new PlatformOwnerActivationLinkResult(grant.Id, null, grant.ExpiresAt, true); } protected async Task OwnerActivationStatusAsync( TikuDbContext dbContext, Tenant tenant, CancellationToken cancellationToken) { if (tenant.OwnerUserId is not { } ownerId) return new PlatformOwnerActivationStatus("domain_pending", null, null); var activated = await dbContext.Users.AsNoTracking().AnyAsync(value => value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken); if (activated) return new PlatformOwnerActivationStatus("activated", null, null); var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && value.ConsumedAt == null && value.RevokedAt == null) .OrderByDescending(value => value.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (grant is not null) return new PlatformOwnerActivationStatus( grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired", grant.Id, grant.ExpiresAt); var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, cancellationToken); return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null); } protected Task ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); } protected Task ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Platform, nameof(PlatformAdministrationServiceBase), reason, Guid.NewGuid().ToString("N"), true), async (provider, _) => await operation(provider, provider.GetRequiredService()), cancellationToken); } protected 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"); } protected 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) }); } protected static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) { return new PlatformTenantItem( tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, subscriptionExpiresAt, domainCount, tenant.CreatedAt, tenant.UpdatedAt); } protected static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) { return new PlatformTenantDomainItem( domain.Id, domain.TenantId, domain.Host, domain.DomainType, domain.Status, domain.IsPrimary, domain.VerifiedAt, domain.DnsVerifiedAt, domain.TlsReadyAt, domain.LastCheckedAt, domain.LastFailureReason, null, null, null); } protected PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) { return ToDomainItem(domain) with { VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}", VerificationToken = domain.VerificationToken, CnameTarget = domains.AllowedCnameTargets.FirstOrDefault() }; } protected static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) { return new PlatformTenantSubscriptionItem( subscription.Id, subscription.TenantId, subscription.BaseOfferingVersionId, subscription.Status, subscription.StartsAt, subscription.CurrentPeriodStart, subscription.CurrentPeriodEnd, subscription.CancelAtPeriodEnd, subscription.Metadata); } protected static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) { return new TenantBillingProfileItem( 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); } protected static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) { return new TenantBillingPolicyItem( policy.TenantId, policy.CollectionMode, policy.DefaultPaymentProvider, policy.AutoGenerateRenewal, policy.RenewalLeadDays, policy.CreatedAt, policy.UpdatedAt); } protected static PlatformBillingDunningChannelItem ToDunningChannelItem( PlatformBillingDunningNotificationChannel channel) { return new PlatformBillingDunningChannelItem( 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); } protected static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) { return new PlatformBillingDunningEventItem( 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); } protected static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) { return new PlatformStaffItem( user.Id, user.Name, MaskPhone(user.Phone), user.Email, user.Status, roleCodes, user.CreatedAt, user.UpdatedAt); } protected static int Limit(int? limit) { return Math.Clamp(limit ?? 50, 1, 200); } protected static string NormalizeCode(string value) { return value.Trim().ToLowerInvariant(); } protected static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } protected static string Required(string? value, string name) { return string.IsNullOrWhiteSpace(value) ? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required") : value.Trim(); } protected static string ProvisioningRequestHash(CreatePlatformTenantCommand command) { var payload = JsonSerializer.Serialize(new { command.Slug, command.Name, command.LegalName, command.Status, command.BillingStatus, command.Metadata, command.PrimaryDomainHost, command.OwnerEmail, command.OwnerPhone, command.OwnerName, command.InitialOfferingVersionId, command.TrialDays, command.CollectionMode, command.DefaultPaymentProvider, command.AutoGenerateRenewal, command.RenewalLeadDays }); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); } protected static string OwnerActivationRequestHash(IssuePlatformOwnerActivationLinkCommand command) { var payload = JsonSerializer.Serialize(new { command.TenantId, Reason = command.Reason.Trim(), command.ReplaceExisting }); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); } protected static TenantDomain CreatePrimaryDomain(Guid tenantId, string host) { try { return TenantDomainProvisioning.CreatePrimary(tenantId, host); } catch (ArgumentException exception) { throw new PlatformAdminException(exception.Message, "tenant_domain_invalid"); } } protected static string Base64Url(byte[] value) { return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } protected string BuildOwnerActivationUrl(string host, Guid activationId, string token) { return TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); } protected static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); } protected static string? MaskPhone(string? phone) { var value = Normalize(phone); return value is { Length: >= 7 } ? $"{value[..3]}****{value[^4..]}" : value; } protected static string? MaskBankAccount(string? account) { var value = Normalize(account); return value is { Length: > 8 } ? $"****{value[^4..]}" : value; } protected 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}/****"; } protected 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; } protected static bool ParseEnabledStatus(string status) { return NormalizeCode(status) switch { "enabled" or "active" or "true" => true, "disabled" or "inactive" or "false" => false, _ => throw InvalidStatus(status) }; } protected static TenantStatus ParseTenantStatus(string status) { return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); } protected static TenantDomainStatus ParseDomainStatus(string status) { return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); } protected static async Task EnsureTenantOwnerRoleAsync( TikuDbContext dbContext, Guid tenantId, Guid ownerUserId, CancellationToken cancellationToken) { var featureCodes = SaasFeatureCatalog.All.ToArray(); var existingFeatureCodes = await dbContext.SaasFeatures .Where(value => featureCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(cancellationToken); dbContext.SaasFeatures.AddRange(featureCodes .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 tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); var existingPermissionCodes = await dbContext.BackendPermissions .Where(value => tenantPermissionCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(cancellationToken); dbContext.BackendPermissions.AddRange(tenantPermissionCodes .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(tenantPermissionCodes.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = role.Id, PermissionCode = code })); dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = ownerUserId, RoleId = role.Id }); } protected static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) { return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); } protected static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) { return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); } protected static PlatformAdminException InvalidStatus(string status) { return new PlatformAdminException($"Unsupported status '{status}'.", "invalid_status"); } }