feat(platform): harden governance and approval workflows
Some checks failed
ci / release-gate (push) Has been cancelled
Some checks failed
ci / release-gate (push) Has been cancelled
This commit is contained in:
@@ -17,6 +17,7 @@ internal static class ApiPresentationExtensions
|
||||
services.AddOpenApi(options =>
|
||||
{
|
||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||
options.AddOperationTransformer<PlatformOperationMetadataTransformer>();
|
||||
});
|
||||
services.AddProblemDetails();
|
||||
|
||||
|
||||
24
Tiku.Api/Contracts/PlatformApprovalDtos.cs
Normal file
24
Tiku.Api/Contracts/PlatformApprovalDtos.cs
Normal 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());
|
||||
}
|
||||
31
Tiku.Api/Contracts/PlatformGovernanceDtos.cs
Normal file
31
Tiku.Api/Contracts/PlatformGovernanceDtos.cs
Normal 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);
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
73
Tiku.Api/Controllers/PlatformApprovalsController.cs
Normal file
73
Tiku.Api/Controllers/PlatformApprovalsController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
89
Tiku.Api/Controllers/PlatformGovernanceController.cs
Normal file
89
Tiku.Api/Controllers/PlatformGovernanceController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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("禁用平台支付渠道")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
52
Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs
Normal file
52
Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
105
Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs
Normal file
105
Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
42
Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs
Normal file
42
Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
};
|
||||
|
||||
116
Tiku.Domain/Platform/PlatformGovernanceEntities.cs
Normal file
116
Tiku.Domain/Platform/PlatformGovernanceEntities.cs
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
20988
Tiku.Infrastructure/Persistence/Migrations/20260803025523_AddPlatformGovernance.Designer.cs
generated
Normal file
20988
Tiku.Infrastructure/Persistence/Migrations/20260803025523_AddPlatformGovernance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
21388
Tiku.Infrastructure/Persistence/Migrations/20260803030615_AddPlatformGovernanceCenters.Designer.cs
generated
Normal file
21388
Tiku.Infrastructure/Persistence/Migrations/20260803030615_AddPlatformGovernanceCenters.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
427
Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs
Normal file
427
Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs
Normal 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);
|
||||
}
|
||||
237
Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs
Normal file
237
Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
129
Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs
Normal file
129
Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
13
Tiku.PlatformAdmin.Web/src/api/platform-command.test.ts
Normal file
13
Tiku.PlatformAdmin.Web/src/api/platform-command.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
Tiku.PlatformAdmin.Web/src/api/platform-command.ts
Normal file
12
Tiku.PlatformAdmin.Web/src/api/platform-command.ts
Normal 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
1002
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
1002
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
11
Tiku.PlatformAdmin.Web/src/auth/RequirePermission.tsx
Normal file
11
Tiku.PlatformAdmin.Web/src/auth/RequirePermission.tsx
Normal 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 }} />;
|
||||
}
|
||||
42
Tiku.PlatformAdmin.Web/src/layout/AppLayout.test.tsx
Normal file
42
Tiku.PlatformAdmin.Web/src/layout/AppLayout.test.tsx
Normal 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(['/']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
126
Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx
Normal file
126
Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
7
Tiku.PlatformAdmin.Web/src/pages/ForbiddenPage.tsx
Normal file
7
Tiku.PlatformAdmin.Web/src/pages/ForbiddenPage.tsx
Normal 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>} />;
|
||||
}
|
||||
88
Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx
Normal file
88
Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
41
Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs
Normal file
41
Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
## 文档边界
|
||||
|
||||
24
docs/architecture/dual-line-capability-matrix.md
Normal file
24
docs/architecture/dual-line-capability-matrix.md
Normal 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 状态、丢失迭代与依赖状态。未满足这些条件时,只能记录源码推断,不能记录生产容量胜负。
|
||||
@@ -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 状态;跨项目比较不得把不同接口语义或不同数据规模混成一个排名。
|
||||
|
||||
30
tools/performance/compare-baseline.mjs
Normal file
30
tools/performance/compare-baseline.mjs
Normal 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;
|
||||
65
tools/performance/dual-line.js
Normal file
65
tools/performance/dual-line.js
Normal 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
28
tools/performance/run-three.sh
Executable 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}"
|
||||
Reference in New Issue
Block a user