Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs
xiong 290a0c7bd7
Some checks failed
ci / release-gate (push) Has been cancelled
feat(platform): harden governance and approval workflows
2026-08-03 11:43:18 +08:00

428 lines
24 KiB
C#

using System.Security.Cryptography;
using System.Security.Claims;
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<IReadOnlyCollection<PlatformApprovalRequestItem>> 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<PlatformApprovalRequestItem> 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<IReadOnlyCollection<PlatformApprovalPolicyItem>> 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<PlatformApprovalPolicyItem> 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<PlatformApprovalRequestItem> ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) =>
DecideAsync(actor, requestId, true, reason, cancellationToken);
public Task<PlatformApprovalRequestItem> RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) =>
DecideAsync(actor, requestId, false, reason, cancellationToken);
public async Task<PlatformApprovalRequestItem> 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<PlatformCommandSubmission> SubmitRefundAsync(
SaasCatalogActor actor,
RequestPlatformRefundCommand command,
CancellationToken cancellationToken = default) =>
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<PlatformCommandSubmission> 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"),
IsGlobal: true),
async (services, token) => await services.GetRequiredService<TikuDbContext>()
.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<PlatformCommandSubmission> 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<PlatformCommandSubmission> UpsertPaymentChannelAsync(
PlatformCapabilityActor actor,
UpsertPlatformPaymentChannelCommand command,
string idempotencyKey,
CancellationToken cancellationToken = default) =>
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<PlatformCommandSubmission> 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);
}
private async Task<PlatformCommandSubmission> SubmitAsync<TCommand, TResult>(
Guid actorUserId,
string policyCode,
string commandType,
string targetType,
string targetId,
int? amountCents,
string idempotencyKey,
string? reason,
TCommand command,
Func<CancellationToken, Task<TResult>> 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<PlatformApprovalRequestItem> 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);
}
public async Task<int> 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<JsonElement> ExecuteApprovedAsync(PlatformApprovalRequest item, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<ICurrentUser>().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<IPlatformBillingAdminService>()
.RequestRefundAsync(new SaasCatalogActor(item.RequestedBy), Deserialize<RequestPlatformRefundCommand>(item), cancellationToken),
nameof(ConfirmManualPaymentCommand) => await scope.ServiceProvider.GetRequiredService<IPlatformBillingAdminService>()
.ConfirmManualPaymentAsync(new SaasCatalogActor(item.RequestedBy), Deserialize<ConfirmManualPaymentCommand>(item), cancellationToken),
nameof(UpdatePlatformTenantStatusCommand) => await scope.ServiceProvider.GetRequiredService<IPlatformAdminService>()
.UpdateTenantStatusAsync(new PlatformAdminActor(item.RequestedBy), Deserialize<UpdatePlatformTenantStatusCommand>(item), cancellationToken),
nameof(UpsertPlatformPaymentChannelCommand) => await scope.ServiceProvider.GetRequiredService<IPlatformPaymentSettingsService>()
.UpsertChannelAsync(new PlatformCapabilityActor(item.RequestedBy), Deserialize<UpsertPlatformPaymentChannelCommand>(item), cancellationToken),
nameof(ReplaceRoleBindingsCommand) => await scope.ServiceProvider.GetRequiredService<IBackofficeService>()
.ReplacePlatformRoleBindingsAsync(new BackofficeActor(item.RequestedBy, null, true), Deserialize<ReplaceRoleBindingsCommand>(item), cancellationToken),
_ => throw Error("Approval command type is not supported.", "approval_command_not_supported")
};
return JsonSerializer.SerializeToElement(result, JsonOptions);
}
private static async Task<PlatformCommandSubmission> ExecuteImmediateAsync<TResult>(Func<Task<TResult>> execute)
{
var result = await execute();
return new PlatformCommandSubmission("executed", JsonSerializer.SerializeToElement(result, JsonOptions), null);
}
private static T Deserialize<T>(PlatformApprovalRequest item) =>
JsonSerializer.Deserialize<T>(item.RequestSnapshot.GetRawText(), JsonOptions)
?? throw Error("Approval request snapshot is invalid.", "approval_snapshot_invalid");
private async Task<PlatformApprovalRequest> RequiredRequestAsync(Guid requestId, CancellationToken cancellationToken) =>
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) =>
auditService.WriteAsync(new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), JsonSerializer.SerializeToElement(details, JsonOptions)), cancellationToken);
private static JsonElement RedactedSnapshot<T>(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) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string RequiredReason(string reason) => 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) => new(message, code);
private static PlatformApprovalRequestItem ToItem(PlatformApprovalRequest item) => new(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) => new(item.Id, item.Code, item.Name, item.RequiredPermission,
item.Enabled, item.AlwaysRequireApproval, item.AmountThresholdCents, item.Version, item.ExpiresAfterHours, item.Conditions);
}