feat(platform): harden governance and approval workflows
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-03 11:43:07 +08:00
parent fe594c9ef5
commit 290a0c7bd7
65 changed files with 48479 additions and 229 deletions

View File

@@ -17,6 +17,7 @@ internal static class ApiPresentationExtensions
services.AddOpenApi(options =>
{
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
options.AddOperationTransformer<PlatformOperationMetadataTransformer>();
});
services.AddProblemDetails();

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.PlatformAdmin;
using Tiku.Domain.Common;
namespace Tiku.Api.Contracts;
public sealed record PlatformApprovalDecisionDto([Required, MaxLength(1000)] string Reason);
public sealed record UpdatePlatformApprovalPolicyDto(
bool Enabled,
bool AlwaysRequireApproval,
[Range(1, int.MaxValue)] int? AmountThresholdCents,
[Range(1, 720)] int ExpiresAfterHours,
JsonElement? Conditions)
{
public UpdatePlatformApprovalPolicyCommand ToCommand(string code) => new(
code,
Enabled,
AlwaysRequireApproval,
AmountThresholdCents,
ExpiresAfterHours,
Conditions?.Clone() ?? JsonDefaults.Object());
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.PlatformAdmin;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
namespace Tiku.Api.Contracts;
public sealed record SavePlatformConfigurationDraftDto(
[Required, MaxLength(120)] string DefinitionCode,
[Required, MaxLength(80)] string Environment,
JsonElement? Value,
[MaxLength(300)] string? SecretRef,
[Required, MaxLength(1000)] string Reason)
{
public SavePlatformConfigurationDraftCommand ToCommand() => new(DefinitionCode, Environment, Value, SecretRef, Reason);
}
public sealed record PlatformRollbackDto([Required, MaxLength(1000)] string Reason);
public sealed record UpsertPlatformNotificationTemplateDto(Guid? Id, [Required, MaxLength(120)] string Code,
[Required, MaxLength(200)] string Name, PlatformNotificationChannel Channel,
[Required, MaxLength(500)] string SubjectTemplate, [Required, MaxLength(8000)] string BodyTemplate,
bool Enabled, JsonElement? Variables)
{
public UpsertPlatformNotificationTemplateCommand ToCommand() => new(Id, Code, Name, Channel, SubjectTemplate, BodyTemplate, Enabled, Variables?.Clone() ?? JsonDefaults.Array());
}
public sealed record SendPlatformNotificationDto(Guid TemplateId, [MinLength(1)] IReadOnlyCollection<string> RoleCodes,
IReadOnlyDictionary<string, string>? Variables,
[Required, MaxLength(200)] string IdempotencyKey)
{
public SendPlatformNotificationCommand ToCommand() => new(TemplateId, RoleCodes, Variables ?? new Dictionary<string, string>(), IdempotencyKey);
}

View File

@@ -6,6 +6,7 @@ using Tiku.Application.PlatformAdmin;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Api.OpenApi;
namespace Tiku.Api.Controllers;
@@ -16,6 +17,7 @@ namespace Tiku.Api.Controllers;
[Route("api/platform-admin")]
public sealed class PlatformAdminController(
IPlatformAdminService platformAdminService,
IPlatformApprovalService approvalService,
IAuthAdministrationService authAdministrationService,
ICurrentUser currentUser) : ControllerBase
{
@@ -103,12 +105,16 @@ public sealed class PlatformAdminController(
[HttpPatch("tenants/status")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
[EndpointSummary("更新租户业务状态")]
[ProducesResponseType<PlatformTenantItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<PlatformTenantItem>> TenantStatus(
[PlatformOperationRisk("critical", PlatformApprovalPolicyCodes.TenantArchive)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<PlatformCommandSubmission>> TenantStatus(
UpdatePlatformTenantStatusDto request,
[FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
CancellationToken cancellationToken)
{
return Ok(await platformAdminService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
var result = await approvalService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), idempotencyKey, cancellationToken);
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
}
[HttpPut("tenants/billing-profile")]

View File

@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Api.OpenApi;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("平台端-审批中心")]
[Route("api/platform-admin/approvals")]
[Authorize(Policy = BackendPermissions.PlatformApprovalView)]
public sealed class PlatformApprovalsController(
IPlatformApprovalService approvalService,
ICurrentAccessContext currentAccessContext) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询平台审批任务")]
public async Task<IReadOnlyCollection<PlatformApprovalRequestItem>> Requests(
PlatformApprovalRequestStatus? status,
int limit = 100,
CancellationToken cancellationToken = default) =>
await approvalService.ListAsync(await ActorAsync(cancellationToken), status, limit, cancellationToken);
[HttpGet("{requestId:guid}")]
[EndpointSummary("查询平台审批详情")]
public async Task<PlatformApprovalRequestItem> Details(Guid requestId, CancellationToken cancellationToken) =>
await approvalService.GetAsync(await ActorAsync(cancellationToken), requestId, cancellationToken);
[HttpGet("policies")]
[EndpointSummary("查询平台审批策略")]
public async Task<IReadOnlyCollection<PlatformApprovalPolicyItem>> Policies(CancellationToken cancellationToken) =>
await approvalService.ListPoliciesAsync(await ActorAsync(cancellationToken), cancellationToken);
[HttpPut("policies/{code}")]
[Authorize(Policy = BackendPermissions.PlatformApprovalPolicyManage)]
[EndpointSummary("更新平台审批策略")]
[PlatformOperationRisk("high", "approval.policy-change")]
public async Task<PlatformApprovalPolicyItem> UpdatePolicy(
string code,
UpdatePlatformApprovalPolicyDto request,
CancellationToken cancellationToken) =>
await approvalService.UpdatePolicyAsync(await ActorAsync(cancellationToken), request.ToCommand(code), cancellationToken);
[HttpPost("{requestId:guid}/approve")]
[Authorize(Policy = BackendPermissions.PlatformApprovalDecide)]
[EndpointSummary("批准并执行平台审批任务")]
[PlatformOperationRisk("high")]
public async Task<PlatformApprovalRequestItem> Approve(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) =>
await approvalService.ApproveAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken);
[HttpPost("{requestId:guid}/reject")]
[Authorize(Policy = BackendPermissions.PlatformApprovalDecide)]
[EndpointSummary("拒绝平台审批任务")]
[PlatformOperationRisk("high")]
public async Task<PlatformApprovalRequestItem> Reject(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) =>
await approvalService.RejectAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken);
[HttpPost("{requestId:guid}/cancel")]
[EndpointSummary("撤销本人提交的平台审批任务")]
public async Task<PlatformApprovalRequestItem> Cancel(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) =>
await approvalService.CancelAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken);
private async Task<PlatformApprovalActor> ActorAsync(CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
return access.UserId is { } userId && access.IsUserActive
? new PlatformApprovalActor(userId, access.PlatformPermissions)
: throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied");
}
}

View File

@@ -3,6 +3,9 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Backoffice;
using Tiku.Application.Security;
using System.ComponentModel.DataAnnotations;
using Tiku.Api.OpenApi;
using Tiku.Application.PlatformAdmin;
namespace Tiku.Api.Controllers;
@@ -11,6 +14,7 @@ namespace Tiku.Api.Controllers;
[Route("api/backoffice/platform")]
public sealed class PlatformBackofficeController(
IBackofficeService backofficeService,
IPlatformApprovalService approvalService,
ICurrentAccessContext currentAccessContext) : ControllerBase
{
[HttpGet("ui-bootstrap")]
@@ -48,13 +52,17 @@ public sealed class PlatformBackofficeController(
[HttpPut("roles/{roleId:guid}/bindings")]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[EndpointSummary("替换平台后台角色权限绑定")]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplaceRoleBindings(
[PlatformOperationRisk("critical", PlatformApprovalPolicyCodes.SuperAdminGrant)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<PlatformCommandSubmission>> ReplaceRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
[FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
var result = await approvalService.ReplaceRoleBindingsAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(roleId), idempotencyKey, cancellationToken);
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
}
[HttpPut("users/{userId:guid}/roles")]

View File

@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Api.OpenApi;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("平台端-治理配置")]
[Route("api/platform-admin/governance")]
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
public sealed class PlatformGovernanceController(
IPlatformGovernanceService governanceService,
ICurrentAccessContext currentAccessContext) : ControllerBase
{
[HttpGet("configuration/definitions")]
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
[EndpointSummary("查询类型化平台配置定义")]
public async Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> ConfigurationDefinitions(CancellationToken cancellationToken) =>
await governanceService.GetConfigurationDefinitionsAsync(await ActorAsync(cancellationToken), cancellationToken);
[HttpGet("configuration/versions")]
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
[EndpointSummary("查询平台配置版本")]
public async Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> ConfigurationVersions(string definitionCode, string? environment, CancellationToken cancellationToken) =>
await governanceService.GetConfigurationVersionsAsync(await ActorAsync(cancellationToken), definitionCode, environment, cancellationToken);
[HttpPost("configuration/drafts")]
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
[EndpointSummary("保存平台配置草稿")]
public async Task<PlatformConfigurationVersionItem> SaveConfigurationDraft(SavePlatformConfigurationDraftDto request, CancellationToken cancellationToken) =>
await governanceService.SaveConfigurationDraftAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
[HttpPost("configuration/versions/{versionId:guid}/publish")]
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
[EndpointSummary("发布平台配置版本")]
[PlatformOperationRisk("high")]
public async Task<PlatformConfigurationVersionItem> PublishConfiguration(Guid versionId, CancellationToken cancellationToken) =>
await governanceService.PublishConfigurationAsync(await ActorAsync(cancellationToken), versionId, cancellationToken);
[HttpPost("configuration/versions/{versionId:guid}/rollback")]
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
[EndpointSummary("回滚平台配置版本")]
[PlatformOperationRisk("high")]
public async Task<PlatformConfigurationVersionItem> RollbackConfiguration(Guid versionId, PlatformRollbackDto request, CancellationToken cancellationToken) =>
await governanceService.RollbackConfigurationAsync(await ActorAsync(cancellationToken), versionId, request.Reason, cancellationToken);
[HttpGet("notifications/templates")]
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
[EndpointSummary("查询平台通知模板")]
public async Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> NotificationTemplates(CancellationToken cancellationToken) =>
await governanceService.GetNotificationTemplatesAsync(await ActorAsync(cancellationToken), cancellationToken);
[HttpPut("notifications/templates")]
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
[EndpointSummary("新增或更新平台通知模板")]
public async Task<PlatformNotificationTemplateItem> UpsertNotificationTemplate(UpsertPlatformNotificationTemplateDto request, CancellationToken cancellationToken) =>
await governanceService.UpsertNotificationTemplateAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
[HttpPost("notifications/send")]
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
[EndpointSummary("按平台岗位发送通知")]
public async Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotification(SendPlatformNotificationDto request, CancellationToken cancellationToken) =>
await governanceService.SendNotificationAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
[HttpGet("notifications/deliveries")]
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
[EndpointSummary("分页查询平台通知投递")]
public async Task<PagedResult<PlatformNotificationDeliveryItem>> NotificationDeliveries(
int page = 1, int pageSize = 50, string? search = null, PlatformNotificationDeliveryStatus? status = null, CancellationToken cancellationToken = default) =>
await governanceService.GetNotificationDeliveriesAsync(await ActorAsync(cancellationToken), new PagedQuery(page, pageSize, search), status, cancellationToken);
[HttpPost("notifications/deliveries/{deliveryId:guid}/retry")]
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
[EndpointSummary("重试平台通知投递")]
public async Task<PlatformNotificationDeliveryItem> RetryNotification(Guid deliveryId, CancellationToken cancellationToken) =>
await governanceService.RetryNotificationAsync(await ActorAsync(cancellationToken), deliveryId, cancellationToken);
private async Task<PlatformApprovalActor> ActorAsync(CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
return access.UserId is { } userId && access.IsUserActive
? new PlatformApprovalActor(userId, access.PlatformPermissions)
: throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied");
}
}

View File

@@ -4,6 +4,7 @@ using Tiku.Api.Contracts;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Application.Assets;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Persistence;
@@ -119,6 +120,26 @@ public sealed class PlatformOperationsController(
});
}
[HttpGet("governance-metrics")]
[EndpointSummary("查询审批、配置与通知治理指标")]
public async Task<ActionResult<object>> GovernanceMetrics(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var approvalCounts = await dbContext.PlatformApprovalRequests.AsNoTracking()
.GroupBy(item => item.Status)
.Select(group => new { status = group.Key, count = group.Count() })
.ToArrayAsync(cancellationToken);
var expiredPending = await dbContext.PlatformApprovalRequests.AsNoTracking()
.CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken);
var configurationDrafts = await dbContext.PlatformConfigurationVersions.AsNoTracking()
.CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken);
var notificationCounts = await dbContext.PlatformNotificationDeliveries.AsNoTracking()
.GroupBy(item => item.Status)
.Select(group => new { status = group.Key, count = group.Count() })
.ToArrayAsync(cancellationToken);
return Ok(new { approvals = approvalCounts, expiredPending, configurationDrafts, notifications = notificationCounts, checkedAt = now });
}
[HttpGet("jobs")]
[EndpointSummary("查询全平台后台任务")]
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(

View File

@@ -4,6 +4,8 @@ using Tiku.Api.Contracts;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using System.ComponentModel.DataAnnotations;
using Tiku.Api.OpenApi;
namespace Tiku.Api.Controllers;
@@ -14,6 +16,7 @@ namespace Tiku.Api.Controllers;
[Route("api/platform-admin/payment-settings")]
public sealed class PlatformPaymentSettingsController(
IPlatformPaymentSettingsService paymentSettingsService,
IPlatformApprovalService approvalService,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet("apps")]
@@ -37,8 +40,17 @@ public sealed class PlatformPaymentSettingsController(
[HttpPut("channels")]
[EndpointSummary("新增或更新平台支付渠道")]
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
public Task<PlatformPaymentChannel> UpsertChannel(UpsertPlatformPaymentChannelDto request, CancellationToken cancellationToken) =>
paymentSettingsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken);
[PlatformOperationRisk("critical", PlatformApprovalPolicyCodes.PaymentChannelChange)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<PlatformCommandSubmission>> UpsertChannel(
UpsertPlatformPaymentChannelDto request,
[FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
CancellationToken cancellationToken)
{
var result = await approvalService.UpsertPaymentChannelAsync(Actor(), request.ToCommand(), idempotencyKey, cancellationToken);
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
}
[HttpPost("channels/{id:guid}/disable")]
[EndpointSummary("禁用平台支付渠道")]

View File

@@ -1,9 +1,12 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.PlatformBilling;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Api.OpenApi;
namespace Tiku.Api.Controllers;
@@ -14,6 +17,7 @@ namespace Tiku.Api.Controllers;
public sealed class PlatformSaasController(
ISaasCatalogAdminService catalogService,
IPlatformBillingAdminService billingService,
IPlatformApprovalService approvalService,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet("catalog")]
@@ -111,8 +115,14 @@ public sealed class PlatformSaasController(
[HttpPost("payments/manual/confirm")]
[EndpointSummary("确认平台手工支付")]
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
public Task<PlatformBillingPayment> ConfirmManualPayment(ConfirmManualPlatformPaymentDto request, CancellationToken cancellationToken) =>
billingService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), cancellationToken);
[PlatformOperationRisk("high", PlatformApprovalPolicyCodes.FinancialAdjustment)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<PlatformCommandSubmission>> ConfirmManualPayment(
ConfirmManualPlatformPaymentDto request,
[FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
CancellationToken cancellationToken) =>
CommandResult(await approvalService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), idempotencyKey, cancellationToken));
[HttpPut("tenant-feature-overrides")]
[EndpointSummary("新增或更新租户功能覆盖规则")]
@@ -159,8 +169,11 @@ public sealed class PlatformSaasController(
[HttpPost("refunds")]
[EndpointSummary("申请平台 SaaS 退款")]
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
public Task<PlatformBillingRefund> RequestRefund(RequestPlatformRefundDto request, CancellationToken cancellationToken) =>
billingService.RequestRefundAsync(Actor(), request.ToCommand(), cancellationToken);
[PlatformOperationRisk("high", PlatformApprovalPolicyCodes.FinancialAdjustment)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<PlatformCommandSubmission>> RequestRefund(RequestPlatformRefundDto request, CancellationToken cancellationToken) =>
CommandResult(await approvalService.SubmitRefundAsync(Actor(), request.ToCommand(), cancellationToken));
[HttpPost("refunds/{refundId:guid}/approve")]
[EndpointSummary("批准平台 SaaS 退款")]
@@ -183,4 +196,7 @@ public sealed class PlatformSaasController(
private SaasCatalogActor Actor() => currentUser.UserId is { } userId
? new SaasCatalogActor(userId)
: throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied");
private ActionResult<PlatformCommandSubmission> CommandResult(PlatformCommandSubmission result) =>
result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
}

View File

@@ -36,6 +36,18 @@ public sealed class ExceptionHandlingMiddleware(
}
catch (Exception exception)
{
if (exception is PlatformApprovalException approvalException)
{
var status = approvalException.Code switch
{
"approval_request_not_found" or "approval_policy_not_found" => StatusCodes.Status404NotFound,
"platform_access_denied" or "approval_business_permission_required" or "approval_cancel_denied" => StatusCodes.Status403Forbidden,
"approval_request_not_pending" or "approval_request_expired" or "approval_maker_checker_required" or "idempotency_conflict" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
await WriteProblemAsync(context, approvalException.Message, status, approvalException.Code);
return;
}
if (exception is AuthorizationSecurityUnavailableException)
{
await WriteProblemAsync(

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Tiku.Api.Security;
namespace Tiku.Api.OpenApi;
[AttributeUsage(AttributeTargets.Method)]
public sealed class PlatformOperationRiskAttribute(
string riskLevel,
string? approvalPolicyCode = null) : Attribute
{
public string RiskLevel { get; } = riskLevel;
public string? ApprovalPolicyCode { get; } = approvalPolicyCode;
}
internal sealed class PlatformOperationMetadataTransformer : IOpenApiOperationTransformer
{
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var authorization = context.Description.ActionDescriptor.EndpointMetadata
.OfType<EndpointAuthorizationMetadata>()
.LastOrDefault();
if (authorization?.Realm != "platform")
{
return Task.CompletedTask;
}
if (authorization.Permission is not null)
{
operation.AddExtension("x-tiku-required-permission",
new JsonNodeExtension(JsonValue.Create(authorization.Permission)));
}
var declaredRisk = context.Description.ActionDescriptor.EndpointMetadata
.OfType<PlatformOperationRiskAttribute>()
.LastOrDefault();
var riskLevel = declaredRisk?.RiskLevel ??
(authorization.Operation == Tiku.Application.Security.CapabilityOperation.Read ? "low" : "medium");
operation.AddExtension("x-tiku-risk-level", new JsonNodeExtension(JsonValue.Create(riskLevel)));
if (declaredRisk?.ApprovalPolicyCode is not null)
{
operation.AddExtension("x-tiku-approval-policy-code",
new JsonNodeExtension(JsonValue.Create(declaredRisk.ApprovalPolicyCode)));
}
return Task.CompletedTask;
}
}

View File

@@ -35,7 +35,7 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Cast<string>()
.ToArray();
var permission = policies.FirstOrDefault(policy =>
var permission = policies.LastOrDefault(policy =>
BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy));
var realm = permission is not null && BackendPermissions.Platform.Contains(permission) ||
policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal))

View File

@@ -0,0 +1,105 @@
using System.Text.Json;
using Tiku.Application.Backoffice;
using Tiku.Application.PlatformBilling;
using Tiku.Domain.Platform;
namespace Tiku.Application.PlatformAdmin;
public static class PlatformApprovalPolicyCodes
{
public const string TenantArchive = "tenant.archive";
public const string SuperAdminGrant = "security.super-admin-grant";
public const string PaymentChannelChange = "payment.channel-change";
public const string FinancialAdjustment = "finance.adjustment";
}
public sealed record PlatformApprovalActor(Guid UserId, IReadOnlySet<string> Permissions);
public sealed record PlatformCommandSubmission(
string ExecutionStatus,
JsonElement? Result,
PlatformApprovalRequestItem? ApprovalRequest);
public sealed record PlatformApprovalRequestItem(
Guid Id,
string RequestNo,
string PolicyCode,
int PolicyVersion,
PlatformApprovalRequestStatus Status,
Guid RequestedBy,
Guid? DecidedBy,
string RequiredPermission,
string CommandType,
string TargetType,
string TargetId,
int? AmountCents,
JsonElement RequestSnapshot,
string? RequestReason,
string? DecisionReason,
string? Error,
DateTimeOffset ExpiresAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record PlatformApprovalPolicyItem(
Guid Id,
string Code,
string Name,
string RequiredPermission,
bool Enabled,
bool AlwaysRequireApproval,
int? AmountThresholdCents,
int Version,
int ExpiresAfterHours,
JsonElement Conditions);
public sealed record UpdatePlatformApprovalPolicyCommand(
string Code,
bool Enabled,
bool AlwaysRequireApproval,
int? AmountThresholdCents,
int ExpiresAfterHours,
JsonElement Conditions);
public interface IPlatformApprovalService
{
Task<IReadOnlyCollection<PlatformApprovalRequestItem>> ListAsync(PlatformApprovalActor actor, PlatformApprovalRequestStatus? status, int limit, CancellationToken cancellationToken = default);
Task<PlatformApprovalRequestItem> GetAsync(PlatformApprovalActor actor, Guid requestId, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformApprovalPolicyItem>> ListPoliciesAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default);
Task<PlatformApprovalPolicyItem> UpdatePolicyAsync(PlatformApprovalActor actor, UpdatePlatformApprovalPolicyCommand command, CancellationToken cancellationToken = default);
Task<PlatformApprovalRequestItem> ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default);
Task<PlatformApprovalRequestItem> RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default);
Task<PlatformApprovalRequestItem> CancelAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default);
Task<int> ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default);
Task<PlatformCommandSubmission> SubmitRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default);
Task<PlatformCommandSubmission> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
Task<PlatformCommandSubmission> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
Task<PlatformCommandSubmission> UpsertPaymentChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
Task<PlatformCommandSubmission> ReplaceRoleBindingsAsync(BackofficeActor actor, ReplaceRoleBindingsCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
}
public sealed class PlatformApprovalException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}
public static class PlatformApprovalRules
{
public static bool RequiresApproval(bool enabled, bool alwaysRequireApproval, int? amountThresholdCents, int? amountCents) =>
enabled && (alwaysRequireApproval || amountThresholdCents.HasValue && amountCents >= amountThresholdCents);
public static string? DecisionDenialCode(
PlatformApprovalRequestStatus status,
Guid requestedBy,
Guid decisionActor,
DateTimeOffset expiresAt,
string requiredPermission,
IReadOnlySet<string> permissions,
DateTimeOffset now)
{
if (status != PlatformApprovalRequestStatus.Pending) return "approval_request_not_pending";
if (expiresAt <= now) return "approval_request_expired";
if (requestedBy == decisionActor) return "approval_maker_checker_required";
return permissions.Contains(requiredPermission) ? null : "approval_business_permission_required";
}
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json;
using Tiku.Domain.Platform;
namespace Tiku.Application.PlatformAdmin;
public sealed record PagedQuery(int Page = 1, int PageSize = 50, string? Search = null, string? SortBy = null, bool Descending = true)
{
public int SafePage => Math.Max(1, Page);
public int SafePageSize => Math.Clamp(PageSize, 1, 200);
}
public sealed record PagedResult<T>(IReadOnlyCollection<T> Items, int Total, int Page, int PageSize);
public sealed record PlatformConfigurationDefinitionItem(Guid Id, string Code, string Name, string Category,
PlatformConfigurationValueType ValueType, bool AllowRuntimeManagement, bool IsSensitive, string? Description, JsonElement ValidationSchema);
public sealed record PlatformConfigurationVersionItem(Guid Id, Guid DefinitionId, string Environment, int Version,
PlatformConfigurationVersionStatus Status, JsonElement? Value, string? SecretRef, Guid CreatedBy, Guid? PublishedBy,
Guid? RolledBackFromVersionId, string Reason, DateTimeOffset? PublishedAt, DateTimeOffset CreatedAt);
public sealed record SavePlatformConfigurationDraftCommand(string DefinitionCode, string Environment, JsonElement? Value, string? SecretRef, string Reason);
public sealed record PlatformNotificationTemplateItem(Guid Id, string Code, string Name, PlatformNotificationChannel Channel,
string SubjectTemplate, string BodyTemplate, bool Enabled, JsonElement Variables, DateTimeOffset UpdatedAt);
public sealed record UpsertPlatformNotificationTemplateCommand(Guid? Id, string Code, string Name, PlatformNotificationChannel Channel,
string SubjectTemplate, string BodyTemplate, bool Enabled, JsonElement Variables);
public sealed record SendPlatformNotificationCommand(Guid TemplateId, IReadOnlyCollection<string> RoleCodes,
IReadOnlyDictionary<string, string> Variables, string IdempotencyKey);
public sealed record PlatformNotificationDeliveryItem(Guid Id, Guid TemplateId, Guid RecipientUserId, string RecipientRoleCode,
PlatformNotificationChannel Channel, PlatformNotificationDeliveryStatus Status, string Subject, string Body,
int Attempts, string? LastError, DateTimeOffset? SentAt, DateTimeOffset CreatedAt);
public interface IPlatformGovernanceService
{
Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> GetConfigurationDefinitionsAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> GetConfigurationVersionsAsync(PlatformApprovalActor actor, string definitionCode, string? environment, CancellationToken cancellationToken = default);
Task<PlatformConfigurationVersionItem> SaveConfigurationDraftAsync(PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default);
Task<PlatformConfigurationVersionItem> PublishConfigurationAsync(PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default);
Task<PlatformConfigurationVersionItem> RollbackConfigurationAsync(PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default);
Task<PagedResult<PlatformNotificationDeliveryItem>> GetNotificationDeliveriesAsync(PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> GetNotificationTemplatesAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default);
Task<PlatformNotificationTemplateItem> UpsertNotificationTemplateAsync(PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotificationAsync(PlatformApprovalActor actor, SendPlatformNotificationCommand command, CancellationToken cancellationToken = default);
Task<PlatformNotificationDeliveryItem> RetryNotificationAsync(PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default);
}

View File

@@ -37,6 +37,11 @@ public static class BackendPermissions
public const string PlatformPaymentWrite = "platform:payment:write";
public const string PlatformOperationsView = "platform:operations:view";
public const string PlatformOperationsManage = "platform:operations:manage";
public const string PlatformApprovalView = "platform:approval:view";
public const string PlatformApprovalDecide = "platform:approval:decide";
public const string PlatformApprovalPolicyManage = "platform:approval-policy:manage";
public const string PlatformConfigurationManage = "platform:configuration:manage";
public const string PlatformNotificationManage = "platform:notification:manage";
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
{
@@ -77,7 +82,12 @@ public static class BackendPermissions
PlatformPaymentRead,
PlatformPaymentWrite,
PlatformOperationsView,
PlatformOperationsManage
PlatformOperationsManage,
PlatformApprovalView,
PlatformApprovalDecide,
PlatformApprovalPolicyManage,
PlatformConfigurationManage,
PlatformNotificationManage
};
public static void EnsureTenant(string permissionCode)

View File

@@ -84,6 +84,7 @@ public static class PermissionModuleCatalog
["platform_sms"] = null,
["platform_payment"] = null,
["platform_operations"] = null,
["platform_governance"] = null,
["commerce"] = SaasFeatureCatalog.StudentStore
};
@@ -116,6 +117,8 @@ public static class PermissionModuleCatalog
BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms",
BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment",
BackendPermissions.PlatformOperationsView or BackendPermissions.PlatformOperationsManage => "platform_operations",
BackendPermissions.PlatformApprovalView or BackendPermissions.PlatformApprovalDecide or BackendPermissions.PlatformApprovalPolicyManage => "platform_governance",
BackendPermissions.PlatformConfigurationManage or BackendPermissions.PlatformNotificationManage => "platform_governance",
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
};

View File

@@ -0,0 +1,116 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Platform;
public enum PlatformApprovalRequestStatus
{
Pending,
Approved,
Rejected,
Executing,
Succeeded,
Failed,
Cancelled,
Expired
}
public sealed class PlatformApprovalPolicy : AuditableEntity
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string RequiredPermission { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public bool AlwaysRequireApproval { get; set; }
public int? AmountThresholdCents { get; set; }
public int Version { get; set; } = 1;
public int ExpiresAfterHours { get; set; } = 24;
public JsonElement Conditions { get; set; } = JsonDefaults.Object();
}
public sealed class PlatformApprovalRequest : AuditableEntity
{
public string RequestNo { get; set; } = string.Empty;
public string PolicyCode { get; set; } = string.Empty;
public int PolicyVersion { get; set; }
public PlatformApprovalRequestStatus Status { get; set; } = PlatformApprovalRequestStatus.Pending;
public Guid RequestedBy { get; set; }
public Guid? DecidedBy { get; set; }
public string RequiredPermission { get; set; } = string.Empty;
public string CommandType { get; set; } = string.Empty;
public string TargetType { get; set; } = string.Empty;
public string TargetId { get; set; } = string.Empty;
public int? AmountCents { get; set; }
public string IdempotencyKey { get; set; } = string.Empty;
public string RequestHash { get; set; } = string.Empty;
public JsonElement RequestSnapshot { get; set; } = JsonDefaults.Object();
public JsonElement? ResultSnapshot { get; set; }
public string? RequestReason { get; set; }
public string? DecisionReason { get; set; }
public string? Error { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? DecidedAt { get; set; }
public DateTimeOffset? ExecutedAt { get; set; }
public Guid ConcurrencyStamp { get; set; } = Guid.NewGuid();
}
public enum PlatformConfigurationValueType { String, Number, Boolean, Json, SecretReference }
public enum PlatformConfigurationVersionStatus { Draft, Published, Retired }
public sealed class PlatformConfigurationDefinition : AuditableEntity
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public PlatformConfigurationValueType ValueType { get; set; }
public bool AllowRuntimeManagement { get; set; } = true;
public bool IsSensitive { get; set; }
public string? Description { get; set; }
public JsonElement ValidationSchema { get; set; } = JsonDefaults.Object();
}
public sealed class PlatformConfigurationVersion : AuditableEntity
{
public Guid DefinitionId { get; set; }
public string Environment { get; set; } = "all";
public int Version { get; set; }
public PlatformConfigurationVersionStatus Status { get; set; } = PlatformConfigurationVersionStatus.Draft;
public JsonElement Value { get; set; } = JsonDefaults.Object();
public string? SecretRef { get; set; }
public Guid CreatedBy { get; set; }
public Guid? PublishedBy { get; set; }
public Guid? RolledBackFromVersionId { get; set; }
public string Reason { get; set; } = string.Empty;
public DateTimeOffset? PublishedAt { get; set; }
}
public enum PlatformNotificationChannel { InApp, Sms, Email }
public enum PlatformNotificationDeliveryStatus { Pending, Processing, Sent, Failed, Cancelled }
public sealed class PlatformNotificationTemplate : AuditableEntity
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public PlatformNotificationChannel Channel { get; set; } = PlatformNotificationChannel.InApp;
public string SubjectTemplate { get; set; } = string.Empty;
public string BodyTemplate { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public JsonElement Variables { get; set; } = JsonDefaults.Array();
}
public sealed class PlatformNotificationDelivery : AuditableEntity
{
public Guid TemplateId { get; set; }
public Guid RecipientUserId { get; set; }
public string RecipientRoleCode { get; set; } = string.Empty;
public PlatformNotificationChannel Channel { get; set; }
public PlatformNotificationDeliveryStatus Status { get; set; } = PlatformNotificationDeliveryStatus.Pending;
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string IdempotencyKey { get; set; } = string.Empty;
public int Attempts { get; set; }
public string? LastError { get; set; }
public DateTimeOffset? SentAt { get; set; }
public Guid CreatedBy { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}

View File

@@ -10,6 +10,7 @@ using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using ZLinq;
namespace Tiku.Infrastructure.Auth;
@@ -466,6 +467,7 @@ public sealed class AuthSessionStore(
.ToArrayAsync(cancellationToken);
return sessions
.AsValueEnumerable()
.GroupBy(item => item.TokenFamilyId)
.Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) })
.Where(value => value.Active is not null)

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Application.PlatformAdmin;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
@@ -57,6 +58,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270),
new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280),
new("platform_operations", "平台运维", BackendPermissionArea.Platform, null, 290),
new("platform_governance", "平台治理", BackendPermissionArea.Platform, null, 295),
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
];
@@ -96,6 +98,11 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"),
new(BackendPermissions.PlatformOperationsView, "平台运维查询", BackendPermissionArea.Platform, "platform_operations"),
new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform, "platform_operations"),
new(BackendPermissions.PlatformApprovalView, "平台审批查询", BackendPermissionArea.Platform, "platform_governance"),
new(BackendPermissions.PlatformApprovalDecide, "平台审批决策", BackendPermissionArea.Platform, "platform_governance"),
new(BackendPermissions.PlatformApprovalPolicyManage, "平台审批策略管理", BackendPermissionArea.Platform, "platform_governance"),
new(BackendPermissions.PlatformConfigurationManage, "平台配置中心管理", BackendPermissionArea.Platform, "platform_governance"),
new(BackendPermissions.PlatformNotificationManage, "平台通知中心管理", BackendPermissionArea.Platform, "platform_governance"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -115,15 +122,42 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100),
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110),
new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120),
new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10),
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20),
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50),
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60),
new("platform.crm", null, "CRM 接入", BackendPermissionArea.Platform, "/platform/crm", BackendPermissions.PlatformCrmRead, 70),
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/platform/sms", BackendPermissions.PlatformSmsRead, 80),
new("platform.payment", null, "支付设置", BackendPermissionArea.Platform, "/platform/payment-settings", BackendPermissions.PlatformPaymentRead, 90)
new("platform.dashboard", null, "经营概览", BackendPermissionArea.Platform, "/", BackendPermissions.PlatformDashboardView, 10),
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/tenants", BackendPermissions.PlatformTenantManage, 20),
new("platform.subscriptions", null, "订阅与应收", BackendPermissionArea.Platform, "/subscriptions", BackendPermissions.PlatformSaasBillingManage, 30),
new("platform.usage", null, "用量计费", BackendPermissionArea.Platform, "/usage", BackendPermissions.PlatformSaasBillingManage, 40),
new("platform.dunning", null, "收款与催缴", BackendPermissionArea.Platform, "/dunning", BackendPermissions.PlatformBillingNotification, 50),
new("platform.refunds", null, "退款处理", BackendPermissionArea.Platform, "/refunds", BackendPermissions.PlatformSaasBillingManage, 60),
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/question-banks", BackendPermissions.PlatformQuestionBankManage, 70),
new("platform.crm", null, "CRM 服务", BackendPermissionArea.Platform, "/crm", BackendPermissions.PlatformCrmRead, 80),
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/sms", BackendPermissions.PlatformSmsRead, 90),
new("platform.payment", null, "支付服务", BackendPermissionArea.Platform, "/payments", BackendPermissions.PlatformPaymentRead, 100),
new("platform.staff", null, "员工与角色", BackendPermissionArea.Platform, "/staff", BackendPermissions.PlatformStaffManage, 110),
new("platform.audit", null, "审计日志", BackendPermissionArea.Platform, "/audit", BackendPermissions.PlatformAuditView, 120),
new("platform.alerts", null, "审计告警", BackendPermissionArea.Platform, "/alerts", BackendPermissions.PlatformAuditView, 130),
new("platform.operations", null, "运行中心", BackendPermissionArea.Platform, "/operations", BackendPermissions.PlatformOperationsView, 140),
new("platform.approvals", null, "审批中心", BackendPermissionArea.Platform, "/approvals", BackendPermissions.PlatformApprovalView, 150),
new("platform.configuration", null, "配置中心", BackendPermissionArea.Platform, "/configuration", BackendPermissions.PlatformConfigurationManage, 160),
new("platform.notifications", null, "通知中心", BackendPermissionArea.Platform, "/notifications", BackendPermissions.PlatformNotificationManage, 170)
];
private static readonly BuiltinPlatformRole[] PlatformRoles =
[
new("platform_customer_service", "客服运营", "租户开通、客户服务与只读渠道排障",
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformTenantManage, BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformNotificationManage],
["platform.dashboard", "platform.tenants", "platform.crm", "platform.sms", "platform.approvals", "platform.notifications"]),
new("platform_finance", "财务运营", "套餐、订阅、应收、催缴、退款与支付查询",
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformSaasCatalogManage, BackendPermissions.PlatformSaasBillingManage, BackendPermissions.PlatformBillingNotification, BackendPermissions.PlatformPaymentRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide],
["platform.dashboard", "platform.subscriptions", "platform.usage", "platform.dunning", "platform.refunds", "platform.payment", "platform.approvals"]),
new("platform_content_operator", "内容运营", "平台公共题库运营",
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformQuestionBankManage],
["platform.dashboard", "platform.content"]),
new("platform_technical_operations", "技术运维", "依赖健康、Worker 与后台任务治理",
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformOperationsView, BackendPermissions.PlatformOperationsManage, BackendPermissions.PlatformConfigurationManage],
["platform.dashboard", "platform.operations", "platform.configuration"]),
new("platform_security_admin", "安全管理员", "平台员工、角色、审计与安全告警治理",
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformStaffManage, BackendPermissions.PlatformRoleManage, BackendPermissions.PlatformAuditView, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide, BackendPermissions.PlatformApprovalPolicyManage, BackendPermissions.PlatformConfigurationManage],
["platform.dashboard", "platform.staff", "platform.audit", "platform.alerts", "platform.approvals", "platform.configuration"])
];
public async Task SeedAsync(CancellationToken cancellationToken = default)
@@ -168,10 +202,9 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
var menuCodes = Menus.Select(item => item.Code).ToArray();
var existingMenuCodes = await dbContext.BackendMenus.AsNoTracking()
var existingMenus = await dbContext.BackendMenus
.Where(item => menuCodes.Contains(item.Code))
.Select(item => item.Code)
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
.ToDictionaryAsync(item => item.Code, StringComparer.Ordinal, cancellationToken);
dbContext.SaasFeatures.AddRange(Features
.Where(item => !existingFeatureCodes.Contains(item.Code))
@@ -205,7 +238,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
IsSystem = true
}));
dbContext.BackendMenus.AddRange(Menus
.Where(item => !existingMenuCodes.Contains(item.Code))
.Where(item => !existingMenus.ContainsKey(item.Code))
.Select(item => new BackendMenu
{
Code = item.Code,
@@ -217,6 +250,74 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
SortOrder = item.SortOrder,
IsActive = true
}));
var roleCodes = PlatformRoles.Select(item => item.Code).ToArray();
var existingRoles = await dbContext.PlatformBackendRoles
.Where(role => roleCodes.Contains(role.Code))
.ToDictionaryAsync(role => role.Code, StringComparer.Ordinal, cancellationToken);
foreach (var builtin in PlatformRoles)
{
if (!existingRoles.TryGetValue(builtin.Code, out var role))
{
role = new PlatformBackendRole { Code = builtin.Code, IsSystem = true };
dbContext.PlatformBackendRoles.Add(role);
existingRoles[builtin.Code] = role;
}
role.Name = builtin.Name;
role.Description = builtin.Description;
role.Status = BackendRoleStatus.Active;
role.IsSystem = true;
}
var approvalPolicies = new[]
{
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.TenantArchive, Name = "终止租户", RequiredPermission = BackendPermissions.PlatformTenantManage, AlwaysRequireApproval = true },
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.SuperAdminGrant, Name = "平台超级权限变更", RequiredPermission = BackendPermissions.PlatformRoleManage, AlwaysRequireApproval = true },
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.PaymentChannelChange, Name = "支付渠道或密钥引用变更", RequiredPermission = BackendPermissions.PlatformPaymentWrite, AlwaysRequireApproval = true },
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "大额退款及手工收款", RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 }
};
var approvalPolicyCodes = approvalPolicies.Select(item => item.Code).ToArray();
var existingPolicyCodes = await dbContext.PlatformApprovalPolicies.AsNoTracking()
.Where(policy => approvalPolicyCodes.Contains(policy.Code))
.Select(policy => policy.Code)
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
dbContext.PlatformApprovalPolicies.AddRange(approvalPolicies.Where(policy => !existingPolicyCodes.Contains(policy.Code)));
var configurationDefinitions = new[]
{
new PlatformConfigurationDefinition { Code = "platform.support-contact", Name = "平台支持联系方式", Category = "platform", ValueType = PlatformConfigurationValueType.String },
new PlatformConfigurationDefinition { Code = "operations.notification-retention-days", Name = "通知投递保留天数", Category = "operations", ValueType = PlatformConfigurationValueType.Number },
new PlatformConfigurationDefinition { Code = "security.jwt-key-ring", Name = "JWT 密钥环", Category = "security", ValueType = PlatformConfigurationValueType.SecretReference, IsSensitive = true, AllowRuntimeManagement = false, Description = "安全配置只能通过部署环境变更。" }
};
var configurationCodes = configurationDefinitions.Select(item => item.Code).ToArray();
var existingConfigurationCodes = await dbContext.PlatformConfigurationDefinitions.AsNoTracking()
.Where(item => configurationCodes.Contains(item.Code)).Select(item => item.Code)
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
dbContext.PlatformConfigurationDefinitions.AddRange(configurationDefinitions.Where(item => !existingConfigurationCodes.Contains(item.Code)));
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var builtin in PlatformRoles)
{
var role = existingRoles[builtin.Code];
var boundPermissions = await dbContext.PlatformBackendRolePermissions
.Where(binding => binding.RoleId == role.Id)
.ToArrayAsync(cancellationToken);
dbContext.PlatformBackendRolePermissions.RemoveRange(boundPermissions
.Where(binding => !builtin.PermissionCodes.Contains(binding.PermissionCode, StringComparer.Ordinal)));
var boundPermissionCodes = boundPermissions.Select(binding => binding.PermissionCode).ToHashSet(StringComparer.Ordinal);
dbContext.PlatformBackendRolePermissions.AddRange(builtin.PermissionCodes
.Where(code => !boundPermissionCodes.Contains(code))
.Select(code => new PlatformBackendRolePermission { RoleId = role.Id, PermissionCode = code }));
var boundMenus = await dbContext.PlatformBackendRoleMenus
.Where(binding => binding.RoleId == role.Id)
.ToArrayAsync(cancellationToken);
dbContext.PlatformBackendRoleMenus.RemoveRange(boundMenus
.Where(binding => !builtin.MenuCodes.Contains(binding.MenuCode, StringComparer.Ordinal)));
var boundMenuCodes = boundMenus.Select(binding => binding.MenuCode).ToHashSet(StringComparer.Ordinal);
dbContext.PlatformBackendRoleMenus.AddRange(builtin.MenuCodes
.Where(code => !boundMenuCodes.Contains(code))
.Select(code => new PlatformBackendRoleMenu { RoleId = role.Id, MenuCode = code }));
}
var superAdminRoleId = await dbContext.PlatformBackendRoles.AsNoTracking()
.Where(role => role.Code == PlatformAdminBootstrapper.SuperAdminRoleCode && role.IsSystem)
@@ -244,4 +345,5 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder);
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode);
private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
private sealed record BuiltinPlatformRole(string Code, string Name, string Description, string[] PermissionCodes, string[] MenuCodes);
}

View File

@@ -143,6 +143,8 @@ public static class DependencyInjection
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
services.AddScoped<IPlatformPaymentSettingsService, PlatformPaymentSettingsService>();
services.AddScoped<IPlatformApprovalService, PlatformApprovalService>();
services.AddScoped<IPlatformGovernanceService, PlatformGovernanceService>();
services.AddScoped<IPlatformTenantPaymentAdminService, PlatformTenantPaymentAdminService>();
services.AddScoped<ISaasCatalogAdminService, SaasCatalogAdminService>();
services.AddScoped<ITenantBillingService, TenantBillingService>();

View File

@@ -13,6 +13,7 @@ using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using ZLinq;
namespace Tiku.Infrastructure.Learning;
@@ -83,6 +84,7 @@ public sealed class LearningActivityService(
.Select(item => new { item.AnsweredAt, item.IsCorrect })
.ToArrayAsync(cancellationToken);
var items = rows
.AsValueEnumerable()
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
.OrderBy(group => group.Key)
.Select(group => new LearningTrendItem(

View File

@@ -246,3 +246,127 @@ internal sealed class PlatformPaymentChannelConfiguration : IEntityTypeConfigura
builder.HasOne<PlatformPaymentApp>().WithMany().HasForeignKey(entity => entity.AppId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PlatformApprovalPolicyConfiguration : IEntityTypeConfiguration<PlatformApprovalPolicy>
{
public void Configure(EntityTypeBuilder<PlatformApprovalPolicy> builder)
{
builder.ConfigureEntity("platform_approval_policies");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(120);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.RequiredPermission).HasMaxLength(200);
builder.Property(entity => entity.Conditions).IsJson("{}");
builder.HasIndex(entity => entity.Code).IsUnique();
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_platform_approval_policies_version", "version > 0");
table.HasCheckConstraint("ck_platform_approval_policies_expiry", "expires_after_hours between 1 and 720");
table.HasCheckConstraint("ck_platform_approval_policies_threshold", "amount_threshold_cents is null or amount_threshold_cents > 0");
});
}
}
internal sealed class PlatformApprovalRequestConfiguration : IEntityTypeConfiguration<PlatformApprovalRequest>
{
public void Configure(EntityTypeBuilder<PlatformApprovalRequest> builder)
{
builder.ConfigureEntity("platform_approval_requests");
builder.ConfigureTimestamps();
builder.Property(entity => entity.RequestNo).HasMaxLength(50);
builder.Property(entity => entity.PolicyCode).HasMaxLength(120);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.RequiredPermission).HasMaxLength(200);
builder.Property(entity => entity.CommandType).HasMaxLength(200);
builder.Property(entity => entity.TargetType).HasMaxLength(100);
builder.Property(entity => entity.TargetId).HasMaxLength(200);
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
builder.Property(entity => entity.RequestSnapshot).IsJson("{}");
builder.Property(entity => entity.ResultSnapshot).HasColumnType("jsonb");
builder.Property(entity => entity.RequestReason).HasMaxLength(1000);
builder.Property(entity => entity.DecisionReason).HasMaxLength(1000);
builder.Property(entity => entity.Error).HasMaxLength(4000);
builder.Property(entity => entity.ConcurrencyStamp).IsConcurrencyToken();
builder.HasIndex(entity => entity.RequestNo).IsUnique();
builder.HasIndex(entity => new { entity.RequestedBy, entity.CommandType, entity.IdempotencyKey }).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.ExpiresAt, entity.CreatedAt });
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.RequestedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.DecidedBy).OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformConfigurationDefinitionConfiguration : IEntityTypeConfiguration<PlatformConfigurationDefinition>
{
public void Configure(EntityTypeBuilder<PlatformConfigurationDefinition> builder)
{
builder.ConfigureEntity("platform_configuration_definitions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(120);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Category).HasMaxLength(100);
builder.Property(entity => entity.ValueType).HasSnakeCaseEnum();
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.Property(entity => entity.ValidationSchema).IsJson("{}");
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Category, entity.Code });
}
}
internal sealed class PlatformConfigurationVersionConfiguration : IEntityTypeConfiguration<PlatformConfigurationVersion>
{
public void Configure(EntityTypeBuilder<PlatformConfigurationVersion> builder)
{
builder.ConfigureEntity("platform_configuration_versions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Environment).HasMaxLength(80);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Value).IsJson("{}");
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.Reason).HasMaxLength(1000);
builder.HasIndex(entity => new { entity.DefinitionId, entity.Environment, entity.Version }).IsUnique();
builder.HasIndex(entity => new { entity.DefinitionId, entity.Environment, entity.Status });
builder.HasOne<PlatformConfigurationDefinition>().WithMany().HasForeignKey(entity => entity.DefinitionId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.PublishedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PlatformConfigurationVersion>().WithMany().HasForeignKey(entity => entity.RolledBackFromVersionId).OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformNotificationTemplateConfiguration : IEntityTypeConfiguration<PlatformNotificationTemplate>
{
public void Configure(EntityTypeBuilder<PlatformNotificationTemplate> builder)
{
builder.ConfigureEntity("platform_notification_templates");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(120);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Channel).HasSnakeCaseEnum();
builder.Property(entity => entity.SubjectTemplate).HasMaxLength(500);
builder.Property(entity => entity.BodyTemplate).HasMaxLength(8000);
builder.Property(entity => entity.Variables).IsJson("[]");
builder.HasIndex(entity => entity.Code).IsUnique();
}
}
internal sealed class PlatformNotificationDeliveryConfiguration : IEntityTypeConfiguration<PlatformNotificationDelivery>
{
public void Configure(EntityTypeBuilder<PlatformNotificationDelivery> builder)
{
builder.ConfigureEntity("platform_notification_deliveries");
builder.ConfigureTimestamps();
builder.Property(entity => entity.RecipientRoleCode).HasMaxLength(120);
builder.Property(entity => entity.Channel).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Subject).HasMaxLength(500);
builder.Property(entity => entity.Body).HasMaxLength(8000);
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
builder.Property(entity => entity.LastError).HasMaxLength(4000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TemplateId, entity.RecipientUserId, entity.IdempotencyKey }).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.CreatedAt });
builder.HasOne<PlatformNotificationTemplate>().WithMany().HasForeignKey(entity => entity.TemplateId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.RecipientUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlatformGovernance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "platform_approval_policies",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
required_permission = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
enabled = table.Column<bool>(type: "boolean", nullable: false),
always_require_approval = table.Column<bool>(type: "boolean", nullable: false),
amount_threshold_cents = table.Column<int>(type: "integer", nullable: true),
version = table.Column<int>(type: "integer", nullable: false),
expires_after_hours = table.Column<int>(type: "integer", nullable: false),
conditions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_approval_policies", x => x.id);
table.CheckConstraint("ck_platform_approval_policies_expiry", "expires_after_hours between 1 and 720");
table.CheckConstraint("ck_platform_approval_policies_threshold", "amount_threshold_cents is null or amount_threshold_cents > 0");
table.CheckConstraint("ck_platform_approval_policies_version", "version > 0");
});
migrationBuilder.CreateTable(
name: "platform_approval_requests",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
request_no = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
policy_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
policy_version = table.Column<int>(type: "integer", nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
requested_by = table.Column<Guid>(type: "uuid", nullable: false),
decided_by = table.Column<Guid>(type: "uuid", nullable: true),
required_permission = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
command_type = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
target_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
target_id = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
amount_cents = table.Column<int>(type: "integer", nullable: true),
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
request_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
request_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
result_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: true),
request_reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
decision_reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
decided_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
executed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
concurrency_stamp = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_approval_requests", x => x.id);
table.ForeignKey(
name: "fk_platform_approval_requests_users_decided_by",
column: x => x.decided_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_approval_requests_users_requested_by",
column: x => x.requested_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "ix_platform_approval_policies_code",
table: "platform_approval_policies",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_approval_requests_decided_by",
table: "platform_approval_requests",
column: "decided_by");
migrationBuilder.CreateIndex(
name: "ix_platform_approval_requests_request_no",
table: "platform_approval_requests",
column: "request_no",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_approval_requests_requested_by_command_type_idempo~",
table: "platform_approval_requests",
columns: new[] { "requested_by", "command_type", "idempotency_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_approval_requests_status_expires_at_created_at",
table: "platform_approval_requests",
columns: new[] { "status", "expires_at", "created_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "platform_approval_policies");
migrationBuilder.DropTable(
name: "platform_approval_requests");
}
}
}

View File

@@ -0,0 +1,257 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlatformGovernanceCenters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "platform_configuration_definitions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
category = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
value_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
allow_runtime_management = table.Column<bool>(type: "boolean", nullable: false),
is_sensitive = table.Column<bool>(type: "boolean", nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
validation_schema = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_configuration_definitions", x => x.id);
});
migrationBuilder.CreateTable(
name: "platform_notification_templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
channel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
subject_template = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
body_template = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
enabled = table.Column<bool>(type: "boolean", nullable: false),
variables = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_notification_templates", x => x.id);
});
migrationBuilder.CreateTable(
name: "platform_configuration_versions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
definition_id = table.Column<Guid>(type: "uuid", nullable: false),
environment = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
version = table.Column<int>(type: "integer", nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
value = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
created_by = table.Column<Guid>(type: "uuid", nullable: false),
published_by = table.Column<Guid>(type: "uuid", nullable: true),
rolled_back_from_version_id = table.Column<Guid>(type: "uuid", nullable: true),
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
published_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_configuration_versions", x => x.id);
table.ForeignKey(
name: "fk_platform_configuration_versions_platform_configuration_defi~",
column: x => x.definition_id,
principalTable: "platform_configuration_definitions",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_configuration_versions_platform_configuration_vers~",
column: x => x.rolled_back_from_version_id,
principalTable: "platform_configuration_versions",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_configuration_versions_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_configuration_versions_users_published_by",
column: x => x.published_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "platform_notification_deliveries",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
template_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_user_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_role_code = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
channel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
subject = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
attempts = table.Column<int>(type: "integer", nullable: false),
last_error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
sent_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_by = table.Column<Guid>(type: "uuid", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_notification_deliveries", x => x.id);
table.ForeignKey(
name: "fk_platform_notification_deliveries_platform_notification_temp~",
column: x => x.template_id,
principalTable: "platform_notification_templates",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_notification_deliveries_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_notification_deliveries_users_recipient_user_id",
column: x => x.recipient_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_definitions_category_code",
table: "platform_configuration_definitions",
columns: new[] { "category", "code" });
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_definitions_code",
table: "platform_configuration_definitions",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_versions_created_by",
table: "platform_configuration_versions",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_versions_definition_id_environment_s~",
table: "platform_configuration_versions",
columns: new[] { "definition_id", "environment", "status" });
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_versions_definition_id_environment_v~",
table: "platform_configuration_versions",
columns: new[] { "definition_id", "environment", "version" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_versions_published_by",
table: "platform_configuration_versions",
column: "published_by");
migrationBuilder.CreateIndex(
name: "ix_platform_configuration_versions_rolled_back_from_version_id",
table: "platform_configuration_versions",
column: "rolled_back_from_version_id");
migrationBuilder.CreateIndex(
name: "ix_platform_notification_deliveries_created_by",
table: "platform_notification_deliveries",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_platform_notification_deliveries_recipient_user_id",
table: "platform_notification_deliveries",
column: "recipient_user_id");
migrationBuilder.CreateIndex(
name: "ix_platform_notification_deliveries_status_created_at",
table: "platform_notification_deliveries",
columns: new[] { "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_platform_notification_deliveries_template_id_recipient_user~",
table: "platform_notification_deliveries",
columns: new[] { "template_id", "recipient_user_id", "idempotency_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_notification_templates_code",
table: "platform_notification_templates",
column: "code",
unique: true);
migrationBuilder.Sql("""
UPDATE backend_menus
SET path = CASE code
WHEN 'platform.dashboard' THEN '/'
WHEN 'platform.tenants' THEN '/tenants'
WHEN 'platform.staff' THEN '/staff'
WHEN 'platform.content' THEN '/question-banks'
WHEN 'platform.audit' THEN '/audit'
WHEN 'platform.crm' THEN '/crm'
WHEN 'platform.sms' THEN '/sms'
WHEN 'platform.payment' THEN '/payments'
ELSE path
END
WHERE (code, path) IN (
('platform.dashboard', '/platform/dashboard'),
('platform.tenants', '/platform/tenants'),
('platform.staff', '/platform/staff'),
('platform.content', '/platform/question-banks'),
('platform.audit', '/platform/audit'),
('platform.crm', '/platform/crm'),
('platform.sms', '/platform/sms'),
('platform.payment', '/platform/payment-settings'));
UPDATE backend_menus
SET is_active = FALSE
WHERE code = 'platform.saas' AND path = '/platform/saas';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "platform_configuration_versions");
migrationBuilder.DropTable(
name: "platform_notification_deliveries");
migrationBuilder.DropTable(
name: "platform_configuration_definitions");
migrationBuilder.DropTable(
name: "platform_notification_templates");
}
}
}

View File

@@ -12246,6 +12246,239 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("permission_modules", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformApprovalPolicy", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<bool>("AlwaysRequireApproval")
.HasColumnType("boolean")
.HasColumnName("always_require_approval");
b.Property<int?>("AmountThresholdCents")
.HasColumnType("integer")
.HasColumnName("amount_threshold_cents");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("code");
b.Property<JsonElement>("Conditions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("conditions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<int>("ExpiresAfterHours")
.HasColumnType("integer")
.HasColumnName("expires_after_hours");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("RequiredPermission")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("required_permission");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.HasKey("Id")
.HasName("pk_platform_approval_policies");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_platform_approval_policies_code");
b.ToTable("platform_approval_policies", null, t =>
{
t.HasCheckConstraint("ck_platform_approval_policies_expiry", "expires_after_hours between 1 and 720");
t.HasCheckConstraint("ck_platform_approval_policies_threshold", "amount_threshold_cents is null or amount_threshold_cents > 0");
t.HasCheckConstraint("ck_platform_approval_policies_version", "version > 0");
});
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int?>("AmountCents")
.HasColumnType("integer")
.HasColumnName("amount_cents");
b.Property<string>("CommandType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("command_type");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("concurrency_stamp");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("decided_at");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid")
.HasColumnName("decided_by");
b.Property<string>("DecisionReason")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("decision_reason");
b.Property<string>("Error")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("error");
b.Property<DateTimeOffset?>("ExecutedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("executed_at");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("idempotency_key");
b.Property<string>("PolicyCode")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("policy_code");
b.Property<int>("PolicyVersion")
.HasColumnType("integer")
.HasColumnName("policy_version");
b.Property<string>("RequestHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("request_hash");
b.Property<string>("RequestNo")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("request_no");
b.Property<string>("RequestReason")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("request_reason");
b.Property<JsonElement>("RequestSnapshot")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("request_snapshot")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid>("RequestedBy")
.HasColumnType("uuid")
.HasColumnName("requested_by");
b.Property<string>("RequiredPermission")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("required_permission");
b.Property<JsonElement?>("ResultSnapshot")
.HasColumnType("jsonb")
.HasColumnName("result_snapshot");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<string>("TargetId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("target_id");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("target_type");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_approval_requests");
b.HasIndex("DecidedBy")
.HasDatabaseName("ix_platform_approval_requests_decided_by");
b.HasIndex("RequestNo")
.IsUnique()
.HasDatabaseName("ix_platform_approval_requests_request_no");
b.HasIndex("RequestedBy", "CommandType", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("ix_platform_approval_requests_requested_by_command_type_idempo~");
b.HasIndex("Status", "ExpiresAt", "CreatedAt")
.HasDatabaseName("ix_platform_approval_requests_status_expires_at_created_at");
b.ToTable("platform_approval_requests", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
{
b.Property<Guid>("Id")
@@ -13543,6 +13776,353 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformConfigurationDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<bool>("AllowRuntimeManagement")
.HasColumnType("boolean")
.HasColumnName("allow_runtime_management");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("category");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSensitive")
.HasColumnType("boolean")
.HasColumnName("is_sensitive");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("ValidationSchema")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("validation_schema")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("ValueType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("value_type");
b.HasKey("Id")
.HasName("pk_platform_configuration_definitions");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_platform_configuration_definitions_code");
b.HasIndex("Category", "Code")
.HasDatabaseName("ix_platform_configuration_definitions_category_code");
b.ToTable("platform_configuration_definitions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformConfigurationVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("Environment")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)")
.HasColumnName("environment");
b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("published_at");
b.Property<Guid?>("PublishedBy")
.HasColumnType("uuid")
.HasColumnName("published_by");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("reason");
b.Property<Guid?>("RolledBackFromVersionId")
.HasColumnType("uuid")
.HasColumnName("rolled_back_from_version_id");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Value")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("value")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.HasKey("Id")
.HasName("pk_platform_configuration_versions");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_platform_configuration_versions_created_by");
b.HasIndex("PublishedBy")
.HasDatabaseName("ix_platform_configuration_versions_published_by");
b.HasIndex("RolledBackFromVersionId")
.HasDatabaseName("ix_platform_configuration_versions_rolled_back_from_version_id");
b.HasIndex("DefinitionId", "Environment", "Status")
.HasDatabaseName("ix_platform_configuration_versions_definition_id_environment_s~");
b.HasIndex("DefinitionId", "Environment", "Version")
.IsUnique()
.HasDatabaseName("ix_platform_configuration_versions_definition_id_environment_v~");
b.ToTable("platform_configuration_versions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformNotificationDelivery", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("Attempts")
.HasColumnType("integer")
.HasColumnName("attempts");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("body");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("channel");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("idempotency_key");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("last_error");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("RecipientRoleCode")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("recipient_role_code");
b.Property<Guid>("RecipientUserId")
.HasColumnType("uuid")
.HasColumnName("recipient_user_id");
b.Property<DateTimeOffset?>("SentAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("sent_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("subject");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid")
.HasColumnName("template_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_notification_deliveries");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_platform_notification_deliveries_created_by");
b.HasIndex("RecipientUserId")
.HasDatabaseName("ix_platform_notification_deliveries_recipient_user_id");
b.HasIndex("Status", "CreatedAt")
.HasDatabaseName("ix_platform_notification_deliveries_status_created_at");
b.HasIndex("TemplateId", "RecipientUserId", "IdempotencyKey")
.IsUnique()
.HasDatabaseName("ix_platform_notification_deliveries_template_id_recipient_user~");
b.ToTable("platform_notification_deliveries", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformNotificationTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("BodyTemplate")
.IsRequired()
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("body_template");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("channel");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("SubjectTemplate")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("subject_template");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Variables")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("variables")
.HasDefaultValueSql("'[]'::jsonb");
b.HasKey("Id")
.HasName("pk_platform_notification_templates");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_platform_notification_templates_code");
b.ToTable("platform_notification_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformOperationIdempotency", b =>
{
b.Property<Guid>("Id")
@@ -19808,6 +20388,22 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_permission_modules_saas_features_required_feature_code");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformApprovalRequest", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("DecidedBy")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_platform_approval_requests_users_decided_by");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("RequestedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_approval_requests_users_requested_by");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -20065,6 +20661,59 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_platform_billing_refunds_platform_billing_payments_tenant_i~");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformConfigurationVersion", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_configuration_versions_users_created_by");
b.HasOne("Tiku.Domain.Platform.PlatformConfigurationDefinition", null)
.WithMany()
.HasForeignKey("DefinitionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_configuration_versions_platform_configuration_defi~");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("PublishedBy")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_platform_configuration_versions_users_published_by");
b.HasOne("Tiku.Domain.Platform.PlatformConfigurationVersion", null)
.WithMany()
.HasForeignKey("RolledBackFromVersionId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_platform_configuration_versions_platform_configuration_vers~");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformNotificationDelivery", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_notification_deliveries_users_created_by");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("RecipientUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_notification_deliveries_users_recipient_user_id");
b.HasOne("Tiku.Domain.Platform.PlatformNotificationTemplate", null)
.WithMany()
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_notification_deliveries_platform_notification_temp~");
});
modelBuilder.Entity("Tiku.Domain.Platform.PlatformOperationIdempotency", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)

View File

@@ -196,6 +196,12 @@ public sealed class TikuDbContext(
public DbSet<PlatformBillingDunningNotificationEvent> PlatformBillingDunningNotificationEvents => Set<PlatformBillingDunningNotificationEvent>();
public DbSet<PlatformPaymentApp> PlatformPaymentApps => Set<PlatformPaymentApp>();
public DbSet<PlatformPaymentChannel> PlatformPaymentChannels => Set<PlatformPaymentChannel>();
public DbSet<PlatformApprovalPolicy> PlatformApprovalPolicies => Set<PlatformApprovalPolicy>();
public DbSet<PlatformApprovalRequest> PlatformApprovalRequests => Set<PlatformApprovalRequest>();
public DbSet<PlatformConfigurationDefinition> PlatformConfigurationDefinitions => Set<PlatformConfigurationDefinition>();
public DbSet<PlatformConfigurationVersion> PlatformConfigurationVersions => Set<PlatformConfigurationVersion>();
public DbSet<PlatformNotificationTemplate> PlatformNotificationTemplates => Set<PlatformNotificationTemplate>();
public DbSet<PlatformNotificationDelivery> PlatformNotificationDeliveries => Set<PlatformNotificationDelivery>();
public DbSet<SaasFeature> SaasFeatures => Set<SaasFeature>();
public DbSet<PermissionModule> PermissionModules => Set<PermissionModule>();
public DbSet<SaasOffering> SaasOfferings => Set<SaasOffering>();

View File

@@ -0,0 +1,427 @@
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);
}

View File

@@ -0,0 +1,237 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed partial class PlatformGovernanceService(
TikuDbContext dbContext,
IOperationAuditService auditService) : IPlatformGovernanceService
{
public async Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> GetConfigurationDefinitionsAsync(
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category).ThenBy(item => item.Code)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> GetConfigurationVersionsAsync(
PlatformApprovalActor actor, string definitionCode, string? environment, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var definitionId = await dbContext.PlatformConfigurationDefinitions.AsNoTracking()
.Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id).SingleOrDefaultAsync(cancellationToken)
?? throw Error("Configuration definition was not found.", "platform_configuration_not_found");
var query = dbContext.PlatformConfigurationVersions.AsNoTracking().Where(item => item.DefinitionId == definitionId);
if (!string.IsNullOrWhiteSpace(environment)) query = query.Where(item => item.Environment == NormalizeEnvironment(environment));
return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
}
public async Task<PlatformConfigurationVersionItem> SaveConfigurationDraftAsync(
PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var definition = await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync(item => item.Code == command.DefinitionCode, cancellationToken)
?? throw Error("Configuration definition was not found.", "platform_configuration_not_found");
if (!definition.AllowRuntimeManagement)
throw Error("Security-controlled configuration cannot be changed at runtime.", "platform_configuration_runtime_forbidden");
var environment = NormalizeEnvironment(command.Environment);
ValidateValue(definition, command.Value, command.SecretRef);
var nextVersion = (await dbContext.PlatformConfigurationVersions
.Where(item => item.DefinitionId == definition.Id && item.Environment == environment)
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
var item = new PlatformConfigurationVersion
{
DefinitionId = definition.Id,
Environment = environment,
Version = nextVersion,
Value = definition.IsSensitive ? JsonDefaults.Object() : command.Value!.Value.Clone(),
SecretRef = definition.IsSensitive ? command.SecretRef!.Trim() : null,
CreatedBy = actor.UserId,
Reason = Required(command.Reason, "reason")
};
dbContext.PlatformConfigurationVersions.Add(item);
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.configuration.draft_saved", "platform_configuration_versions", item.Id,
new { definition.Code, item.Environment, item.Version }, cancellationToken);
return ToItem(item);
}
public async Task<PlatformConfigurationVersionItem> PublishConfigurationAsync(
PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
if (item.Status != PlatformConfigurationVersionStatus.Draft)
throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft");
var current = await dbContext.PlatformConfigurationVersions.Where(value => value.DefinitionId == item.DefinitionId &&
value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published).ToArrayAsync(cancellationToken);
foreach (var published in current) published.Status = PlatformConfigurationVersionStatus.Retired;
item.Status = PlatformConfigurationVersionStatus.Published;
item.PublishedBy = actor.UserId;
item.PublishedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.configuration.published", "platform_configuration_versions", item.Id,
new { item.DefinitionId, item.Environment, item.Version }, cancellationToken);
return ToItem(item);
}
public async Task<PlatformConfigurationVersionItem> RollbackConfigurationAsync(
PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var source = await dbContext.PlatformConfigurationVersions.AsNoTracking().SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken)
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item => item.DefinitionId == source.DefinitionId && item.Environment == source.Environment)
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
var rollback = new PlatformConfigurationVersion
{
DefinitionId = source.DefinitionId,
Environment = source.Environment,
Version = nextVersion,
Value = source.Value.Clone(),
SecretRef = source.SecretRef,
CreatedBy = actor.UserId,
RolledBackFromVersionId = source.Id,
Reason = Required(reason, "reason")
};
dbContext.PlatformConfigurationVersions.Add(rollback);
await dbContext.SaveChangesAsync(cancellationToken);
return await PublishConfigurationAsync(actor, rollback.Id, cancellationToken);
}
public async Task<PagedResult<PlatformNotificationDeliveryItem>> GetNotificationDeliveriesAsync(
PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var values = dbContext.PlatformNotificationDeliveries.AsNoTracking();
if (status.HasValue) values = values.Where(item => item.Status == status.Value);
if (!string.IsNullOrWhiteSpace(query.Search))
values = values.Where(item => item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search));
var total = await values.CountAsync(cancellationToken);
var items = await values.OrderByDescending(item => item.CreatedAt)
.Skip((query.SafePage - 1) * query.SafePageSize).Take(query.SafePageSize)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
return new PagedResult<PlatformNotificationDeliveryItem>(items, total, query.SafePage, query.SafePageSize);
}
public async Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> GetNotificationTemplatesAsync(
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
return await dbContext.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
}
public async Task<PlatformNotificationTemplateItem> UpsertNotificationTemplateAsync(
PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var code = Required(command.Code, "code").ToLowerInvariant();
var item = command.Id.HasValue
? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
: await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, cancellationToken);
item ??= new PlatformNotificationTemplate { Code = code };
if (dbContext.Entry(item).State == EntityState.Detached) dbContext.PlatformNotificationTemplates.Add(item);
item.Name = Required(command.Name, "name");
item.Channel = command.Channel;
item.SubjectTemplate = Required(command.SubjectTemplate, "subjectTemplate");
item.BodyTemplate = Required(command.BodyTemplate, "bodyTemplate");
item.Enabled = command.Enabled;
item.Variables = command.Variables.Clone();
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates", item.Id, new { item.Code, item.Channel }, cancellationToken);
return ToItem(item);
}
public async Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotificationAsync(
PlatformApprovalActor actor, SendPlatformNotificationCommand command, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var template = await dbContext.PlatformNotificationTemplates.AsNoTracking().SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken)
?? throw Error("Notification template was not found.", "platform_notification_template_not_found");
if (!template.Enabled) throw Error("Notification template is disabled.", "platform_notification_template_disabled");
var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal).ToArray();
var recipients = await (from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active
select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken);
var subject = Render(template.SubjectTemplate, command.Variables);
var body = Render(template.BodyTemplate, command.Variables);
var deliveries = new List<PlatformNotificationDelivery>();
foreach (var recipient in recipients)
{
var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => item.TemplateId == template.Id &&
item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
if (existing) continue;
var delivery = new PlatformNotificationDelivery
{
TemplateId = template.Id,
RecipientUserId = recipient.UserId,
RecipientRoleCode = recipient.RoleCode,
Channel = template.Channel,
Subject = subject,
Body = body,
IdempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"),
CreatedBy = actor.UserId,
Status = template.Channel == PlatformNotificationChannel.InApp ? PlatformNotificationDeliveryStatus.Sent : PlatformNotificationDeliveryStatus.Pending,
SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null
};
dbContext.PlatformNotificationDeliveries.Add(delivery);
deliveries.Add(delivery);
}
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id,
new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken);
return deliveries.Select(ToItem).ToArray();
}
public async Task<PlatformNotificationDeliveryItem> RetryNotificationAsync(
PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, cancellationToken)
?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found");
if (item.Status is not (PlatformNotificationDeliveryStatus.Failed or PlatformNotificationDeliveryStatus.Pending))
throw Error("Only pending or failed notification delivery can be retried.", "platform_notification_not_retryable");
item.Status = PlatformNotificationDeliveryStatus.Pending;
item.Attempts++;
item.LastError = null;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries", item.Id, new { item.Channel, item.Attempts }, cancellationToken);
return ToItem(item);
}
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)), cancellationToken);
private static void Require(PlatformApprovalActor actor, string permission) { if (!actor.Permissions.Contains(permission)) throw Error("Platform governance access is denied.", "platform_access_denied"); }
private static string Required(string? value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
private static string NormalizeEnvironment(string value) { value = Required(value, "environment").ToLowerInvariant(); return EnvironmentPattern().IsMatch(value) ? value : throw Error("Environment code is invalid.", "platform_configuration_environment_invalid"); }
private static void ValidateValue(PlatformConfigurationDefinition definition, JsonElement? value, string? secretRef)
{
if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef)) throw Error("Sensitive configuration requires a secret reference.", "platform_configuration_secret_ref_required");
if (definition.IsSensitive && value.HasValue && value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object)) throw Error("Sensitive configuration cannot contain a plain value.", "platform_configuration_plain_secret_forbidden");
if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef)) throw Error("Non-sensitive configuration cannot use a secret reference.", "platform_configuration_secret_ref_invalid");
if (!definition.IsSensitive && !value.HasValue) throw Error("Configuration value is required.", "platform_configuration_value_required");
}
private static string Render(string template, IReadOnlyDictionary<string, string> variables) => TokenPattern().Replace(template, match =>
variables.TryGetValue(match.Groups[1].Value, out var value) ? value : throw Error($"Notification variable {match.Groups[1].Value} is missing.", "platform_notification_variable_missing"));
private static PlatformApprovalException Error(string message, string code) => new(message, code);
private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item) => new(item.Id, item.Code, item.Name, item.Category, item.ValueType, item.AllowRuntimeManagement, item.IsSensitive, item.Description, item.ValidationSchema);
private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item) => new(item.Id, item.DefinitionId, item.Environment, item.Version, item.Status, item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy, item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt);
private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item) => new(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate, item.BodyTemplate, item.Enabled, item.Variables, item.UpdatedAt);
private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item) => new(item.Id, item.TemplateId, item.RecipientUserId, item.RecipientRoleCode, item.Channel, item.Status, item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt);
[GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,79}$")]
private static partial Regex EnvironmentPattern();
[GeneratedRegex("\\{\\{([a-zA-Z][a-zA-Z0-9_.-]*)\\}\\}")]
private static partial Regex TokenPattern();
}

View File

@@ -26,6 +26,7 @@
<PackageReference Include="Senparc.Weixin.TenPayV3" />
<PackageReference Include="Senparc.Weixin.WxOpen" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="ZLinq" />
</ItemGroup>
<PropertyGroup>

View File

@@ -14,10 +14,10 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
// Reviewed additions: browser Owner activation completion, primary-domain replacement,
// and platform-issued Owner activation links.
private const int ExpectedActionCount = 447;
private const string ExpectedSha256 = "2ede51a0ea15b90a416ffc4e431604e44cb258e889fe0687707f917b493edcfb";
// Reviewed additions: platform approval, typed configuration, notification governance,
// and operation-level authorization metadata.
private const int ExpectedActionCount = 465;
private const string ExpectedSha256 = "06409c4dca7fc07001d518cf0c4728ff77f4fe94d4151bd63ae64f7f2eb1a2ab";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()

View File

@@ -18,4 +18,21 @@ public sealed class OpenApiDocumentationTests
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/auth/login/password", out _));
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/catalog/regions", out _));
}
[Fact]
public async Task Platform_operation_metadata_prefers_action_permission_and_exposes_risk()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/openapi/v1.json");
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var operation = document.RootElement.GetProperty("paths")
.GetProperty("/api/platform-admin/approvals/{requestId}/approve")
.GetProperty("post");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("platform:approval:decide", operation.GetProperty("x-tiku-required-permission").GetString());
Assert.Equal("high", operation.GetProperty("x-tiku-risk-level").GetString());
}
}

View File

@@ -222,14 +222,17 @@ public sealed class PlatformAdminEndpointTests
versionRequest with { Id = versionId, AmountCents = 50_000 });
var catalog = await client.GetAsync("/api/platform-admin/saas/catalog");
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
var suspended = await client.PatchAsJsonAsync(
"/api/platform-admin/tenants/status",
new UpdatePlatformTenantStatusDto
using var suspendRequest = new HttpRequestMessage(HttpMethod.Patch, "/api/platform-admin/tenants/status")
{
Content = JsonContent.Create(new UpdatePlatformTenantStatusDto
{
TenantId = tenantId,
Status = TenantStatus.Suspended,
Reason = "integration test suspension"
});
})
};
suspendRequest.Headers.Add("Idempotency-Key", $"suspend-{tenantId:N}");
var suspended = await client.SendAsync(suspendRequest);
using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
runtimeRequest.Headers.Host = "six-a.example.test";

View File

@@ -0,0 +1,129 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class PlatformApprovalEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
[Fact]
public async Task Tenant_archive_requires_another_authorized_operator_and_executes_once()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var permissions = new[]
{
BackendPermissions.PlatformDashboardView,
BackendPermissions.PlatformTenantManage,
BackendPermissions.PlatformApprovalView,
BackendPermissions.PlatformApprovalDecide
};
var (requester, approver) = await SeedActorsAsync(factory, permissions);
var tenant = new Tenant { Slug = $"approval-{Guid.NewGuid():N}"[..30], Name = "Approval Tenant", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active };
await factory.SeedAsync(
tenant,
new PlatformApprovalPolicy
{
Code = PlatformApprovalPolicyCodes.TenantArchive,
Name = "Tenant archive",
RequiredPermission = BackendPermissions.PlatformTenantManage,
AlwaysRequireApproval = true
});
using var requesterClient = factory.CreateClient();
requesterClient.UseAccessToken(await requesterClient.LoginAsPlatformAsync(requester.Email));
using var archive = new HttpRequestMessage(HttpMethod.Patch, "/api/platform-admin/tenants/status")
{
Content = JsonContent.Create(new UpdatePlatformTenantStatusDto { TenantId = tenant.Id, Status = TenantStatus.Archived, Reason = "Close expired customer" })
};
archive.Headers.Add("Idempotency-Key", "archive-approval-1");
var submission = await requesterClient.SendAsync(archive);
Assert.Equal(HttpStatusCode.Accepted, submission.StatusCode);
var body = await submission.Content.ReadFromJsonAsync<PlatformCommandSubmission>(JsonOptions);
Assert.NotNull(body?.ApprovalRequest);
var selfApproval = await requesterClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Self approval"));
Assert.Equal(HttpStatusCode.Conflict, selfApproval.StatusCode);
using var approverClient = factory.CreateClient();
approverClient.UseAccessToken(await approverClient.LoginAsPlatformAsync(approver.Email));
var approval = await approverClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Independent verification complete"));
Assert.Equal(HttpStatusCode.OK, approval.StatusCode);
var approved = await approval.Content.ReadFromJsonAsync<PlatformApprovalRequestItem>(JsonOptions);
Assert.Equal(PlatformApprovalRequestStatus.Approved, approved?.Status);
using (var executionScope = factory.CreateSystemScope("Execute approved platform command"))
{
var processor = executionScope.ServiceProvider.GetRequiredService<IPlatformApprovalService>();
Assert.Equal(1, await processor.ProcessApprovedAsync());
}
var replay = await approverClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Replay"));
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
using var scope = factory.CreateSystemScope("Verify platform approval execution");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var executed = await db.PlatformApprovalRequests.AsNoTracking().SingleAsync(item => item.Id == body.ApprovalRequest.Id);
Assert.True(executed.Status == PlatformApprovalRequestStatus.Succeeded, $"Approval execution failed: {executed.Error}");
Assert.Equal(TenantStatus.Archived, await db.Tenants.Where(item => item.Id == tenant.Id).Select(item => item.Status).SingleAsync());
Assert.True(await db.AuditLogs.AnyAsync(item => item.Action == "platform.approval.executed" && item.TargetId == body.ApprovalRequest.Id.ToString("N")));
}
private static async Task<((Guid UserId, string Email) Requester, (Guid UserId, string Email) Approver)> SeedActorsAsync(
ApiTestFactory factory,
IReadOnlyCollection<string> permissionCodes)
{
var requester = (UserId: Guid.NewGuid(), Email: $"approval-requester-{Guid.NewGuid():N}@example.test");
var approver = (UserId: Guid.NewGuid(), Email: $"approval-approver-{Guid.NewGuid():N}@example.test");
var requesterRole = Guid.NewGuid();
var approverRole = Guid.NewGuid();
var modules = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode).Distinct(StringComparer.Ordinal)
.Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Platform, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] });
var permissions = permissionCodes.Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true });
await factory.SeedAsync([
..modules, ..permissions,
User(requester.UserId, requester.Email), User(approver.UserId, approver.Email),
new PlatformBackendRole { Id = requesterRole, Code = $"approval_requester_{requesterRole:N}", Name = "Approval Requester", Status = BackendRoleStatus.Active },
new PlatformBackendRole { Id = approverRole, Code = $"approval_approver_{approverRole:N}", Name = "Approval Approver", Status = BackendRoleStatus.Active },
..permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = requesterRole, PermissionCode = code }),
..permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = approverRole, PermissionCode = code }),
new PlatformBackendUserRole { UserId = requester.UserId, RoleId = requesterRole },
new PlatformBackendUserRole { UserId = approver.UserId, RoleId = approverRole }
]);
return (requester, approver);
}
private static User User(Guid id, string email) => new User
{
Id = id,
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Name = email,
PrimaryRole = "platform_admin",
RawProfile = JsonDefaults.Object()
}.WithTestPassword();
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
}

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
@@ -258,14 +259,22 @@ public sealed class SaasBillingLifecycleTests
var platform = await SeedPlatformAdminAsync(factory);
var platformTenantId = Guid.NewGuid();
var tenantA = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-a");
await factory.SeedAsync(new Tenant
{
Id = platformTenantId,
Slug = $"platform-{platformTenantId:N}",
Name = "Platform Owner",
Mode = TenantMode.PlatformOwned,
Status = TenantStatus.Active
});
await factory.SeedAsync(
new Tenant
{
Id = platformTenantId,
Slug = $"platform-{platformTenantId:N}",
Name = "Platform Owner",
Mode = TenantMode.PlatformOwned,
Status = TenantStatus.Active
},
new PlatformApprovalPolicy
{
Code = PlatformApprovalPolicyCodes.FinancialAdjustment,
Name = "Financial adjustment",
RequiredPermission = BackendPermissions.PlatformSaasBillingManage,
AmountThresholdCents = 5_000_000
});
var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null);
using var tenantClient = factory.CreateClient();
@@ -317,19 +326,22 @@ public sealed class SaasBillingLifecycleTests
using var platformClient = factory.CreateClient();
platformClient.UseAccessToken(await platformClient.LoginAsPlatformAsync(platform.Email));
var confirmationIdempotencyKey = $"manual-confirm-{Guid.NewGuid():N}";
var confirmation = new ConfirmManualPlatformPaymentDto(
paymentId,
$"manual-{Guid.NewGuid():N}",
DateTimeOffset.UtcNow,
"integration test receipt");
platformClient.DefaultRequestHeaders.Add("Idempotency-Key", confirmationIdempotencyKey);
var confirm = await platformClient.PostAsJsonAsync(
"/api/platform-admin/saas/payments/manual/confirm",
confirmation);
var repeatedConfirm = await platformClient.PostAsJsonAsync(
"/api/platform-admin/saas/payments/manual/confirm",
confirmation);
Assert.Equal(HttpStatusCode.OK, confirm.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
platformClient.DefaultRequestHeaders.Remove("Idempotency-Key");
Assert.True(confirm.StatusCode == HttpStatusCode.OK, await confirm.Content.ReadAsStringAsync());
Assert.True(repeatedConfirm.StatusCode == HttpStatusCode.OK, await repeatedConfirm.Content.ReadAsStringAsync());
var subscription = await tenantClient.GetAsync("/api/tenant-billing/subscription");
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
@@ -352,8 +364,8 @@ public sealed class SaasBillingLifecycleTests
var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest);
Assert.Equal(HttpStatusCode.OK, refundResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedRefund.StatusCode);
var refundId = await ReadGuidAsync(refundResponse, "id");
Assert.Equal(refundId, await ReadGuidAsync(repeatedRefund, "id"));
var refundId = await ReadSubmissionResultGuidAsync(refundResponse, "id");
Assert.Equal(refundId, await ReadSubmissionResultGuidAsync(repeatedRefund, "id"));
var approved = await platformClient.PostAsJsonAsync(
$"/api/platform-admin/saas/refunds/{refundId}/approve",
new ReviewPlatformRefundDto("integration test approval"));
@@ -391,7 +403,7 @@ public sealed class SaasBillingLifecycleTests
IdempotencyKey = $"refund-final-{Guid.NewGuid():N}"
});
Assert.Equal(HttpStatusCode.OK, finalRefund.StatusCode);
var finalRefundId = await ReadGuidAsync(finalRefund, "id");
var finalRefundId = await ReadSubmissionResultGuidAsync(finalRefund, "id");
var finalApproved = await platformClient.PostAsJsonAsync(
$"/api/platform-admin/saas/refunds/{finalRefundId}/approve",
new ReviewPlatformRefundDto("integration test final approval"));
@@ -1194,6 +1206,12 @@ public sealed class SaasBillingLifecycleTests
return payload.RootElement.GetProperty(propertyName).GetGuid();
}
private static async Task<Guid> ReadSubmissionResultGuidAsync(HttpResponseMessage response, string propertyName)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.GetProperty("result").GetProperty(propertyName).GetGuid();
}
private static async Task<string> ReadStringAsync(HttpResponseMessage response, string propertyName)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());

View File

@@ -23,13 +23,13 @@ public sealed class BuiltinBackofficeCatalogSeederTests
await seeder.SeedAsync();
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
Assert.Equal(28, await dbContext.PermissionModules.CountAsync());
Assert.Equal(42, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(29, await dbContext.BackendMenus.CountAsync());
Assert.Equal(15, await dbContext.SaasFeatures.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(28, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(42, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(29, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync());
Assert.True(await dbContext.PermissionModules.AnyAsync(item => item.Code == "platform_operations"));
Assert.True(await dbContext.BackendPermissions.AnyAsync(item =>
item.Code == BackendPermissions.PlatformOperationsView &&
@@ -89,9 +89,9 @@ public sealed class BuiltinBackofficeCatalogSeederTests
Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync(
item => item.Code == "tenant.dashboard")).Title);
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
Assert.Equal(28, await dbContext.PermissionModules.CountAsync());
Assert.Equal(42, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(29, await dbContext.BackendMenus.CountAsync());
Assert.False(await dbContext.PermissionModules.AnyAsync(module =>
module.RequiredFeatureCode != null &&
!dbContext.SaasFeatures.Any(feature => feature.Code == module.RequiredFeatureCode)));

View File

@@ -40,6 +40,9 @@ for (const [route, pathItem] of Object.entries(document.paths || {})) {
parameters: [...(pathItem.parameters || []), ...(operation.parameters || [])],
requestSchema,
responseSchema,
requiredPermission: operation['x-tiku-required-permission'] || null,
riskLevel: operation['x-tiku-risk-level'] || (method === 'get' ? 'low' : 'medium'),
approvalPolicyCode: operation['x-tiku-approval-policy-code'] || null,
});
}
}

View File

@@ -12,6 +12,8 @@ export const platformGroups = [
{ key: 'payments', label: '支付设置', description: '平台支付与租户支付应用托管' },
{ key: 'staff', label: '平台权限', description: '平台员工、角色与权限绑定' },
{ key: 'audit', label: '审计与告警', description: '平台审计日志、告警与通知' },
{ key: 'approvals', label: '审批中心', description: '高风险平台命令与审批策略' },
{ key: 'governance', label: '平台治理', description: '类型化配置与平台通知' },
] as const;
export type PlatformGroupKey = (typeof platformGroups)[number]['key'];
@@ -20,6 +22,8 @@ export function groupForOperation(operation: PlatformOperation): PlatformGroupKe
const route = operation.path;
if (route === '/api/platform-admin/overview') return 'overview';
if (route.startsWith('/api/platform-admin/operations')) return 'operations';
if (route.startsWith('/api/platform-admin/approvals')) return 'approvals';
if (route.startsWith('/api/platform-admin/governance')) return 'governance';
if (route.startsWith('/api/platform-admin/tenants') || route.startsWith('/api/platform-admin/domains')) return 'tenants';
if (route.startsWith('/api/platform-admin/question-banks')) return 'question-banks';
if (route.startsWith('/api/platform-admin/tenant-capabilities/crm')) return 'crm';

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { pendingApproval } from './platform-command';
describe('平台命令提交结果', () => {
it('识别需要四眼审批的 202 业务响应', () => {
expect(pendingApproval({ executionStatus: 'pending_approval', approvalRequest: { id: 'approval-1' } }))
.toMatchObject({ approvalRequest: { id: 'approval-1' } });
});
it('即时执行结果不进入审批提示', () => {
expect(pendingApproval({ executionStatus: 'executed', result: {} })).toBeNull();
});
});

View File

@@ -0,0 +1,12 @@
export interface PlatformCommandSubmissionLike {
executionStatus?: unknown;
approvalRequest?: { id?: unknown; requestNo?: unknown } | null;
}
export function pendingApproval(result: unknown): PlatformCommandSubmissionLike | null {
if (!result || typeof result !== 'object') return null;
const submission = result as PlatformCommandSubmissionLike;
return submission.executionStatus === 'pending_approval' && submission.approvalRequest
? submission
: null;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,9 @@ export interface PlatformOperation {
parameters: readonly OpenApiParameter[];
requestSchema: JsonSchema | null;
responseSchema: JsonSchema | null;
requiredPermission: string | null;
riskLevel: 'low' | 'medium' | 'high' | 'critical';
approvalPolicyCode: string | null;
}
export type OperationInput = Record<string, unknown>;

View File

@@ -2,14 +2,18 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, t
import { authRequest, getCurrentUser } from '../api/http';
import { tokenStore, type TokenPair } from '../api/token-store';
import type { components } from '../api/schema.generated';
import { platformRequest } from '../api/platform';
type AuthenticationResult = components['schemas']['AuthenticationResultDto'];
type CurrentUser = components['schemas']['MeResponse'];
type UiBootstrap = components['schemas']['BackofficeUiBootstrap'];
const platformRealm = 1;
interface AuthContextValue {
loading: boolean;
user: CurrentUser | null;
bootstrap: UiBootstrap | null;
hasPermission(...permissions: string[]): boolean;
challengeToken: string | null;
login(identifier: string, password: string): Promise<void>;
completePasswordChange(password: string): Promise<void>;
@@ -26,19 +30,27 @@ function tokensFrom(result: AuthenticationResult): TokenPair | null {
export function AuthProvider({ children }: PropsWithChildren) {
const [loading, setLoading] = useState(Boolean(tokenStore.get()));
const [user, setUser] = useState<CurrentUser | null>(null);
const [bootstrap, setBootstrap] = useState<UiBootstrap | null>(null);
const [challengeToken, setChallengeToken] = useState<string | null>(null);
const loadUser = useCallback(async () => {
if (!tokenStore.get()) {
setUser(null);
setBootstrap(null);
setLoading(false);
return;
}
try {
setUser(await getCurrentUser<CurrentUser>());
const [currentUser, uiBootstrap] = await Promise.all([
getCurrentUser<CurrentUser>(),
platformRequest<UiBootstrap>('GET', '/api/backoffice/platform/ui-bootstrap'),
]);
setUser(currentUser);
setBootstrap(uiBootstrap);
} catch {
tokenStore.set(null);
setUser(null);
setBootstrap(null);
} finally {
setLoading(false);
}
@@ -65,6 +77,12 @@ export function AuthProvider({ children }: PropsWithChildren) {
const value = useMemo<AuthContextValue>(() => ({
loading,
user,
bootstrap,
hasPermission(...permissions) {
if (permissions.length === 0) return true;
const granted = new Set(bootstrap?.permissionCodes || []);
return permissions.some((permission) => granted.has(permission));
},
challengeToken,
async login(identifier, password) {
const result = await authRequest<AuthenticationResult>('/api/auth/login/password', {
@@ -90,11 +108,12 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (refreshToken) await authRequest('/api/auth/logout', { refreshToken });
} finally {
tokenStore.set(null);
setUser(null);
setUser(null);
setBootstrap(null);
setChallengeToken(null);
}
},
}), [applyAuthentication, challengeToken, loading, user]);
}), [applyAuthentication, bootstrap, challengeToken, loading, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router';
import { useAuth } from './AuthProvider';
export function RequirePermission({ anyOf, children }: { anyOf: readonly string[]; children: ReactNode }) {
const auth = useAuth();
const location = useLocation();
return auth.hasPermission(...anyOf)
? children
: <Navigate to="/forbidden" replace state={{ from: location.pathname }} />;
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import type { components } from '../api/schema.generated';
import { visibleNavigationPaths } from './AppLayout';
type BackofficeMenuItem = components['schemas']['BackofficeMenuItem'];
function menu(path: string, permissionCode: string, isActive = true): BackofficeMenuItem {
return {
id: crypto.randomUUID(),
code: `menu.${path}`,
parentCode: null,
title: path,
area: 0,
path,
icon: null,
permissionCode,
sortOrder: 10,
isActive,
};
}
describe('平台导航授权', () => {
it('同时以 ui-bootstrap 菜单和权限作为可见性边界', () => {
const granted = new Set(['platform:dashboard:view', 'platform:tenant:manage', 'platform:audit:view']);
const paths = visibleNavigationPaths([
menu('/', 'platform:dashboard:view'),
menu('/tenants', 'platform:tenant:manage'),
menu('/audit', 'platform:audit:view', false),
], (...permissions) => permissions.some((permission) => granted.has(permission)));
expect(paths).toEqual(['/', '/tenants']);
});
it('数据库未返回的菜单不会因本地路由定义而出现', () => {
const paths = visibleNavigationPaths(
[menu('/', 'platform:dashboard:view')],
() => true,
);
expect(paths).toEqual(['/']);
});
});

View File

@@ -14,43 +14,86 @@ import {
MessageOutlined,
PieChartOutlined,
ShopOutlined,
SafetyCertificateOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Avatar, Button, Dropdown, Flex, Layout, Menu, Space, Spin, Typography } from 'antd';
import type { MenuProps } from 'antd';
import { useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router';
import type { components } from '../api/schema.generated';
import { useAuth } from '../auth/AuthProvider';
const { Header, Sider, Content } = Layout;
const items = [
{ key: '/', icon: <DashboardOutlined />, label: '经营概览' },
const navigation = [
{ key: '/', icon: <DashboardOutlined />, label: '经营概览', permission: 'platform:dashboard:view' },
{ type: 'group' as const, label: '业务运营', children: [
{ key: '/tenants', icon: <ApartmentOutlined />, label: '租户管理' },
{ key: '/subscriptions', icon: <ShopOutlined />, label: '订阅与应收' },
{ key: '/usage', icon: <PieChartOutlined />, label: '用量计费' },
{ key: '/dunning', icon: <FileTextOutlined />, label: '收款与催缴' },
{ key: '/refunds', icon: <BankOutlined />, label: '退款处理' },
{ key: '/tenants', icon: <ApartmentOutlined />, label: '租户管理', permissions: ['platform:tenant:manage'] },
{ key: '/subscriptions', icon: <ShopOutlined />, label: '订阅与应收', permissions: ['platform:saas-catalog:manage', 'platform:saas-billing:manage'] },
{ key: '/usage', icon: <PieChartOutlined />, label: '用量计费', permissions: ['platform:saas-billing:manage'] },
{ key: '/dunning', icon: <FileTextOutlined />, label: '收款与催缴', permissions: ['platform:billing:notification', 'platform:saas-billing:manage'] },
{ key: '/refunds', icon: <BankOutlined />, label: '退款处理', permissions: ['platform:saas-billing:manage'] },
] },
{ type: 'group' as const, label: '内容资产', children: [
{ key: '/question-banks', icon: <BookOutlined />, label: '公共题库' },
{ key: '/question-banks', icon: <BookOutlined />, label: '公共题库', permissions: ['platform:question-bank:manage'] },
] },
{ type: 'group' as const, label: '平台服务', children: [
{ key: '/crm', icon: <ContactsOutlined />, label: 'CRM 服务' },
{ key: '/sms', icon: <MessageOutlined />, label: '短信服务' },
{ key: '/payments', icon: <CreditCardOutlined />, label: '支付服务' },
{ key: '/crm', icon: <ContactsOutlined />, label: 'CRM 服务', permissions: ['platform:crm:read'] },
{ key: '/sms', icon: <MessageOutlined />, label: '短信服务', permissions: ['platform:sms:read'] },
{ key: '/payments', icon: <CreditCardOutlined />, label: '支付服务', permissions: ['platform:payment:read'] },
] },
{ type: 'group' as const, label: '安全与系统', children: [
{ key: '/staff', icon: <UserSwitchOutlined />, label: '员工与角色' },
{ key: '/audit', icon: <AuditOutlined />, label: '审计日志' },
{ key: '/alerts', icon: <AlertOutlined />, label: '审计告警' },
{ key: '/operations', icon: <DashboardOutlined />, label: '运行中心' },
{ key: '/staff', icon: <UserSwitchOutlined />, label: '员工与角色', permissions: ['platform:staff:manage', 'platform:role:manage'] },
{ key: '/audit', icon: <AuditOutlined />, label: '审计日志', permissions: ['platform:audit:view'] },
{ key: '/alerts', icon: <AlertOutlined />, label: '审计告警', permissions: ['platform:audit:view'] },
{ key: '/operations', icon: <DashboardOutlined />, label: '运行中心', permissions: ['platform:operations:view'] },
{ key: '/approvals', icon: <SafetyCertificateOutlined />, label: '审批中心', permissions: ['platform:approval:view'] },
{ key: '/configuration', icon: <ApartmentOutlined />, label: '配置中心', permissions: ['platform:configuration:manage'] },
{ key: '/notifications', icon: <MessageOutlined />, label: '通知中心', permissions: ['platform:notification:manage'] },
] },
];
type BackofficeMenuItem = components['schemas']['BackofficeMenuItem'];
export function visibleNavigationPaths(
menus: readonly BackofficeMenuItem[],
hasPermission: (...permissions: string[]) => boolean,
): string[] {
const enabledPaths = new Set(menus.filter((menu) => menu.isActive && menu.path).map((menu) => menu.path as string));
return navigation.flatMap((item) => {
if ('permission' in item && item.permission && item.key) {
return enabledPaths.has(item.key) && hasPermission(item.permission) ? [item.key] : [];
}
return (item.children || [])
.filter((child) => enabledPaths.has(child.key) && hasPermission(...child.permissions))
.map((child) => child.key);
});
}
function visibleNavigation(
menus: readonly BackofficeMenuItem[],
hasPermission: (...permissions: string[]) => boolean,
): MenuProps['items'] {
const items: NonNullable<MenuProps['items']> = [];
const visiblePaths = new Set(visibleNavigationPaths(menus, hasPermission));
const menuByPath = new Map(menus.filter((menu) => menu.isActive && menu.path).map((menu) => [menu.path as string, menu]));
for (const item of navigation) {
if ('permission' in item && item.permission && item.key) {
if (visiblePaths.has(item.key)) items.push({ key: item.key, icon: item.icon, label: menuByPath.get(item.key)?.title || item.label });
continue;
}
if (!item.children) continue;
const children = item.children.filter((child) => visiblePaths.has(child.key))
.map(({ permissions: _, ...child }) => ({ ...child, label: menuByPath.get(child.key)?.title || child.label }));
if (children.length > 0) items.push({ type: 'group', label: item.label, children });
}
return items;
}
const pageTitles: Record<string, string> = {
'/': '经营概览', '/tenants': '租户开通', '/subscriptions': '订阅与应收', '/billing': '订阅与应收',
'/usage': '用量计费', '/dunning': '收款与催缴', '/refunds': '退款处理', '/question-banks': '公共题库', '/crm': 'CRM 服务',
'/sms': '短信服务', '/payments': '支付服务', '/staff': '员工与角色', '/audit': '审计日志', '/alerts': '审计告警', '/operations': '运行中心',
'/sms': '短信服务', '/payments': '支付服务', '/staff': '员工与角色', '/audit': '审计日志', '/alerts': '审计告警', '/operations': '运行中心', '/approvals': '审批中心', '/configuration': '配置中心', '/notifications': '通知中心',
};
export function AppLayout() {
@@ -68,7 +111,7 @@ export function AppLayout() {
<div className="brand-mark"><img src="/logo.png" alt="Logo" /></div>
{!collapsed && <div className="brand-copy"><strong></strong><span></span></div>}
</div>
<div className="sider-navigation"><Menu theme="light" mode="inline" selectedKeys={[location.pathname]} items={items} onClick={({ key }) => navigate(key)} /></div>
<div className="sider-navigation"><Menu theme="light" mode="inline" selectedKeys={[location.pathname]} items={visibleNavigation(auth.bootstrap?.menus || [], auth.hasPermission)} onClick={({ key }) => navigate(key)} /></div>
<div className="sider-footer">
<Button type="text" block icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsed((value) => !value)}>{collapsed ? null : '收起导航'}</Button>
</div>

View File

@@ -0,0 +1,126 @@
import { CheckOutlined, CloseOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Descriptions, Drawer, Flex, Form, Input, Space, Table, Tabs, Tag, Typography } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { components } from '../api/schema.generated';
import { platformRequest } from '../api/platform';
import { useAuth } from '../auth/AuthProvider';
type Approval = components['schemas']['PlatformApprovalRequestItem'];
type Policy = components['schemas']['PlatformApprovalPolicyItem'];
const statusLabels: Record<string, string> = {
'0': '待审批', '1': '已批准', '2': '已拒绝', '3': '执行中', '4': '成功', '5': '失败', '6': '已撤销', '7': '已过期',
Pending: '待审批', Approved: '已批准', Rejected: '已拒绝', Executing: '执行中', Succeeded: '成功', Failed: '失败', Cancelled: '已撤销', Expired: '已过期',
};
function statusColor(status: unknown) {
const value = String(status);
if (['4', 'Succeeded'].includes(value)) return 'green';
if (['5', 'Failed', '2', 'Rejected'].includes(value)) return 'red';
if (['0', 'Pending'].includes(value)) return 'gold';
if (['3', 'Executing'].includes(value)) return 'blue';
return 'default';
}
export function ApprovalCenterPage() {
const auth = useAuth();
const { message, modal } = App.useApp();
const [items, setItems] = useState<Approval[]>([]);
const [policies, setPolicies] = useState<Policy[]>([]);
const [selected, setSelected] = useState<Approval | null>(null);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<{ reason: string }>();
const load = useCallback(async () => {
setLoading(true);
try {
const [requests, policyItems] = await Promise.all([
platformRequest<Approval[]>('GET', '/api/platform-admin/approvals', { query: { limit: 200 } }),
platformRequest<Policy[]>('GET', '/api/platform-admin/approvals/policies'),
]);
setItems(requests);
setPolicies(policyItems);
setSelected((current) => current ? requests.find((item) => item.id === current.id) ?? null : null);
} catch (error) {
message.error(error instanceof Error ? error.message : '审批中心加载失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => { void load(); }, [load]);
const decide = async (action: 'approve' | 'reject' | 'cancel') => {
if (!selected) return;
const { reason } = await form.validateFields();
modal.confirm({
title: action === 'approve' ? '确认批准并执行' : action === 'reject' ? '确认拒绝' : '确认撤销',
content: action === 'approve' ? '批准时会重新校验权限和目标状态,成功后立即执行。' : '该决定会写入不可省略的平台审计日志。',
okButtonProps: { danger: action !== 'approve' },
onOk: async () => {
setLoading(true);
try {
const result = await platformRequest<Approval>('POST', `/api/platform-admin/approvals/{requestId}/${action}`, {
path: { requestId: selected.id }, body: { reason },
});
setSelected(result);
form.resetFields();
message.success(action === 'approve' ? '审批已批准,等待 Worker 执行' : '审批状态已更新');
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : '审批操作失败');
} finally {
setLoading(false);
}
},
});
};
const columns = useMemo(() => [
{ title: '审批单', dataIndex: 'requestNo', key: 'requestNo' },
{ title: '策略', dataIndex: 'policyCode', key: 'policyCode' },
{ title: '目标', key: 'target', render: (_: unknown, row: Approval) => `${row.targetType} / ${row.targetId}` },
{ title: '金额', dataIndex: 'amountCents', key: 'amount', render: (value: unknown) => value == null ? '-' : `¥${(Number(value) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}` },
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: unknown) => <Tag color={statusColor(value)}>{statusLabels[String(value)] ?? String(value)}</Tag> },
{ title: '提交时间', dataIndex: 'createdAt', key: 'createdAt', render: (value: string) => new Date(value).toLocaleString('zh-CN') },
], []);
const pending = selected && ['0', 'Pending'].includes(String(selected.status));
const canDecide = pending && selected.requestedBy !== auth.user?.userId && auth.hasPermission('platform:approval:decide');
const canCancel = pending && selected.requestedBy === auth.user?.userId;
return (
<div className="business-page">
<Flex justify="space-between" align="end" gap={16} wrap>
<div><Typography.Text type="secondary"></Typography.Text><Typography.Title level={2}></Typography.Title><Typography.Paragraph type="secondary"></Typography.Paragraph></div>
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void load()}></Button>
</Flex>
<Tabs items={[
{ key: 'requests', label: '审批任务', children: <Card><Table rowKey="id" loading={loading} dataSource={items} columns={columns} pagination={{ pageSize: 20 }} onRow={(record) => ({ onClick: () => { setSelected(record); form.resetFields(); } })} /></Card> },
{ key: 'policies', label: '审批策略', children: <Card><Table rowKey="id" dataSource={policies} pagination={false} columns={[
{ title: '策略', dataIndex: 'name' }, { title: '编码', dataIndex: 'code' },
{ title: '规则', render: (_: unknown, row: Policy) => row.alwaysRequireApproval ? '始终审批' : row.amountThresholdCents ? `达到 ¥${(Number(row.amountThresholdCents) / 100).toLocaleString('zh-CN')}` : '即时执行' },
{ title: '版本', dataIndex: 'version' }, { title: '有效期', dataIndex: 'expiresAfterHours', render: (value: unknown) => `${String(value)} 小时` },
]} /></Card> },
]} />
<Drawer title={selected?.requestNo} width={680} open={Boolean(selected)} onClose={() => setSelected(null)}>
{selected && <Space direction="vertical" size={16} style={{ width: '100%' }}>
{selected.error && <Alert type="error" showIcon message="执行失败" description={selected.error} />}
<Descriptions bordered size="small" column={1} items={[
{ key: 'status', label: '状态', children: <Tag color={statusColor(selected.status)}>{statusLabels[String(selected.status)] ?? String(selected.status)}</Tag> },
{ key: 'command', label: '命令', children: selected.commandType },
{ key: 'target', label: '目标', children: `${selected.targetType} / ${selected.targetId}` },
{ key: 'reason', label: '申请原因', children: selected.requestReason || '-' },
{ key: 'expires', label: '审批期限', children: new Date(selected.expiresAt).toLocaleString('zh-CN') },
{ key: 'snapshot', label: '脱敏快照', children: <pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(selected.requestSnapshot, null, 2)}</pre> },
]} />
{(canDecide || canCancel) && <Form form={form} layout="vertical"><Form.Item name="reason" label="决定原因" rules={[{ required: true, min: 3 }]}><Input.TextArea rows={3} /></Form.Item></Form>}
<Flex justify="end" gap={8}>
{canCancel && <Button danger icon={<StopOutlined />} onClick={() => void decide('cancel')}></Button>}
{canDecide && <><Button danger icon={<CloseOutlined />} onClick={() => void decide('reject')}></Button><Button type="primary" icon={<CheckOutlined />} onClick={() => void decide('approve')}></Button></>}
</Flex>
</Space>}
</Drawer>
</div>
);
}

View File

@@ -2,11 +2,13 @@ import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { App, Button, Card, Drawer, Empty, Flex, Form, Segmented, Space, Tabs, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { apiRequest } from '../api/http';
import { pendingApproval } from '../api/platform-command';
import { platformOperations } from '../api/platform-operations.generated';
import type { OperationInput, PlatformOperation } from '../api/types';
import { BusinessTable } from '../components/BusinessTable';
import { OperationForm, normalizeOperationInput } from '../components/OperationForm';
import { businessPages } from './business-definitions';
import { useAuth } from '../auth/AuthProvider';
function prefillFromRecord(operation: PlatformOperation, record: Record<string, unknown> | null): OperationInput {
if (!record) return {};
@@ -20,7 +22,9 @@ function prefillFromRecord(operation: PlatformOperation, record: Record<string,
export function BusinessPage({ pageKey }: { pageKey: string }) {
const definition = businessPages.find((page) => page.key === pageKey)!;
const operations = useMemo(() => platformOperations.filter(definition.matches), [definition]);
const auth = useAuth();
const operations = useMemo(() => platformOperations.filter((operation) =>
definition.matches(operation) && (!operation.requiredPermission || auth.hasPermission(operation.requiredPermission))), [auth, definition]);
const reads = operations.filter((operation) => operation.method === 'GET');
const writes = operations.filter((operation) => operation.method !== 'GET');
const [activeReadId, setActiveReadId] = useState(reads[0]?.id || '');
@@ -58,7 +62,14 @@ export function BusinessPage({ pageKey }: { pageKey: string }) {
okText: '确认执行', cancelText: '取消',
onOk: async () => {
setLoading(true);
try { await apiRequest(drawerOperation, values); message.success(`${drawerOperation.summary}完成`); setDrawerOperation(null); await runRead(); }
try {
const result = await apiRequest(drawerOperation, values);
const approval = pendingApproval(result);
if (approval) message.info(`已提交审批${approval.approvalRequest?.requestNo ? `${String(approval.approvalRequest.requestNo)}` : ''},尚未执行`);
else message.success(`${drawerOperation.summary}完成`);
setDrawerOperation(null);
await runRead();
}
catch (error) { message.error(error instanceof Error ? error.message : '操作失败'); }
finally { setLoading(false); }
},

View File

@@ -3,11 +3,13 @@ import { Alert, App, Button, Card, Col, Descriptions, Drawer, Empty, Flex, Form,
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { apiRequest } from '../api/http';
import { pendingApproval } from '../api/platform-command';
import { platformOperations } from '../api/platform-operations.generated';
import type { OperationInput, PlatformOperation } from '../api/types';
import { BusinessTable, rowsFromPayload } from '../components/BusinessTable';
import { normalizeOperationInput, OperationForm } from '../components/OperationForm';
import { TenantOnboardingWorkbench } from './TenantOnboardingWorkbench';
import { useAuth } from '../auth/AuthProvider';
type WorkbenchKey = 'onboarding' | 'receivables' | 'dunning' | 'refunds';
@@ -67,8 +69,10 @@ function statusTimeline(record: Record<string, unknown> | null) {
}
function GenericCommercialWorkbenchPage({ workbenchKey }: { workbenchKey: Exclude<WorkbenchKey, 'onboarding'> }) {
const auth = useAuth();
const definition = workbenches[workbenchKey];
const operations = useMemo(() => platformOperations.filter(definition.matches), [definition]);
const operations = useMemo(() => platformOperations.filter((operation) =>
definition.matches(operation) && (!operation.requiredPermission || auth.hasPermission(operation.requiredPermission))), [auth, definition]);
const reads = operations.filter((operation) => operation.method === 'GET');
const writes = operations.filter((operation) => operation.method !== 'GET');
const [activeReadId, setActiveReadId] = useState(reads.find((item) => item.id === definition.preferredRead)?.id || reads[0]?.id || '');
@@ -119,7 +123,9 @@ function GenericCommercialWorkbenchPage({ workbenchKey }: { workbenchKey: Exclud
setLoading(true);
try {
const result = await apiRequest(action, input);
message.success(`${action.summary}完成`);
const approval = pendingApproval(result);
if (approval) message.info(`高风险操作已提交审批${approval.approvalRequest?.requestNo ? `${String(approval.approvalRequest.requestNo)}` : ''},批准后才会执行`);
else message.success(`${action.summary}完成`);
setAction(null);
await load();
} catch (error) {

View File

@@ -0,0 +1,7 @@
import { Button, Result } from 'antd';
import { useNavigate } from 'react-router';
export function ForbiddenPage() {
const navigate = useNavigate();
return <Result status="403" title="无权访问" subTitle="当前平台岗位没有此工作台权限。" extra={<Button type="primary" onClick={() => navigate('/')}></Button>} />;
}

View File

@@ -0,0 +1,88 @@
import { App, Button, Card, Drawer, Flex, Form, Input, InputNumber, Select, Space, Switch, Table, Tag, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { platformRequest } from '../api/platform';
interface Definition { id: string; code: string; name: string; category: string; valueType: string | number; allowRuntimeManagement: boolean; isSensitive: boolean; description?: string }
interface Version { id: string; definitionId: string; environment: string; version: number; status: string | number; value?: unknown; secretRef?: string; reason: string; publishedAt?: string; createdAt: string }
interface Template { id: string; code: string; name: string; channel: string | number; subjectTemplate: string; bodyTemplate: string; enabled: boolean; variables: unknown }
interface Delivery { id: string; templateId: string; recipientRoleCode: string; channel: string | number; status: string | number; subject: string; attempts: number; lastError?: string; createdAt: string }
interface Paged<T> { items: T[]; total: number; page: number; pageSize: number }
const statusName: Record<string, string> = { '0': '草稿', '1': '已发布', '2': '已归档', Draft: '草稿', Published: '已发布', Retired: '已归档', Pending: '待发送', Processing: '发送中', Sent: '已发送', Failed: '失败', Cancelled: '已取消', '3': '失败', '4': '已取消' };
export function ConfigurationCenterPage() {
const { message, modal } = App.useApp();
const [definitions, setDefinitions] = useState<Definition[]>([]);
const [selected, setSelected] = useState<Definition>();
const [versions, setVersions] = useState<Version[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<{ environment: string; value?: string; secretRef?: string; reason: string }>();
const loadDefinitions = useCallback(async () => {
const values = await platformRequest<Definition[]>('GET', '/api/platform-admin/governance/configuration/definitions');
setDefinitions(values); setSelected((current) => current ?? values[0]);
}, []);
const loadVersions = useCallback(async (definition?: Definition) => {
if (!definition) return setVersions([]);
setVersions(await platformRequest<Version[]>('GET', '/api/platform-admin/governance/configuration/versions', { query: { definitionCode: definition.code } }));
}, []);
useEffect(() => { setLoading(true); loadDefinitions().catch((error) => message.error(error instanceof Error ? error.message : '配置定义加载失败')).finally(() => setLoading(false)); }, [loadDefinitions, message]);
useEffect(() => { void loadVersions(selected); }, [loadVersions, selected]);
const saveDraft = async () => {
if (!selected) return;
const values = await form.validateFields();
let parsed: unknown;
if (!selected.isSensitive) {
try { parsed = selected.valueType === 'Json' || String(selected.valueType) === '3' ? JSON.parse(values.value || '{}') : values.value; }
catch { message.error('配置值不是合法 JSON'); return; }
}
setLoading(true);
try {
await platformRequest('POST', '/api/platform-admin/governance/configuration/drafts', { body: { definitionCode: selected.code, environment: values.environment, value: parsed, secretRef: values.secretRef, reason: values.reason } });
setOpen(false); form.resetFields(); message.success('配置草稿已保存'); await loadVersions(selected);
} catch (error) { message.error(error instanceof Error ? error.message : '配置草稿保存失败'); }
finally { setLoading(false); }
};
const transition = (version: Version, action: 'publish' | 'rollback') => modal.confirm({
title: action === 'publish' ? `发布 v${version.version}` : `回滚到 v${version.version}`,
content: '变更会保留完整版本和平台审计记录。',
onOk: async () => {
const body = action === 'rollback' ? { reason: `回滚到 v${version.version}` } : undefined;
await platformRequest('POST', `/api/platform-admin/governance/configuration/versions/{versionId}/${action}`, { path: { versionId: version.id }, body });
message.success(action === 'publish' ? '配置已发布' : '配置已回滚'); await loadVersions(selected);
},
});
return <div className="business-page"><Flex justify="space-between" align="end"><div><Typography.Text type="secondary"></Typography.Text><Typography.Title level={2}></Typography.Title><Typography.Paragraph type="secondary">线</Typography.Paragraph></div>{selected?.allowRuntimeManagement && <Button type="primary" onClick={() => { form.setFieldsValue({ environment: 'all' }); setOpen(true); }}>稿</Button>}</Flex>
<Flex gap={16} align="start"><Card title="配置定义" style={{ width: 360 }}><Table size="small" rowKey="id" pagination={false} dataSource={definitions} columns={[{ title: '名称', dataIndex: 'name' }, { title: '分类', dataIndex: 'category' }]} onRow={(record) => ({ onClick: () => setSelected(record) })} /></Card>
<Card title={selected ? `${selected.name} · ${selected.code}` : '版本'} style={{ flex: 1 }}><Table rowKey="id" loading={loading} dataSource={versions} columns={[{ title: '环境', dataIndex: 'environment' }, { title: '版本', dataIndex: 'version' }, { title: '状态', dataIndex: 'status', render: (value) => <Tag>{statusName[String(value)] ?? String(value)}</Tag> }, { title: '原因', dataIndex: 'reason' }, { title: '操作', render: (_, row: Version) => <Space>{['0', 'Draft'].includes(String(row.status)) && <Button size="small" onClick={() => transition(row, 'publish')}></Button>}<Button size="small" onClick={() => transition(row, 'rollback')}></Button></Space> }]} /></Card></Flex>
<Drawer title="新建配置草稿" width={560} open={open} onClose={() => setOpen(false)} footer={<Flex justify="end" gap={8}><Button onClick={() => setOpen(false)}></Button><Button type="primary" loading={loading} onClick={() => void saveDraft()}>稿</Button></Flex>}><Form form={form} layout="vertical"><Form.Item name="environment" label="环境" rules={[{ required: true }]}><Input /></Form.Item>{selected?.isSensitive ? <Form.Item name="secretRef" label="密钥引用" rules={[{ required: true }]}><Input placeholder="vault://..." /></Form.Item> : <Form.Item name="value" label="配置值" rules={[{ required: true }]}><Input.TextArea rows={8} /></Form.Item>}<Form.Item name="reason" label="变更原因" rules={[{ required: true, min: 3 }]}><Input.TextArea rows={3} /></Form.Item></Form></Drawer>
</div>;
}
export function NotificationCenterPage() {
const { message } = App.useApp();
const [templates, setTemplates] = useState<Template[]>([]);
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
const [templateOpen, setTemplateOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
const [templateForm] = Form.useForm();
const [sendForm] = Form.useForm();
const load = useCallback(async () => {
const [templateItems, deliveryPage] = await Promise.all([
platformRequest<Template[]>('GET', '/api/platform-admin/governance/notifications/templates'),
platformRequest<Paged<Delivery>>('GET', '/api/platform-admin/governance/notifications/deliveries', { query: { page: 1, pageSize: 100 } }),
]); setTemplates(templateItems); setDeliveries(deliveryPage.items);
}, []);
useEffect(() => { load().catch((error) => message.error(error instanceof Error ? error.message : '通知中心加载失败')); }, [load, message]);
const saveTemplate = async () => { const values = await templateForm.validateFields(); await platformRequest('PUT', '/api/platform-admin/governance/notifications/templates', { body: { ...values, variables: [] } }); setTemplateOpen(false); templateForm.resetFields(); message.success('模板已保存'); await load(); };
const send = async () => { const values = await sendForm.validateFields(); let variables = {}; try { variables = JSON.parse(values.variables || '{}'); } catch { return message.error('变量必须是 JSON 对象'); } await platformRequest('POST', '/api/platform-admin/governance/notifications/send', { body: { templateId: values.templateId, roleCodes: values.roleCodes.split(',').map((item: string) => item.trim()).filter(Boolean), variables, idempotencyKey: crypto.randomUUID() } }); setSendOpen(false); sendForm.resetFields(); message.success('通知投递已创建'); await load(); };
const retry = async (id: string) => { await platformRequest('POST', '/api/platform-admin/governance/notifications/deliveries/{deliveryId}/retry', { path: { deliveryId: id } }); message.success('已重新入队'); await load(); };
return <div className="business-page"><Flex justify="space-between" align="end"><div><Typography.Text type="secondary"></Typography.Text><Typography.Title level={2}></Typography.Title><Typography.Paragraph type="secondary"></Typography.Paragraph></div><Space><Button onClick={() => setTemplateOpen(true)}></Button><Button type="primary" onClick={() => setSendOpen(true)}></Button></Space></Flex>
<Card title="通知模板"><Table rowKey="id" dataSource={templates} pagination={false} columns={[{ title: '名称', dataIndex: 'name' }, { title: '编码', dataIndex: 'code' }, { title: '渠道', dataIndex: 'channel' }, { title: '启用', dataIndex: 'enabled', render: (value) => <Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag> }]} /></Card>
<Card title="投递记录" style={{ marginTop: 16 }}><Table rowKey="id" dataSource={deliveries} columns={[{ title: '主题', dataIndex: 'subject' }, { title: '岗位', dataIndex: 'recipientRoleCode' }, { title: '渠道', dataIndex: 'channel' }, { title: '状态', dataIndex: 'status', render: (value) => <Tag>{statusName[String(value)] ?? String(value)}</Tag> }, { title: '尝试', dataIndex: 'attempts' }, { title: '错误', dataIndex: 'lastError' }, { title: '操作', render: (_, row: Delivery) => ['0', '3', 'Pending', 'Failed'].includes(String(row.status)) ? <Button size="small" onClick={() => void retry(row.id)}></Button> : null }]} /></Card>
<Drawer title="通知模板" width={560} open={templateOpen} onClose={() => setTemplateOpen(false)} footer={<Button type="primary" onClick={() => void saveTemplate()}></Button>}><Form form={templateForm} layout="vertical" initialValues={{ channel: 'InApp', enabled: true }}><Form.Item name="code" label="编码" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="channel" label="渠道"><Select options={['InApp', 'Sms', 'Email'].map(value => ({ value, label: value }))} /></Form.Item><Form.Item name="subjectTemplate" label="主题模板" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="bodyTemplate" label="正文模板" rules={[{ required: true }]}><Input.TextArea rows={6} /></Form.Item><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Form></Drawer>
<Drawer title="发送平台通知" width={560} open={sendOpen} onClose={() => setSendOpen(false)} footer={<Button type="primary" onClick={() => void send()}></Button>}><Form form={sendForm} layout="vertical"><Form.Item name="templateId" label="模板" rules={[{ required: true }]}><Select options={templates.map(item => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="roleCodes" label="目标岗位编码(逗号分隔)" rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="variables" label="模板变量 JSON"><Input.TextArea rows={6} placeholder='{"name":"示例"}' /></Form.Item></Form></Drawer>
</div>;
}

View File

@@ -24,6 +24,8 @@ export const businessPages: readonly BusinessPageDefinition[] = [
{ key: 'audit', eyebrow: '安全治理', title: '平台审计日志', description: '检索跨租户敏感操作、账务变更与权限事件。', matches: (op) => starts(op, '/api/platform-admin/audit-logs') },
{ key: 'alerts', eyebrow: '安全治理', title: '审计告警与处理', description: '按开放、确认、解决或忽略状态处理平台安全告警。', matches: (op) => starts(op, '/api/platform-admin/audit-alerts') },
{ key: 'operations', eyebrow: '运行治理', title: '任务与 Worker 运行中心', description: '查看依赖健康、Worker 心跳、后台任务积压,并处理失败任务。', matches: (op) => starts(op, '/api/platform-admin/operations') },
{ key: 'approvals', eyebrow: '安全治理', title: '平台审批中心', description: '处理分级四眼审批任务并维护版本化审批策略。', matches: (op) => starts(op, '/api/platform-admin/approvals') },
{ key: 'governance', eyebrow: '平台治理', title: '配置与通知中心', description: '发布类型化配置并管理岗位通知模板和投递记录。', matches: (op) => starts(op, '/api/platform-admin/governance') },
];
export function businessPageForOperation(operation: PlatformOperation) {

View File

@@ -1,10 +1,18 @@
import { createBrowserRouter, Navigate } from 'react-router';
import type { ReactNode } from 'react';
import { AppLayout } from './layout/AppLayout';
import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from './pages/LoginPage';
import { BusinessPage } from './pages/BusinessPage';
import { QuestionBankPage } from './pages/QuestionBankPage';
import { CommercialWorkbenchPage } from './pages/CommercialWorkbenchPage';
import { RequirePermission } from './auth/RequirePermission';
import { ForbiddenPage } from './pages/ForbiddenPage';
import { ApprovalCenterPage } from './pages/ApprovalCenterPage';
import { ConfigurationCenterPage, NotificationCenterPage } from './pages/GovernanceCenterPage';
const protectedPage = (permissions: readonly string[], element: ReactNode) =>
<RequirePermission anyOf={permissions}>{element}</RequirePermission>;
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
@@ -12,21 +20,25 @@ export const router = createBrowserRouter([
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'tenants', element: <CommercialWorkbenchPage workbenchKey="onboarding" /> },
{ path: 'subscriptions', element: <CommercialWorkbenchPage workbenchKey="receivables" /> },
{ index: true, element: protectedPage(['platform:dashboard:view'], <DashboardPage />) },
{ path: 'tenants', element: protectedPage(['platform:tenant:manage'], <CommercialWorkbenchPage workbenchKey="onboarding" />) },
{ path: 'subscriptions', element: protectedPage(['platform:saas-catalog:manage', 'platform:saas-billing:manage'], <CommercialWorkbenchPage workbenchKey="receivables" />) },
{ path: 'billing', element: <Navigate to="/subscriptions" replace /> },
{ path: 'usage', element: <BusinessPage pageKey="usage" /> },
{ path: 'dunning', element: <CommercialWorkbenchPage workbenchKey="dunning" /> },
{ path: 'refunds', element: <CommercialWorkbenchPage workbenchKey="refunds" /> },
{ path: 'question-banks', element: <QuestionBankPage /> },
{ path: 'crm', element: <BusinessPage pageKey="crm" /> },
{ path: 'sms', element: <BusinessPage pageKey="sms" /> },
{ path: 'payments', element: <BusinessPage pageKey="payments" /> },
{ path: 'staff', element: <BusinessPage pageKey="staff" /> },
{ path: 'audit', element: <BusinessPage pageKey="audit" /> },
{ path: 'alerts', element: <BusinessPage pageKey="alerts" /> },
{ path: 'operations', element: <BusinessPage pageKey="operations" /> },
{ path: 'usage', element: protectedPage(['platform:saas-billing:manage'], <BusinessPage pageKey="usage" />) },
{ path: 'dunning', element: protectedPage(['platform:billing:notification', 'platform:saas-billing:manage'], <CommercialWorkbenchPage workbenchKey="dunning" />) },
{ path: 'refunds', element: protectedPage(['platform:saas-billing:manage'], <CommercialWorkbenchPage workbenchKey="refunds" />) },
{ path: 'question-banks', element: protectedPage(['platform:question-bank:manage'], <QuestionBankPage />) },
{ path: 'crm', element: protectedPage(['platform:crm:read'], <BusinessPage pageKey="crm" />) },
{ path: 'sms', element: protectedPage(['platform:sms:read'], <BusinessPage pageKey="sms" />) },
{ path: 'payments', element: protectedPage(['platform:payment:read'], <BusinessPage pageKey="payments" />) },
{ path: 'staff', element: protectedPage(['platform:staff:manage', 'platform:role:manage'], <BusinessPage pageKey="staff" />) },
{ path: 'audit', element: protectedPage(['platform:audit:view'], <BusinessPage pageKey="audit" />) },
{ path: 'alerts', element: protectedPage(['platform:audit:view'], <BusinessPage pageKey="alerts" />) },
{ path: 'operations', element: protectedPage(['platform:operations:view'], <BusinessPage pageKey="operations" />) },
{ path: 'approvals', element: protectedPage(['platform:approval:view'], <ApprovalCenterPage />) },
{ path: 'configuration', element: protectedPage(['platform:configuration:manage'], <ConfigurationCenterPage />) },
{ path: 'notifications', element: protectedPage(['platform:notification:manage'], <NotificationCenterPage />) },
{ path: 'forbidden', element: <ForbiddenPage /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },

View File

@@ -0,0 +1,41 @@
using Tiku.Application.PlatformAdmin;
using Tiku.Domain.Platform;
namespace Tiku.UnitTests.PlatformAdmin;
public sealed class PlatformApprovalRulesTests
{
[Theory]
[InlineData(4_999_999, false)]
[InlineData(5_000_000, true)]
[InlineData(5_000_001, true)]
public void Financial_threshold_is_fifty_thousand_yuan(int amountCents, bool expected) =>
Assert.Equal(expected, PlatformApprovalRules.RequiresApproval(true, false, 5_000_000, amountCents));
[Fact]
public void Always_policy_requires_approval_without_amount() =>
Assert.True(PlatformApprovalRules.RequiresApproval(true, true, null, null));
[Fact]
public void Disabled_policy_executes_immediately() =>
Assert.False(PlatformApprovalRules.RequiresApproval(false, true, 1, 10));
[Fact]
public void Requester_cannot_approve_own_request()
{
var actor = Guid.NewGuid();
var denial = PlatformApprovalRules.DecisionDenialCode(
PlatformApprovalRequestStatus.Pending, actor, actor, DateTimeOffset.UtcNow.AddHours(1),
"platform:tenant:manage", new HashSet<string> { "platform:tenant:manage" }, DateTimeOffset.UtcNow);
Assert.Equal("approval_maker_checker_required", denial);
}
[Fact]
public void Approver_must_still_hold_business_permission()
{
var denial = PlatformApprovalRules.DecisionDenialCode(
PlatformApprovalRequestStatus.Pending, Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow.AddHours(1),
"platform:tenant:manage", new HashSet<string>(), DateTimeOffset.UtcNow);
Assert.Equal("approval_business_permission_required", denial);
}
}

View File

@@ -129,6 +129,7 @@ internal static class WorkerDependencyInjection
builder.Services.AddHostedService<BackgroundJobsWorker>();
builder.Services.AddHostedService<AuthorizationCacheInvalidationWorker>();
builder.Services.AddHostedService<CommercialBillingWorker>();
builder.Services.AddHostedService<PlatformApprovalWorker>();
return builder;
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
using Npgsql;
using Tiku.Application.Jobs;
using Tiku.Application.PlatformBilling;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Observability;
@@ -298,3 +299,21 @@ internal sealed class CommercialBillingWorker(
.ProcessDueAsync(cancellationToken);
}
}
internal sealed class PlatformApprovalWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<WorkerOptions> options,
ILogger<PlatformApprovalWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "platform-approvals",
TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Execute approved platform commands");
return await scope.ServiceProvider.GetRequiredService<IPlatformApprovalService>()
.ProcessApprovedAsync(cancellationToken: cancellationToken);
}
}

View File

@@ -10,6 +10,7 @@
| 从空库验收租户创建、Owner 激活和站点发布 | [空数据库到租户建站验收](tenant-provisioning.md) |
| 理解项目分层、运行时和业务边界 | [系统架构与业务边界](architecture/overview.md) |
| 修改认证、权限或租户数据 | [认证、授权与租户隔离](architecture/security-and-tenancy.md) |
| 查看 Tiku/RuoYi 双线取舍和公平基准边界 | [双线能力矩阵](architecture/dual-line-capability-matrix.md) |
| 部署 API/Worker、配置依赖或排查任务 | [配置与后台任务](operations.md) |
## 文档边界

View File

@@ -0,0 +1,24 @@
# Tiku / RuoYi 双线能力矩阵
状态只使用:`已实现``已验证``计划中``明确不移植``已实现`表示当前 Tiku 源码存在该能力,`已验证`表示本仓库自动化或本机真实依赖验收已经通过;它不等于生产容量结论。
| 能力 | Tiku 状态 | RuoYi 用途 | 维护决策 |
| --- | --- | --- | --- |
| Host 租户解析与租户开通 | 已验证 | 功能对照 | Tiku 主线 |
| 平台多岗位 RBAC、菜单与路由守卫 | 已验证 | 通用后台样板 | Tiku 原生维护 |
| OpenAPI 操作权限、风险和审批策略元数据 | 已验证 | 不移植 | Tiku 原生维护 |
| 分级四眼审批与 Worker 执行 | 已验证 | 流程参考 | Tiku 原生维护 |
| 类型化配置、版本发布与回滚 | 已验证 | 配置中心参考 | Tiku 原生维护 |
| 平台站内通知与投递记录 | 已实现 | 通知中心参考 | 短信、邮件 Provider 投递计划中 |
| 租户 360 聚合工作台 | 计划中 | 页面交互参考 | 按 Tiku 领域投影实现 |
| 平台跨租户列表统一分页 | 计划中 | SQL 与分页参考 | 逐接口迁移为 `PagedResult<T>` |
| 热目录输出缓存 | 已验证 | 性能对照线 | Tiku 保持租户感知缓存 |
| 冷查询、复合索引和批量写入 | 计划中 | SQL 优化参考 | 先公平基准,再按查询补索引 |
| 代码生成器 | 明确不移植 | 仅开发效率参考 | 不进入产品运行时 |
| 动态数据源、Druid 管理页 | 明确不移植 | 不使用 | 保持单一受控数据访问边界 |
| Quartz 任意任务编辑 | 明确不移植 | 不使用 | 仅实现专用 Worker 与命令 |
| 通用业务字典 | 明确不移植 | 不使用 | 使用领域枚举和类型化配置 |
## 公平比较约束
性能结论必须使用同一台机器、相同 PostgreSQL/Redis 版本、相同租户数和业务数据量,并分别报告热目录、冷目录、认证、平台分页、批量导入和 Worker 竞争。每个场景至少执行三轮,记录 QPS、p95、p99、HTTP 状态、丢失迭代与依赖状态。未满足这些条件时,只能记录源码推断,不能记录生产容量胜负。

View File

@@ -47,3 +47,22 @@ TIKU_MODE=ready TIKU_RATE=100 TIKU_DURATION=30s tools/performance/run-local.sh
从较低 `TIKU_RATE` 逐步翻倍。当首次出现 `dropped_iterations > 0`、错误率达到 1%,或延迟阈值失败时,上一档可视为当前机器、当前数据集、单实例配置下的保守持续吞吐。生产容量还要在接近生产的独立压测机、数据量、网络、连接池和观测配置下复测,不能直接按本机核数线性外推。
请勿把访问令牌、数据库密码或 Redis 密码写进此目录或结果文件。
## Tiku / RuoYi 三轮公平对照
`dual-line.js` 不绑定框架,只按环境变量访问一个明确端点。先为 Tiku 和 RuoYi 准备相同数据量、租户、PostgreSQL、Redis 和业务语义,再对热目录、冷目录、认证、平台分页、批量导入和 Worker 竞争分别运行:
```bash
BENCH_TARGET_NAME=tiku BENCH_SCENARIO_NAME=admin-page \
BENCH_BASE_URL=http://127.0.0.1:5091 BENCH_PATH=/api/platform-admin/tenants \
BENCH_AUTHORIZATION='Bearer <临时令牌>' BENCH_RATE=100 BENCH_DURATION=30s \
tools/performance/run-three.sh
```
RuoYi 使用同一速率、时长和数据集执行到另一个结果目录。令牌只通过进程环境传入,不写入 `run.env`。用三轮中位数执行 5% 回归门禁:
```bash
node tools/performance/compare-baseline.mjs <稳定基线目录> <候选结果目录>
```
候选吞吐下降超过 5%、p95 上升超过 5%、出现请求失败或丢失迭代时命令返回非零。报告还应附测试前后的 PostgreSQL/Redis 状态;跨项目比较不得把不同接口语义或不同数据规模混成一个排名。

View File

@@ -0,0 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
const [baselineDir, candidateDir] = process.argv.slice(2);
if (!baselineDir || !candidateDir) {
throw new Error('usage: node compare-baseline.mjs <baseline-dir> <candidate-dir>');
}
function median(values) {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.floor(sorted.length / 2)];
}
function read(directory) {
const summaries = [1, 2, 3].map((run) => JSON.parse(fs.readFileSync(path.join(directory, `summary-run-${run}.json`), 'utf8')));
return {
throughput: median(summaries.map((summary) => summary.metrics.http_reqs.values.rate)),
p95: median(summaries.map((summary) => summary.metrics.http_req_duration.values['p(95)'])),
p99: median(summaries.map((summary) => summary.metrics.http_req_duration.values['p(99)'])),
failures: summaries.reduce((total, summary) => total + (summary.metrics.http_req_failed?.values?.passes || 0), 0),
dropped: summaries.reduce((total, summary) => total + (summary.metrics.dropped_iterations?.values?.count || 0), 0),
};
}
const baseline = read(baselineDir);
const candidate = read(candidateDir);
const throughputChange = (candidate.throughput - baseline.throughput) / baseline.throughput;
const p95Change = (candidate.p95 - baseline.p95) / baseline.p95;
console.log(JSON.stringify({ baseline, candidate, throughputChange, p95Change }, null, 2));
if (candidate.failures > 0 || candidate.dropped > 0 || throughputChange < -0.05 || p95Change > 0.05) process.exitCode = 1;

View File

@@ -0,0 +1,65 @@
import http from 'k6/http';
import { check, fail } from 'k6';
const baseUrl = required('BENCH_BASE_URL').replace(/\/$/, '');
const path = required('BENCH_PATH');
const method = (__ENV.BENCH_METHOD || 'GET').toUpperCase();
const rate = positiveInteger('BENCH_RATE', 100);
const expectedStatus = positiveInteger('BENCH_EXPECTED_STATUS', 200);
const duration = __ENV.BENCH_DURATION || '30s';
const cacheBust = (__ENV.BENCH_CACHE_BUST || 'false').toLowerCase() === 'true';
export const options = {
discardResponseBodies: true,
summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
scenarios: {
fairComparison: {
executor: 'constant-arrival-rate',
rate,
timeUnit: '1s',
duration,
preAllocatedVUs: positiveInteger('BENCH_PRE_ALLOCATED_VUS', Math.max(20, Math.ceil(rate / 20))),
maxVUs: positiveInteger('BENCH_MAX_VUS', Math.max(100, Math.ceil(rate / 5))),
},
},
thresholds: {
checks: ['rate>0.99'],
http_req_failed: ['rate<0.01'],
dropped_iterations: ['count==0'],
},
};
export function setup() {
const readinessPath = __ENV.BENCH_READINESS_PATH;
if (!readinessPath) return;
const response = http.get(`${baseUrl}${readinessPath}`, { headers: headers() });
if (response.status !== 200) fail(`readiness failed: ${response.status}`);
}
export default function () {
const separator = path.includes('?') ? '&' : '?';
const requestPath = cacheBust ? `${path}${separator}loadProbe=${__VU}-${__ITER}` : path;
const response = http.request(method, `${baseUrl}${requestPath}`, __ENV.BENCH_BODY || null, { headers: headers() });
check(response, { [`status is ${expectedStatus}`]: (value) => value.status === expectedStatus });
}
function headers() {
const values = { Accept: 'application/json' };
if (__ENV.BENCH_HOST_HEADER) values.Host = __ENV.BENCH_HOST_HEADER;
if (__ENV.BENCH_AUTHORIZATION) values.Authorization = __ENV.BENCH_AUTHORIZATION;
if (__ENV.BENCH_TENANT_HEADER) values['x-tenant-code'] = __ENV.BENCH_TENANT_HEADER;
if (__ENV.BENCH_BODY) values['Content-Type'] = 'application/json';
return values;
}
function required(name) {
const value = __ENV[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function positiveInteger(name, fallback) {
const value = Number(__ENV[name] || fallback);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}

28
tools/performance/run-three.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
target_name="${BENCH_TARGET_NAME:-target}"
scenario_name="${BENCH_SCENARIO_NAME:-scenario}"
run_stamp="$(date +%Y%m%d-%H%M%S)"
result_dir="${BENCH_RESULT_DIR:-${repo_root}/artifacts/performance/${run_stamp}-${target_name}-${scenario_name}}"
if ! command -v k6 >/dev/null 2>&1; then
echo "k6 is required for the three-run comparison" >&2
exit 1
fi
if [[ -z "${BENCH_BASE_URL:-}" || -z "${BENCH_PATH:-}" ]]; then
echo "BENCH_BASE_URL and BENCH_PATH are required" >&2
exit 1
fi
mkdir -p "${result_dir}"
for run_number in 1 2 3; do
k6 run --summary-export "${result_dir}/summary-run-${run_number}.json" \
"${repo_root}/tools/performance/dual-line.js" \
| tee "${result_dir}/run-${run_number}.txt"
done
printf 'target=%s\nscenario=%s\nbase_url=%s\npath=%s\n' \
"${target_name}" "${scenario_name}" "${BENCH_BASE_URL}" "${BENCH_PATH}" >"${result_dir}/run.env"
echo "Results: ${result_dir}"