using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Backoffice; using Tiku.Application.PlatformAdmin; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class PlatformApprovalService( TikuDbContext dbContext, IServiceScopeFactory scopeFactory, ITenantExecutionScope tenantExecutionScope, IOperationAuditService auditService, IPlatformBillingAdminService billingService, IPlatformAdminService platformAdminService, IPlatformPaymentSettingsService paymentSettingsService, IBackofficeService backofficeService) : IPlatformApprovalService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; public async Task> ListAsync( PlatformApprovalActor actor, PlatformApprovalRequestStatus? status, int limit, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalView); await ExpirePendingAsync(cancellationToken); var query = dbContext.PlatformApprovalRequests.AsNoTracking(); if (status.HasValue) query = query.Where(item => item.Status == status.Value); return await query.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(limit, 1, 500)) .Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); } public async Task GetAsync( PlatformApprovalActor actor, Guid requestId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalView); var item = await RequiredRequestAsync(requestId, cancellationToken); if (item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= DateTimeOffset.UtcNow) { item.Status = PlatformApprovalRequestStatus.Expired; item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); } return ToItem(item); } public async Task> ListPoliciesAsync( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalView); return await dbContext.PlatformApprovalPolicies.AsNoTracking() .OrderBy(item => item.Code) .Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); } public async Task UpdatePolicyAsync( PlatformApprovalActor actor, UpdatePlatformApprovalPolicyCommand command, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalPolicyManage); var policy = await dbContext.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code, cancellationToken) ?? throw Error("Approval policy was not found.", "approval_policy_not_found"); if (command.ExpiresAfterHours is < 1 or > 720 || command.AmountThresholdCents is <= 0) throw Error("Approval policy limits are invalid.", "approval_policy_invalid"); policy.Enabled = command.Enabled; policy.AlwaysRequireApproval = command.AlwaysRequireApproval; policy.AmountThresholdCents = command.AmountThresholdCents; policy.ExpiresAfterHours = command.ExpiresAfterHours; policy.Conditions = command.Conditions.Clone(); policy.Version++; await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.approval_policy.updated", "platform_approval_policies", policy.Id, new { policy.Code, policy.Version }, cancellationToken); return ToItem(policy); } public Task ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) { return DecideAsync(actor, requestId, true, reason, cancellationToken); } public Task RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) { return DecideAsync(actor, requestId, false, reason, cancellationToken); } public async Task CancelAsync( PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) { var item = await RequiredRequestAsync(requestId, cancellationToken); if (item.RequestedBy != actor.UserId) throw Error("Only the requester can cancel an approval request.", "approval_cancel_denied"); EnsurePending(item); item.Status = PlatformApprovalRequestStatus.Cancelled; item.DecisionReason = RequiredReason(reason); item.DecidedAt = DateTimeOffset.UtcNow; item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.approval.cancelled", "platform_approval_requests", item.Id, new { item.RequestNo }, cancellationToken); return ToItem(item); } public Task SubmitRefundAsync( SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default) { return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment, nameof(RequestPlatformRefundCommand), "platform_billing_payments", command.PaymentId.ToString("N"), command.AmountCents, command.IdempotencyKey, command.Reason, command, async token => await billingService.RequestRefundAsync(actor, command, token), cancellationToken); } public async Task ConfirmManualPaymentAsync( SaasCatalogActor actor, ConfirmManualPaymentCommand command, string idempotencyKey, CancellationToken cancellationToken = default) { var amount = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Platform, nameof(PlatformApprovalService), "Resolve payment amount for platform approval", command.PaymentId.ToString("N"), true), async (services, token) => await services.GetRequiredService() .PlatformBillingPayments.AsNoTracking() .Where(item => item.Id == command.PaymentId) .Select(item => (int?)item.AmountCents) .SingleOrDefaultAsync(token), cancellationToken) ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment, nameof(ConfirmManualPaymentCommand), "platform_billing_payments", command.PaymentId.ToString("N"), amount, idempotencyKey, command.Reason, command, async token => await billingService.ConfirmManualPaymentAsync(actor, command, token), cancellationToken); } public Task UpdateTenantStatusAsync( PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, string idempotencyKey, CancellationToken cancellationToken = default) { if (command.Status != TenantStatus.Archived) return ExecuteImmediateAsync(() => platformAdminService.UpdateTenantStatusAsync(actor, command, cancellationToken)); return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.TenantArchive, nameof(UpdatePlatformTenantStatusCommand), "tenants", command.TenantId.ToString("N"), null, idempotencyKey, command.Reason, command, async token => await platformAdminService.UpdateTenantStatusAsync(actor, command, token), cancellationToken); } public Task UpsertPaymentChannelAsync( PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, string idempotencyKey, CancellationToken cancellationToken = default) { return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.PaymentChannelChange, nameof(UpsertPlatformPaymentChannelCommand), "platform_payment_channels", command.Id?.ToString("N") ?? command.Provider, null, idempotencyKey, "支付渠道或密钥引用变更", command, async token => await paymentSettingsService.UpsertChannelAsync(actor, command, token), cancellationToken); } public async Task ReplaceRoleBindingsAsync( BackofficeActor actor, ReplaceRoleBindingsCommand command, string idempotencyKey, CancellationToken cancellationToken = default) { var isSuperAdmin = await dbContext.PlatformBackendRoles.AsNoTracking() .AnyAsync(role => role.Id == command.RoleId && role.Code == "platform_super_admin", cancellationToken); if (!isSuperAdmin) return await ExecuteImmediateAsync(() => backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, cancellationToken)); return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.SuperAdminGrant, nameof(ReplaceRoleBindingsCommand), "platform_backend_roles", command.RoleId.ToString("N"), null, idempotencyKey, "平台超级管理员权限变更", command, async token => await backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, token), cancellationToken); } public async Task ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default) { var requestIds = await dbContext.PlatformApprovalRequests.AsNoTracking() .Where(item => item.Status == PlatformApprovalRequestStatus.Approved) .OrderBy(item => item.DecidedAt) .Select(item => item.Id) .Take(Math.Clamp(batchSize, 1, 100)) .ToArrayAsync(cancellationToken); var processed = 0; foreach (var requestId in requestIds) { var claimed = await dbContext.PlatformApprovalRequests .Where(item => item.Id == requestId && item.Status == PlatformApprovalRequestStatus.Approved) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, PlatformApprovalRequestStatus.Executing) .SetProperty(item => item.ConcurrencyStamp, Guid.NewGuid()) .SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken); if (claimed == 0) continue; dbContext.ChangeTracker.Clear(); var item = await RequiredRequestAsync(requestId, cancellationToken); try { item.ResultSnapshot = await ExecuteApprovedAsync(item, cancellationToken); item.Status = PlatformApprovalRequestStatus.Succeeded; item.ExecutedAt = DateTimeOffset.UtcNow; item.Error = null; } catch (Exception exception) when (exception is not OperationCanceledException) { item.Status = PlatformApprovalRequestStatus.Failed; item.Error = exception.Message.Length > 4000 ? exception.Message[..4000] : exception.Message; } item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(item.DecidedBy ?? item.RequestedBy, item.Status == PlatformApprovalRequestStatus.Succeeded ? "platform.approval.executed" : "platform.approval.execution_failed", "platform_approval_requests", item.Id, new { item.RequestNo, item.CommandType, item.Error }, cancellationToken); processed++; } return processed; } private async Task SubmitAsync( Guid actorUserId, string policyCode, string commandType, string targetType, string targetId, int? amountCents, string idempotencyKey, string? reason, TCommand command, Func> execute, CancellationToken cancellationToken) { idempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey) ? throw Error("Idempotency-Key is required.", "idempotency_key_required") : idempotencyKey.Trim(); var policy = await dbContext.PlatformApprovalPolicies.AsNoTracking() .SingleOrDefaultAsync(item => item.Code == policyCode, cancellationToken) ?? throw Error("Approval policy is not configured.", "approval_policy_not_configured"); var requiresApproval = PlatformApprovalRules.RequiresApproval( policy.Enabled, policy.AlwaysRequireApproval, policy.AmountThresholdCents, amountCents); if (!requiresApproval) return await ExecuteImmediateAsync(() => execute(cancellationToken)); var snapshot = RedactedSnapshot(command); var requestHash = Hash(snapshot.GetRawText()); var existing = await dbContext.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item => item.RequestedBy == actorUserId && item.CommandType == commandType && item.IdempotencyKey == idempotencyKey, cancellationToken); if (existing is not null) { if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal)) throw Error("Idempotency key was used with a different approval request.", "idempotency_conflict"); return new PlatformCommandSubmission( existing.Status == PlatformApprovalRequestStatus.Succeeded ? "executed" : "pending_approval", existing.ResultSnapshot, ToItem(existing)); } var item = new PlatformApprovalRequest { RequestNo = $"PA{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..24], PolicyCode = policy.Code, PolicyVersion = policy.Version, RequestedBy = actorUserId, RequiredPermission = policy.RequiredPermission, CommandType = commandType, TargetType = targetType, TargetId = targetId, AmountCents = amountCents, IdempotencyKey = idempotencyKey, RequestHash = requestHash, RequestSnapshot = snapshot, RequestReason = reason?.Trim(), ExpiresAt = DateTimeOffset.UtcNow.AddHours(policy.ExpiresAfterHours) }; dbContext.PlatformApprovalRequests.Add(item); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actorUserId, "platform.approval.requested", "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode, item.CommandType, item.TargetType, item.TargetId, item.AmountCents }, cancellationToken); return new PlatformCommandSubmission("pending_approval", null, ToItem(item)); } private async Task DecideAsync( PlatformApprovalActor actor, Guid requestId, bool approve, string reason, CancellationToken cancellationToken) { Require(actor, BackendPermissions.PlatformApprovalDecide); var item = await RequiredRequestAsync(requestId, cancellationToken); var denial = PlatformApprovalRules.DecisionDenialCode(item.Status, item.RequestedBy, actor.UserId, item.ExpiresAt, item.RequiredPermission, actor.Permissions, DateTimeOffset.UtcNow); if (denial == "approval_request_expired") { item.Status = PlatformApprovalRequestStatus.Expired; item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); throw Error("Approval request has expired.", "approval_request_expired"); } if (denial == "approval_request_not_pending") throw Error("Only a pending approval request can be changed.", denial); if (denial == "approval_maker_checker_required") throw Error("Requester cannot approve or reject the same request.", "approval_maker_checker_required"); if (denial == "approval_business_permission_required") throw Error("Approver no longer has the required business permission.", "approval_business_permission_required"); item.DecidedBy = actor.UserId; item.DecidedAt = DateTimeOffset.UtcNow; item.DecisionReason = RequiredReason(reason); item.Status = approve ? PlatformApprovalRequestStatus.Approved : PlatformApprovalRequestStatus.Rejected; item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, approve ? "platform.approval.approved" : "platform.approval.rejected", "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode }, cancellationToken); return ToItem(item); } private async Task ExecuteApprovedAsync(PlatformApprovalRequest item, CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); scope.ServiceProvider.GetRequiredService().Load(new ClaimsPrincipal( new ClaimsIdentity( [new Claim(TikuClaimTypes.UserId, item.RequestedBy.ToString())], "platform-approval"))); object result = item.CommandType switch { nameof(RequestPlatformRefundCommand) => await scope.ServiceProvider .GetRequiredService() .RequestRefundAsync(new SaasCatalogActor(item.RequestedBy), Deserialize(item), cancellationToken), nameof(ConfirmManualPaymentCommand) => await scope.ServiceProvider .GetRequiredService() .ConfirmManualPaymentAsync(new SaasCatalogActor(item.RequestedBy), Deserialize(item), cancellationToken), nameof(UpdatePlatformTenantStatusCommand) => await scope.ServiceProvider .GetRequiredService() .UpdateTenantStatusAsync(new PlatformAdminActor(item.RequestedBy), Deserialize(item), cancellationToken), nameof(UpsertPlatformPaymentChannelCommand) => await scope.ServiceProvider .GetRequiredService() .UpsertChannelAsync(new PlatformCapabilityActor(item.RequestedBy), Deserialize(item), cancellationToken), nameof(ReplaceRoleBindingsCommand) => await scope.ServiceProvider.GetRequiredService() .ReplacePlatformRoleBindingsAsync(new BackofficeActor(item.RequestedBy, null, true), Deserialize(item), cancellationToken), _ => throw Error("Approval command type is not supported.", "approval_command_not_supported") }; return JsonSerializer.SerializeToElement(result, JsonOptions); } private static async Task ExecuteImmediateAsync(Func> execute) { var result = await execute(); return new PlatformCommandSubmission("executed", JsonSerializer.SerializeToElement(result, JsonOptions), null); } private static T Deserialize(PlatformApprovalRequest item) { return JsonSerializer.Deserialize(item.RequestSnapshot.GetRawText(), JsonOptions) ?? throw Error("Approval request snapshot is invalid.", "approval_snapshot_invalid"); } private async Task RequiredRequestAsync(Guid requestId, CancellationToken cancellationToken) { return await dbContext.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId, cancellationToken) ?? throw Error("Approval request was not found.", "approval_request_not_found"); } private async Task ExpirePendingAsync(CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; await dbContext.PlatformApprovalRequests .Where(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, PlatformApprovalRequestStatus.Expired) .SetProperty(item => item.ConcurrencyStamp, Guid.NewGuid()) .SetProperty(item => item.UpdatedAt, now), cancellationToken); } private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) { return auditService.WriteAsync( new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), JsonSerializer.SerializeToElement(details, JsonOptions)), cancellationToken); } private static JsonElement RedactedSnapshot(T command) { var node = JsonSerializer.SerializeToNode(command, JsonOptions) ?? new JsonObject(); Redact(node); return JsonSerializer.SerializeToElement(node, JsonOptions); } private static void Redact(JsonNode? node) { if (node is JsonObject value) foreach (var property in value.ToArray()) { var name = property.Key; if ((name.Contains("password", StringComparison.OrdinalIgnoreCase) || name.Contains("token", StringComparison.OrdinalIgnoreCase) || name.Equals("secret", StringComparison.OrdinalIgnoreCase)) && !name.EndsWith("Ref", StringComparison.OrdinalIgnoreCase)) value[name] = "***"; else Redact(property.Value); } else if (node is JsonArray array) foreach (var item in array) Redact(item); } private static string Hash(string value) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } private static string RequiredReason(string reason) { return string.IsNullOrWhiteSpace(reason) ? throw Error("Decision reason is required.", "approval_reason_required") : reason.Trim(); } private static void EnsurePending(PlatformApprovalRequest item) { if (item.Status != PlatformApprovalRequestStatus.Pending) throw Error("Only a pending approval request can be changed.", "approval_request_not_pending"); } private static void Require(PlatformApprovalActor actor, string permission) { if (!actor.Permissions.Contains(permission)) throw Error("Platform approval access is denied.", "platform_access_denied"); } private static PlatformApprovalException Error(string message, string code) { return new PlatformApprovalException(message, code); } private static PlatformApprovalRequestItem ToItem(PlatformApprovalRequest item) { return new PlatformApprovalRequestItem(item.Id, item.RequestNo, item.PolicyCode, item.PolicyVersion, item.Status, item.RequestedBy, item.DecidedBy, item.RequiredPermission, item.CommandType, item.TargetType, item.TargetId, item.AmountCents, item.RequestSnapshot, item.RequestReason, item.DecisionReason, item.Error, item.ExpiresAt, item.CreatedAt, item.UpdatedAt); } private static PlatformApprovalPolicyItem ToItem(PlatformApprovalPolicy item) { return new PlatformApprovalPolicyItem(item.Id, item.Code, item.Name, item.RequiredPermission, item.Enabled, item.AlwaysRequireApproval, item.AmountThresholdCents, item.Version, item.ExpiresAfterHours, item.Conditions); } }