From f22f329d339a0c2206fa417c8da422b5aad6e3a7 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 28 Jul 2026 09:57:50 +0800 Subject: [PATCH] feat: add phase five operations foundation --- Directory.Packages.props | 3 +- README.md | 5 + Tiku.Api/ApiProgramMarker.cs | 3 + Tiku.Api/Contracts/BackgroundJobDtos.cs | 22 + Tiku.Api/Contracts/BackofficeDtos.cs | 41 + Tiku.Api/Contracts/TenantCommerceDtos.cs | 85 ++ .../Controllers/BackgroundJobsController.cs | 41 + Tiku.Api/Controllers/BackofficeController.cs | 122 +++ .../Controllers/TenantCommerceController.cs | 105 +++ Tiku.Application/Auth/ISmsProvider.cs | 6 + .../Backoffice/BackofficeModels.cs | 70 ++ .../Backoffice/IBackofficeService.cs | 49 ++ .../Commerce/CommerceAdminModels.cs | 76 ++ Tiku.Application/Jobs/BackgroundJobModels.cs | 43 + Tiku.Application/Security/ITenantContext.cs | 1 + Tiku.Domain/Operations/OperationsEntities.cs | 108 +++ .../Tenancy/TenantOperationsEntities.cs | 2 +- Tiku.Infrastructure/Auth/AliyunSmsProvider.cs | 169 ++++ .../Auth/SmsVerificationService.cs | 50 +- .../Backoffice/BackofficeService.cs | 464 +++++++++++ .../Backoffice/OperationAuditService.cs | 27 + .../Commerce/CommerceAdminService.cs | 339 ++++++++ .../Commerce/WechatPayProvider.cs | 204 +++-- Tiku.Infrastructure/DependencyInjection.cs | 9 +- .../Jobs/BackgroundJobService.cs | 147 ++++ .../OperationsConfigurations.cs | 201 +++++ ... 20260728014412_InitialSchema.Designer.cs} | 785 +++++++++++++++++- ...ema.cs => 20260728014412_InitialSchema.cs} | 522 ++++++++++-- .../Migrations/TikuDbContextModelSnapshot.cs | 783 +++++++++++++++++ .../PostgreSqlTenantConstraintSql.cs | 89 ++ .../Persistence/TikuDbContext.cs | 11 + .../Tiku.Infrastructure.csproj | 3 +- Tiku.IntegrationTests/Api/ApiTestFactory.cs | 3 +- .../ArchitectureBoundaryTests.cs | 61 ++ Tiku.UnitTests/Auth/AuthServiceTests.cs | 62 ++ Tiku.Worker/Worker.cs | 38 +- docs/migration-roadmap.md | 7 +- ...nant-isolation-and-shared-question-bank.md | 20 + .../phase-5-backoffice-worker-operations.md | 194 +++++ 39 files changed, 4810 insertions(+), 160 deletions(-) create mode 100644 Tiku.Api/ApiProgramMarker.cs create mode 100644 Tiku.Api/Contracts/BackgroundJobDtos.cs create mode 100644 Tiku.Api/Contracts/BackofficeDtos.cs create mode 100644 Tiku.Api/Controllers/BackgroundJobsController.cs create mode 100644 Tiku.Api/Controllers/BackofficeController.cs create mode 100644 Tiku.Application/Backoffice/BackofficeModels.cs create mode 100644 Tiku.Application/Backoffice/IBackofficeService.cs create mode 100644 Tiku.Application/Jobs/BackgroundJobModels.cs create mode 100644 Tiku.Infrastructure/Auth/AliyunSmsProvider.cs create mode 100644 Tiku.Infrastructure/Backoffice/BackofficeService.cs create mode 100644 Tiku.Infrastructure/Backoffice/OperationAuditService.cs create mode 100644 Tiku.Infrastructure/Jobs/BackgroundJobService.cs rename Tiku.Infrastructure/Persistence/Migrations/{20260727093301_InitialSchema.Designer.cs => 20260728014412_InitialSchema.Designer.cs} (95%) rename Tiku.Infrastructure/Persistence/Migrations/{20260727093301_InitialSchema.cs => 20260728014412_InitialSchema.cs} (95%) create mode 100644 Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs create mode 100644 docs/migration/phase-5-backoffice-worker-operations.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 39e483b..f6274f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + @@ -32,9 +33,9 @@ + - diff --git a/README.md b/README.md index 985e7b9..98b2253 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [`docs/migration/phase-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md) - [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md) - [`docs/migration/phase-4-external-provider-decoupling.md`](docs/migration/phase-4-external-provider-decoupling.md) +- [`docs/migration/phase-5-backoffice-worker-operations.md`](docs/migration/phase-5-backoffice-worker-operations.md) ## 技术栈与分层 @@ -32,6 +33,9 @@ - Serilog - ZLinq - AlibabaCloud.OSS.V2 +- AlibabaCloud.SDK.Dysmsapi20170525 +- Senparc.Weixin.* +- AlipaySDKNet.Standard - xUnit ```text @@ -207,6 +211,7 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs - 自定义域名请求不得通过 `tenantCode`、`host` query 或客户端转发头切换租户。 - 第三方 SDK、密钥读取、OSS bucket 拼接、微信/支付/短信 provider 细节只允许在 Infrastructure provider 实现中出现。 - 不新增 Supabase provider、Supabase URL 拼接、Supabase Storage bucket 逻辑或 Supabase Auth 兼容层。 +- EF Core 管实体和 migration 生命周期;跨表租户不变量不能指望 ORM 自动推导。比如“题目引用只能指向平台或本租户”“分类父节点只能属于平台或本租户”,必须用集中 PostgreSQL trigger / constraint trigger SQL helper + migration 调用 + 真实 PostgreSQL 测试兜底,禁止去数据库手工补。 ## 还剩多少待迁移 diff --git a/Tiku.Api/ApiProgramMarker.cs b/Tiku.Api/ApiProgramMarker.cs new file mode 100644 index 0000000..c563f59 --- /dev/null +++ b/Tiku.Api/ApiProgramMarker.cs @@ -0,0 +1,3 @@ +namespace Tiku.Api; + +public sealed class ApiProgramMarker; diff --git a/Tiku.Api/Contracts/BackgroundJobDtos.cs b/Tiku.Api/Contracts/BackgroundJobDtos.cs new file mode 100644 index 0000000..04be121 --- /dev/null +++ b/Tiku.Api/Contracts/BackgroundJobDtos.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Tiku.Application.Jobs; + +namespace Tiku.Api.Contracts; + +public sealed class CreateBackgroundJobDto +{ + public string JobType { get; set; } = string.Empty; + public JsonElement Payload { get; set; } + public DateTimeOffset? RunAfter { get; set; } + public int MaxRetries { get; set; } = 3; + + public CreateBackgroundJobCommand ToCommand(Guid tenantId) + { + return new CreateBackgroundJobCommand( + tenantId, + JobType, + Payload.ValueKind == JsonValueKind.Undefined ? JsonSerializer.SerializeToElement(new { }) : Payload, + RunAfter, + MaxRetries); + } +} diff --git a/Tiku.Api/Contracts/BackofficeDtos.cs b/Tiku.Api/Contracts/BackofficeDtos.cs new file mode 100644 index 0000000..4b26447 --- /dev/null +++ b/Tiku.Api/Contracts/BackofficeDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Tiku.Application.Backoffice; +using Tiku.Domain.Operations; + +namespace Tiku.Api.Contracts; + +public sealed class UpsertBackofficeRoleDto +{ + public Guid? Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active; + public string? Description { get; set; } + public JsonElement? DataScope { get; set; } + + public UpsertBackofficeRoleCommand ToCommand() + { + return new UpsertBackofficeRoleCommand(Id, Code, Name, Status, Description, DataScope); + } +} + +public sealed class ReplaceRoleBindingsDto +{ + public IReadOnlyCollection PermissionCodes { get; set; } = []; + public IReadOnlyCollection MenuCodes { get; set; } = []; + + public ReplaceRoleBindingsCommand ToCommand(Guid roleId) + { + return new ReplaceRoleBindingsCommand(roleId, PermissionCodes, MenuCodes); + } +} + +public sealed class ReplaceUserRolesDto +{ + public IReadOnlyCollection RoleIds { get; set; } = []; + + public ReplaceUserRolesCommand ToCommand(Guid userId) + { + return new ReplaceUserRolesCommand(userId, RoleIds); + } +} diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs index 9e1aebd..aafe913 100644 --- a/Tiku.Api/Contracts/TenantCommerceDtos.cs +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -249,6 +249,91 @@ public sealed class UpsertPointExchangeItemDto } } +public sealed class CreateRefundRequestDto +{ + [Required] + public Guid OrderId { get; set; } + + public Guid? PaymentId { get; set; } + + [Range(1, int.MaxValue)] + public int AmountCents { get; set; } + + [StringLength(1000)] + public string? Reason { get; set; } + + public RefundEntitlementAction EntitlementAction { get; set; } = RefundEntitlementAction.RevokeOnSuccess; + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public CreateRefundRequestCommand ToCommand() + { + return new CreateRefundRequestCommand(OrderId, PaymentId, AmountCents, Reason, EntitlementAction, Metadata); + } +} + +public sealed class UpdateRefundStatusDto +{ + [Required] + public Guid RefundRequestId { get; set; } + + public CommerceRefundStatus Status { get; set; } + + [StringLength(1000)] + public string? Reason { get; set; } + + [StringLength(200)] + public string? ProviderRefundNo { get; set; } + + public UpdateRefundStatusCommand ToCommand() + { + return new UpdateRefundStatusCommand(RefundRequestId, Status, Reason, ProviderRefundNo); + } +} + +public sealed class CreateReconciliationBatchDto +{ + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + public DateOnly BillDate { get; set; } + public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; + public ReconciliationSource Source { get; set; } = ReconciliationSource.ManualUpload; + + [StringLength(200)] + public string? SourceName { get; set; } + + [Required] + [StringLength(200)] + public string SourceHash { get; set; } = string.Empty; + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public CreateReconciliationBatchCommand ToCommand() + { + return new CreateReconciliationBatchCommand(Provider, BillDate, BillType, Source, SourceName, SourceHash, Metadata); + } +} + +public sealed class UpdateReconciliationIssueDto +{ + [Required] + public Guid IssueId { get; set; } + + public ReconciliationIssueStatus Status { get; set; } = ReconciliationIssueStatus.Investigating; + public ReconciliationResolutionType ResolutionType { get; set; } = ReconciliationResolutionType.None; + + [StringLength(1000)] + public string? Note { get; set; } + + public Guid? AssignedTo { get; set; } + + public UpdateReconciliationIssueCommand ToCommand() + { + return new UpdateReconciliationIssueCommand(IssueId, Status, ResolutionType, Note, AssignedTo); + } +} + public sealed class UpdatePointExchangeOrderStatusDto { [Required] diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs new file mode 100644 index 0000000..3a99de0 --- /dev/null +++ b/Tiku.Api/Controllers/BackgroundJobsController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Jobs; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Route("api/backoffice/tenant/jobs")] +[Authorize(Policy = TikuPolicies.TenantAdmin)] +public sealed class BackgroundJobsController( + IBackgroundJobService backgroundJobService, + ITenantContext tenantContext) : ControllerBase +{ + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] string? jobType, + [FromQuery] int? limit, + CancellationToken cancellationToken) + { + var tenantId = ResolveTenantId(); + return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken)); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Create( + CreateBackgroundJobDto request, + CancellationToken cancellationToken) + { + var tenantId = ResolveTenantId(); + return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken)); + } + + private Guid ResolveTenantId() + { + return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved."); + } +} diff --git a/Tiku.Api/Controllers/BackofficeController.cs b/Tiku.Api/Controllers/BackofficeController.cs new file mode 100644 index 0000000..0864303 --- /dev/null +++ b/Tiku.Api/Controllers/BackofficeController.cs @@ -0,0 +1,122 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Backoffice; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Route("api/backoffice")] +public sealed class BackofficeController( + IBackofficeService backofficeService, + ICurrentUser currentUser, + ITenantContext tenantContext) : ControllerBase +{ + [HttpGet("tenant/bootstrap")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetTenantBootstrap(CancellationToken cancellationToken) + { + return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken)); + } + + [HttpPost("tenant/roles")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertTenantRole( + UpsertBackofficeRoleDto request, + CancellationToken cancellationToken) + { + return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPut("tenant/roles/{roleId:guid}/bindings")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReplaceTenantRoleBindings( + Guid roleId, + ReplaceRoleBindingsDto request, + CancellationToken cancellationToken) + { + return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken)); + } + + [HttpPut("tenant/users/{userId:guid}/roles")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ReplaceTenantUserRoles( + Guid userId, + ReplaceUserRolesDto request, + CancellationToken cancellationToken) + { + await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken); + return NoContent(); + } + + [HttpGet("platform/bootstrap")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetPlatformBootstrap(CancellationToken cancellationToken) + { + return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken)); + } + + [HttpPost("platform/roles")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertPlatformRole( + UpsertBackofficeRoleDto request, + CancellationToken cancellationToken) + { + return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPut("platform/roles/{roleId:guid}/bindings")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReplacePlatformRoleBindings( + Guid roleId, + ReplaceRoleBindingsDto request, + CancellationToken cancellationToken) + { + return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken)); + } + + [HttpPut("platform/users/{userId:guid}/roles")] + [Authorize(Policy = TikuPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ReplacePlatformUserRoles( + Guid userId, + ReplaceUserRolesDto request, + CancellationToken cancellationToken) + { + await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken); + return NoContent(); + } + + private BackofficeActor ResolveTenantActor() + { + if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId) + { + throw new InvalidOperationException("Tenant backoffice actor was not resolved."); + } + + return new BackofficeActor(userId, tenantId, IsPlatformAdmin()); + } + + private BackofficeActor ResolvePlatformActor() + { + if (currentUser.UserId is not { } userId) + { + throw new InvalidOperationException("Platform backoffice actor was not resolved."); + } + + return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin()); + } + + private bool IsPlatformAdmin() + { + return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index fa30b5f..f5a88aa 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Commerce; using Tiku.Application.Security; +using Tiku.Domain.Commerce; namespace Tiku.Api.Controllers; @@ -262,6 +263,110 @@ public sealed class TenantCommerceController( cancellationToken)); } + [HttpGet("refunds")] + [EndpointSummary("查询退款申请")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Refunds( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetRefundsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("refunds")] + [EndpointSummary("创建退款申请")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateRefund( + CreateRefundRequestDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.CreateRefundRequestAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("refunds/status")] + [EndpointSummary("更新退款状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateRefundStatus( + UpdateRefundStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpdateRefundStatusAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("refunds/{refundRequestId:guid}/events")] + [EndpointSummary("查询退款事件")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RefundEvents( + Guid refundRequestId, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetRefundEventsAsync( + ResolveActor(), + refundRequestId, + cancellationToken)); + } + + [HttpGet("reconciliation/batches")] + [EndpointSummary("查询对账批次")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReconciliationBatches( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetReconciliationBatchesAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("reconciliation/batches")] + [EndpointSummary("创建对账批次")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateReconciliationBatch( + CreateReconciliationBatchDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.CreateReconciliationBatchAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("reconciliation/issues")] + [EndpointSummary("查询对账异常")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReconciliationIssues( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetReconciliationIssuesAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("reconciliation/issues/status")] + [EndpointSummary("更新对账异常状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateReconciliationIssue( + UpdateReconciliationIssueDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpdateReconciliationIssueAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + private CommerceAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) diff --git a/Tiku.Application/Auth/ISmsProvider.cs b/Tiku.Application/Auth/ISmsProvider.cs index 18bcf23..5b0e1b0 100644 --- a/Tiku.Application/Auth/ISmsProvider.cs +++ b/Tiku.Application/Auth/ISmsProvider.cs @@ -15,6 +15,12 @@ public sealed record SmsProviderSendResult( string Status, string? MessageId = null); +public sealed class SmsProviderException(string message, string code, Exception? innerException = null) + : AuthException(code, message) +{ + public Exception? ProviderException { get; } = innerException; +} + public interface ISmsProvider { Task SendAsync( diff --git a/Tiku.Application/Backoffice/BackofficeModels.cs b/Tiku.Application/Backoffice/BackofficeModels.cs new file mode 100644 index 0000000..d48b3b2 --- /dev/null +++ b/Tiku.Application/Backoffice/BackofficeModels.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using Tiku.Domain.Operations; + +namespace Tiku.Application.Backoffice; + +public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform); + +public sealed record BackofficeBootstrap( + IReadOnlyCollection Permissions, + IReadOnlyCollection Menus, + IReadOnlyCollection Roles); + +public sealed record BackofficePermissionItem( + Guid Id, + string Code, + string Name, + BackendPermissionArea Area, + string Module, + string? Description, + int SortOrder); + +public sealed record BackofficeMenuItem( + Guid Id, + string Code, + string? ParentCode, + string Title, + BackendPermissionArea Area, + string? Path, + string? Icon, + string? PermissionCode, + int SortOrder, + bool IsActive); + +public sealed record BackofficeRoleItem( + Guid Id, + string Code, + string Name, + BackendRoleStatus Status, + bool IsSystem, + string? Description, + IReadOnlyCollection PermissionCodes, + IReadOnlyCollection MenuCodes, + JsonElement? DataScope = null); + +public sealed record UpsertBackofficeRoleCommand( + Guid? Id, + string Code, + string Name, + BackendRoleStatus Status, + string? Description, + JsonElement? DataScope = null); + +public sealed record ReplaceRoleBindingsCommand( + Guid RoleId, + IReadOnlyCollection PermissionCodes, + IReadOnlyCollection MenuCodes); + +public sealed record ReplaceUserRolesCommand( + Guid UserId, + IReadOnlyCollection RoleIds); + +public sealed record BackofficeOperationAuditCommand( + Guid? TenantId, + Guid? ActorUserId, + string Action, + string? TargetType, + string? TargetId, + JsonElement Details, + string? IpAddress = null, + string? UserAgent = null); diff --git a/Tiku.Application/Backoffice/IBackofficeService.cs b/Tiku.Application/Backoffice/IBackofficeService.cs new file mode 100644 index 0000000..ba6b25a --- /dev/null +++ b/Tiku.Application/Backoffice/IBackofficeService.cs @@ -0,0 +1,49 @@ +namespace Tiku.Application.Backoffice; + +public interface IBackofficeService +{ + Task GetTenantBootstrapAsync( + BackofficeActor actor, + CancellationToken cancellationToken = default); + + Task GetPlatformBootstrapAsync( + BackofficeActor actor, + CancellationToken cancellationToken = default); + + Task UpsertTenantRoleAsync( + BackofficeActor actor, + UpsertBackofficeRoleCommand command, + CancellationToken cancellationToken = default); + + Task UpsertPlatformRoleAsync( + BackofficeActor actor, + UpsertBackofficeRoleCommand command, + CancellationToken cancellationToken = default); + + Task ReplaceTenantRoleBindingsAsync( + BackofficeActor actor, + ReplaceRoleBindingsCommand command, + CancellationToken cancellationToken = default); + + Task ReplacePlatformRoleBindingsAsync( + BackofficeActor actor, + ReplaceRoleBindingsCommand command, + CancellationToken cancellationToken = default); + + Task ReplaceTenantUserRolesAsync( + BackofficeActor actor, + ReplaceUserRolesCommand command, + CancellationToken cancellationToken = default); + + Task ReplacePlatformUserRolesAsync( + BackofficeActor actor, + ReplaceUserRolesCommand command, + CancellationToken cancellationToken = default); +} + +public interface IOperationAuditService +{ + Task WriteAsync( + BackofficeOperationAuditCommand command, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs index 3ee086f..19d198b 100644 --- a/Tiku.Application/Commerce/CommerceAdminModels.cs +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -152,6 +152,42 @@ public sealed record TenantCouponList(IReadOnlyCollection Items); public sealed record TenantCouponRedemptionList(IReadOnlyCollection Items); public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int UsedCount, int DiscountAppliedCents); +public sealed record CreateRefundRequestCommand( + Guid OrderId, + Guid? PaymentId, + int AmountCents, + string? Reason, + RefundEntitlementAction EntitlementAction, + JsonElement Metadata); + +public sealed record UpdateRefundStatusCommand( + Guid RefundRequestId, + CommerceRefundStatus Status, + string? Reason = null, + string? ProviderRefundNo = null); + +public sealed record TenantRefundList(IReadOnlyCollection Items); +public sealed record TenantRefundEventList(IReadOnlyCollection Items); + +public sealed record CreateReconciliationBatchCommand( + string Provider, + DateOnly BillDate, + ReconciliationBillType BillType, + ReconciliationSource Source, + string? SourceName, + string SourceHash, + JsonElement Metadata); + +public sealed record TenantReconciliationBatchList(IReadOnlyCollection Items); +public sealed record TenantReconciliationIssueList(IReadOnlyCollection Items); + +public sealed record UpdateReconciliationIssueCommand( + Guid IssueId, + ReconciliationIssueStatus Status, + ReconciliationResolutionType ResolutionType, + string? Note, + Guid? AssignedTo = null); + public interface ICommerceAdminService { Task> GetPaymentAccountsAsync( @@ -248,4 +284,44 @@ public interface ICommerceAdminService CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default); + + Task GetRefundsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task CreateRefundRequestAsync( + CommerceAdminActor actor, + CreateRefundRequestCommand command, + CancellationToken cancellationToken = default); + + Task UpdateRefundStatusAsync( + CommerceAdminActor actor, + UpdateRefundStatusCommand command, + CancellationToken cancellationToken = default); + + Task GetRefundEventsAsync( + CommerceAdminActor actor, + Guid refundRequestId, + CancellationToken cancellationToken = default); + + Task GetReconciliationBatchesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task CreateReconciliationBatchAsync( + CommerceAdminActor actor, + CreateReconciliationBatchCommand command, + CancellationToken cancellationToken = default); + + Task GetReconciliationIssuesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task UpdateReconciliationIssueAsync( + CommerceAdminActor actor, + UpdateReconciliationIssueCommand command, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs new file mode 100644 index 0000000..99558df --- /dev/null +++ b/Tiku.Application/Jobs/BackgroundJobModels.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Tiku.Domain.Operations; + +namespace Tiku.Application.Jobs; + +public sealed record CreateBackgroundJobCommand( + Guid TenantId, + string JobType, + JsonElement Payload, + DateTimeOffset? RunAfter = null, + int MaxRetries = 3); + +public sealed record BackgroundJobItem( + Guid Id, + Guid TenantId, + string JobType, + BackgroundJobStatus Status, + int RetryCount, + int MaxRetries, + DateTimeOffset? RunAfter, + DateTimeOffset? StartedAt, + DateTimeOffset? CompletedAt, + string? LastError, + Guid? OutputAssetId, + JsonElement Result); + +public interface IBackgroundJobService +{ + Task EnqueueAsync( + CreateBackgroundJobCommand command, + CancellationToken cancellationToken = default); + + Task ProcessPendingAsync( + string workerId, + int batchSize, + CancellationToken cancellationToken = default); + + Task> ListAsync( + Guid tenantId, + string? jobType = null, + int limit = 50, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Security/ITenantContext.cs b/Tiku.Application/Security/ITenantContext.cs index fc8dcb0..3eb082f 100644 --- a/Tiku.Application/Security/ITenantContext.cs +++ b/Tiku.Application/Security/ITenantContext.cs @@ -7,6 +7,7 @@ public enum TenantResolutionSource TenantCode, Jwt, RefreshToken, + Worker, System } diff --git a/Tiku.Domain/Operations/OperationsEntities.cs b/Tiku.Domain/Operations/OperationsEntities.cs index d8b3f6a..152ff02 100644 --- a/Tiku.Domain/Operations/OperationsEntities.cs +++ b/Tiku.Domain/Operations/OperationsEntities.cs @@ -51,6 +51,111 @@ public sealed class AuditLog : Entity public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } +public sealed class BackendPermission : AuditableEntity +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant; + public string Module { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsSystem { get; set; } = true; + public int SortOrder { get; set; } = 100; +} + +public sealed class BackendMenu : AuditableEntity +{ + public string Code { get; set; } = string.Empty; + public string? ParentCode { get; set; } + public string Title { get; set; } = string.Empty; + public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant; + public string? Path { get; set; } + public string? Icon { get; set; } + public string? PermissionCode { get; set; } + public int SortOrder { get; set; } = 100; + public bool IsActive { get; set; } = true; +} + +public sealed class TenantBackendRole : AuditableTenantEntity +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active; + public bool IsSystem { get; set; } + public string? Description { get; set; } + public JsonElement DataScope { get; set; } = JsonDefaults.Object(); +} + +public sealed class TenantBackendRolePermission : Entity, ITenantOwned +{ + public Guid TenantId { get; set; } + public Guid RoleId { get; set; } + public string PermissionCode { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class TenantBackendRoleMenu : Entity, ITenantOwned +{ + public Guid TenantId { get; set; } + public Guid RoleId { get; set; } + public string MenuCode { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class TenantBackendUserRole : Entity, ITenantOwned +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlatformBackendRole : AuditableEntity +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active; + public bool IsSystem { get; set; } + public string? Description { get; set; } +} + +public sealed class PlatformBackendRolePermission : Entity +{ + public Guid RoleId { get; set; } + public string PermissionCode { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlatformBackendRoleMenu : Entity +{ + public Guid RoleId { get; set; } + public string MenuCode { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlatformBackendUserRole : Entity +{ + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class BackgroundJob : AuditableTenantEntity +{ + public string JobType { get; set; } = string.Empty; + public BackgroundJobStatus Status { get; set; } = BackgroundJobStatus.Pending; + public JsonElement Payload { get; set; } = JsonDefaults.Object(); + public int RetryCount { get; set; } + public int MaxRetries { get; set; } = 3; + public string? LockedBy { get; set; } + public DateTimeOffset? LockExpiresAt { get; set; } + public DateTimeOffset? RunAfter { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string? LastError { get; set; } + public Guid? OutputAssetId { get; set; } + public JsonElement Result { get; set; } = JsonDefaults.Object(); +} + public sealed class UserNotification : AuditableTenantEntity { public Guid UserId { get; set; } @@ -147,3 +252,6 @@ public enum TenantContentNotificationType { PublicQuestionBankSynced, PublicQues public enum TenantContentNotificationStatus { Unread, Read, Dismissed, Resolved } public enum TenantThemeTemplateStatus { Active, Disabled } public enum TenantThemeConfigStatus { Draft, Published } +public enum BackendPermissionArea { Platform, Tenant, Both } +public enum BackendRoleStatus { Active, Disabled, Archived } +public enum BackgroundJobStatus { Pending, Processing, Succeeded, Failed, Cancelled } diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs index b075b08..4f956da 100644 --- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs +++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs @@ -162,7 +162,7 @@ public enum TenantExternalProviderCapability { Identity, ObjectStorage, Sms, Pay public enum TenantExternalProviderStatus { Active, Disabled, Testing } public enum TenantSecretStatus { Active, Disabled, Rotating } public enum SmsPurpose { Login, BindPhone, ResetPassword } -public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked } +public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed } public enum AuthLoginResult { Sent, Success, Failed, Blocked } public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device } public enum TenantRoleTemplateStatus { Active, Disabled, Archived } diff --git a/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs new file mode 100644 index 0000000..121962c --- /dev/null +++ b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using AlibabaCloud.OpenApiClient.Models; +using AlibabaCloud.SDK.Dysmsapi20170525.Models; +using Tiku.Application.Auth; +using Tiku.Application.Tenancy; +using Tiku.Domain.Tenancy; +using DysmsClient = AlibabaCloud.SDK.Dysmsapi20170525.Client; + +namespace Tiku.Infrastructure.Auth; + +internal sealed class AliyunSmsProvider( + ITenantExternalProviderConfigService providerConfigService) : ISmsProvider +{ + private const string ProviderCode = "aliyun_sms"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) + { + TenantExternalProviderAccount account; + try + { + account = await providerConfigService.GetActiveProviderAsync( + request.TenantId, + TenantExternalProviderCapability.Sms, + null, + cancellationToken); + } + catch (TenantExternalProviderException) + { + return new SmsProviderSendResult("noop", "accepted"); + } + + if (string.Equals(account.Provider, "noop", StringComparison.OrdinalIgnoreCase) || + string.Equals(account.Provider, "local_dev", StringComparison.OrdinalIgnoreCase)) + { + return new SmsProviderSendResult(account.Provider, "accepted"); + } + + if (!string.Equals(account.Provider, ProviderCode, StringComparison.OrdinalIgnoreCase)) + { + throw new SmsProviderException( + $"SMS provider '{account.Provider}' is not supported.", + "sms_provider_unsupported"); + } + + var signName = Required(account.ConfigPublic, "signName", "smsSignName"); + var templateCode = ResolveTemplateCode(account.ConfigPublic, request.Purpose); + var accessKeyId = Required(account.SecretPayload, "accessKeyId"); + var accessKeySecret = Required(account.SecretPayload, "accessKeySecret"); + var endpoint = Optional(account.ConfigPublic, "endpoint") ?? "dysmsapi.aliyuncs.com"; + var regionId = Optional(account.ConfigPublic, "regionId", "region") ?? "cn-hangzhou"; + + var client = new DysmsClient(new Config + { + AccessKeyId = accessKeyId, + AccessKeySecret = accessKeySecret, + Endpoint = endpoint, + RegionId = regionId + }); + + var sendRequest = new SendSmsRequest + { + PhoneNumbers = request.Phone, + SignName = signName, + TemplateCode = templateCode, + TemplateParam = BuildTemplateParam(account.ConfigPublic, request), + OutId = request.TenantId.ToString("N") + }; + + try + { + var response = await client.SendSmsAsync(sendRequest).WaitAsync(cancellationToken); + var body = response.Body; + if (body is null) + { + throw new SmsProviderException( + "Aliyun SMS returned an empty response.", + "aliyun_sms_empty_response"); + } + + if (!string.Equals(body.Code, "OK", StringComparison.OrdinalIgnoreCase)) + { + throw new SmsProviderException( + $"Aliyun SMS send failed: {body.Code}.", + "aliyun_sms_send_rejected"); + } + + return new SmsProviderSendResult( + ProviderCode, + body.Code ?? "OK", + body.BizId ?? body.RequestId); + } + catch (SmsProviderException) + { + throw; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new SmsProviderException( + "Aliyun SMS send failed.", + "aliyun_sms_send_failed", + exception); + } + } + + private static string ResolveTemplateCode(JsonElement config, SmsPurpose purpose) + { + if (config.ValueKind == JsonValueKind.Object && + config.TryGetProperty("templateCodes", out var templateCodes) && + templateCodes.ValueKind == JsonValueKind.Object) + { + var purposeKey = purpose.ToString().ToLowerInvariant(); + if (templateCodes.TryGetProperty(purposeKey, out var purposeTemplate) && + purposeTemplate.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(purposeTemplate.GetString())) + { + return purposeTemplate.GetString()!; + } + } + + return Required(config, "templateCode", "smsTemplateCode"); + } + + private static string BuildTemplateParam(JsonElement config, SmsProviderSendRequest request) + { + var codeField = Optional(config, "codeField") ?? "code"; + return JsonSerializer.Serialize( + new Dictionary + { + [codeField] = request.Code + }, + JsonOptions); + } + + private static string Required(JsonElement element, params string[] keys) + { + var value = Optional(element, keys); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + throw new SmsProviderException( + "Aliyun SMS provider configuration is incomplete.", + "aliyun_sms_config_incomplete"); + } + + private static string? Optional(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString(); + } + } + + return null; + } +} diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 54df003..2d92438 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Tiku.Domain.Common; using Tiku.Application.Auth; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -44,21 +45,48 @@ public sealed class SmsVerificationService( rateLimit.UpdatedAt = DateTimeOffset.UtcNow; var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture); - var sendResult = await smsProvider.SendAsync( - new SmsProviderSendRequest( - request.TenantId, - phone, - request.Purpose, - code, - request.IpAddress, - request.UserAgent), - cancellationToken); + var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code); + SmsProviderSendResult sendResult; + try + { + sendResult = await smsProvider.SendAsync( + new SmsProviderSendRequest( + request.TenantId, + phone, + request.Purpose, + code, + request.IpAddress, + request.UserAgent), + cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + dbContext.SmsVerificationCodes.Add(new SmsVerificationCode + { + TenantId = request.TenantId, + Phone = phone, + Purpose = request.Purpose, + CodeHash = codeHash, + Provider = "failed", + Status = SmsVerificationStatus.Failed, + ExpiresAt = DateTimeOffset.UtcNow, + IpAddress = request.IpAddress, + UserAgent = request.UserAgent, + Metadata = JsonDefaults.Object() + }); + await dbContext.SaveChangesAsync(cancellationToken); + + throw exception is SmsProviderException + ? exception + : new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception); + } + var verification = new SmsVerificationCode { TenantId = request.TenantId, Phone = phone, Purpose = request.Purpose, - CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code), + CodeHash = codeHash, Provider = sendResult.Provider, Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime), @@ -93,7 +121,7 @@ public sealed class SmsVerificationService( if (verification is null || verification.ExpiresAt <= now || - verification.Status is SmsVerificationStatus.Expired or SmsVerificationStatus.Blocked) + verification.Status != SmsVerificationStatus.Sent) { throw new InvalidCredentialsException("invalid_sms_code"); } diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs new file mode 100644 index 0000000..aaf3a44 --- /dev/null +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -0,0 +1,464 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Backoffice; +using Tiku.Domain.Common; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Backoffice; + +internal sealed class BackofficeService( + TikuDbContext dbContext, + IOperationAuditService auditService) : IBackofficeService +{ + private static readonly BuiltinPermission[] BuiltinPermissions = + [ + new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"), + new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"), + new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"), + new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"), + new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"), + new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), + new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), + new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"), + new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), + new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), + new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), + new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), + new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), + new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"), + new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"), + new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce") + ]; + + private static readonly BuiltinMenu[] BuiltinMenus = + [ + new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10), + new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20), + new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30), + new("tenant.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40), + new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50), + new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60), + 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) + ]; + + public async Task GetTenantBootstrapAsync( + BackofficeActor actor, + CancellationToken cancellationToken = default) + { + var tenantId = RequireTenantAdmin(actor); + await EnsureCatalogAsync(cancellationToken); + var roles = await LoadTenantRolesAsync(tenantId, cancellationToken); + var permissions = await dbContext.BackendPermissions.AsNoTracking() + .Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both) + .OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) + .ToArrayAsync(cancellationToken); + var menus = await dbContext.BackendMenus.AsNoTracking() + .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant) + .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) + .ToArrayAsync(cancellationToken); + return new BackofficeBootstrap( + permissions.Select(ToPermissionItem).ToArray(), + menus.Select(ToMenuItem).ToArray(), + roles); + } + + public async Task GetPlatformBootstrapAsync( + BackofficeActor actor, + CancellationToken cancellationToken = default) + { + RequirePlatformAdmin(actor); + await EnsureCatalogAsync(cancellationToken); + var roles = await LoadPlatformRolesAsync(cancellationToken); + var permissions = await dbContext.BackendPermissions.AsNoTracking() + .Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both) + .OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) + .ToArrayAsync(cancellationToken); + var menus = await dbContext.BackendMenus.AsNoTracking() + .Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform) + .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) + .ToArrayAsync(cancellationToken); + return new BackofficeBootstrap( + permissions.Select(ToPermissionItem).ToArray(), + menus.Select(ToMenuItem).ToArray(), + roles); + } + + public async Task UpsertTenantRoleAsync( + BackofficeActor actor, + UpsertBackofficeRoleCommand command, + CancellationToken cancellationToken = default) + { + var tenantId = RequireTenantAdmin(actor); + var role = command.Id.HasValue + ? await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value, + cancellationToken) + : await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code), + cancellationToken); + if (role is null) + { + role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) }; + dbContext.TenantBackendRoles.Add(role); + } + else if (role.IsSystem) + { + throw new BackofficeException("System tenant role cannot be modified.", "system_role_locked"); + } + + role.Name = command.Name.Trim(); + role.Status = command.Status; + role.Description = command.Description?.Trim(); + role.DataScope = command.DataScope ?? JsonDefaults.Object(); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken); + return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken); + } + + public async Task UpsertPlatformRoleAsync( + BackofficeActor actor, + UpsertBackofficeRoleCommand command, + CancellationToken cancellationToken = default) + { + RequirePlatformAdmin(actor); + var role = command.Id.HasValue + ? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken) + : await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken); + if (role is null) + { + role = new PlatformBackendRole { Code = NormalizeCode(command.Code) }; + dbContext.PlatformBackendRoles.Add(role); + } + else if (role.IsSystem) + { + throw new BackofficeException("System platform role cannot be modified.", "system_role_locked"); + } + + role.Name = command.Name.Trim(); + role.Status = command.Status; + role.Description = command.Description?.Trim(); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken); + return await LoadPlatformRoleAsync(role.Id, cancellationToken); + } + + public async Task ReplaceTenantRoleBindingsAsync( + BackofficeActor actor, + ReplaceRoleBindingsCommand command, + CancellationToken cancellationToken = default) + { + var tenantId = RequireTenantAdmin(actor); + var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.RoleId, + cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found"); + if (role.IsSystem) + { + throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked"); + } + + await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken); + await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken); + return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken); + } + + public async Task ReplacePlatformRoleBindingsAsync( + BackofficeActor actor, + ReplaceRoleBindingsCommand command, + CancellationToken cancellationToken = default) + { + RequirePlatformAdmin(actor); + var role = await dbContext.PlatformBackendRoles.SingleOrDefaultAsync( + item => item.Id == command.RoleId, + cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found"); + if (role.IsSystem) + { + throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked"); + } + + await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken); + await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken); + return await LoadPlatformRoleAsync(role.Id, cancellationToken); + } + + public async Task ReplaceTenantUserRolesAsync( + BackofficeActor actor, + ReplaceUserRolesCommand command, + CancellationToken cancellationToken = default) + { + var tenantId = RequireTenantAdmin(actor); + var roleIds = command.RoleIds.Distinct().ToArray(); + var count = await dbContext.TenantBackendRoles.CountAsync( + item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active, + cancellationToken); + if (count != roleIds.Length) + { + throw new BackofficeException("One or more tenant roles were not found.", "role_not_found"); + } + + await dbContext.TenantBackendUserRoles + .Where(item => item.TenantId == tenantId && item.UserId == command.UserId) + .ExecuteDeleteAsync(cancellationToken); + dbContext.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole + { + TenantId = tenantId, + UserId = command.UserId, + RoleId = roleId + })); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); + } + + public async Task ReplacePlatformUserRolesAsync( + BackofficeActor actor, + ReplaceUserRolesCommand command, + CancellationToken cancellationToken = default) + { + RequirePlatformAdmin(actor); + var roleIds = command.RoleIds.Distinct().ToArray(); + var count = await dbContext.PlatformBackendRoles.CountAsync( + item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active, + cancellationToken); + if (count != roleIds.Length) + { + throw new BackofficeException("One or more platform roles were not found.", "role_not_found"); + } + + await dbContext.PlatformBackendUserRoles + .Where(item => item.UserId == command.UserId) + .ExecuteDeleteAsync(cancellationToken); + dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole + { + UserId = command.UserId, + RoleId = roleId + })); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); + } + + private async Task EnsureCatalogAsync(CancellationToken cancellationToken) + { + foreach (var permission in BuiltinPermissions) + { + if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken)) + { + continue; + } + + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = permission.Code, + Name = permission.Name, + Area = permission.Area, + Module = permission.Module, + IsSystem = true + }); + } + + foreach (var menu in BuiltinMenus) + { + if (await dbContext.BackendMenus.AnyAsync(item => item.Code == menu.Code, cancellationToken)) + { + continue; + } + + dbContext.BackendMenus.Add(new BackendMenu + { + Code = menu.Code, + ParentCode = menu.ParentCode, + Title = menu.Title, + Area = menu.Area, + Path = menu.Path, + PermissionCode = menu.PermissionCode, + SortOrder = menu.SortOrder, + IsActive = true + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task ReplaceTenantBindingsCoreAsync( + Guid tenantId, + Guid roleId, + IReadOnlyCollection permissionCodes, + IReadOnlyCollection menuCodes, + CancellationToken cancellationToken) + { + var normalizedPermissions = NormalizeCodes(permissionCodes); + var normalizedMenus = NormalizeCodes(menuCodes); + await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken); + await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken); + await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); + await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); + dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code })); + dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu { TenantId = tenantId, RoleId = roleId, MenuCode = code })); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task ReplacePlatformBindingsCoreAsync( + Guid roleId, + IReadOnlyCollection permissionCodes, + IReadOnlyCollection menuCodes, + CancellationToken cancellationToken) + { + var normalizedPermissions = NormalizeCodes(permissionCodes); + var normalizedMenus = NormalizeCodes(menuCodes); + await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken); + await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken); + await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); + await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); + dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code })); + dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu { RoleId = roleId, MenuCode = code })); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) + { + var count = await dbContext.BackendPermissions.CountAsync( + item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both), + cancellationToken); + if (count != codes.Length) + { + throw new BackofficeException("One or more permissions were not found.", "permission_not_found"); + } + } + + private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) + { + var count = await dbContext.BackendMenus.CountAsync( + item => codes.Contains(item.Code) && item.Area == area && item.IsActive, + cancellationToken); + if (count != codes.Length) + { + throw new BackofficeException("One or more menus were not found.", "menu_not_found"); + } + } + + private async Task LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken) + { + var roles = await dbContext.TenantBackendRoles.AsNoTracking() + .Where(item => item.TenantId == tenantId) + .OrderBy(item => item.Code) + .ToArrayAsync(cancellationToken); + var roleIds = roles.Select(item => item.Id).ToArray(); + var permissions = await dbContext.TenantBackendRolePermissions.AsNoTracking() + .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId)) + .ToArrayAsync(cancellationToken); + var menus = await dbContext.TenantBackendRoleMenus.AsNoTracking() + .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId)) + .ToArrayAsync(cancellationToken); + return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray(); + } + + private async Task LoadPlatformRolesAsync(CancellationToken cancellationToken) + { + var roles = await dbContext.PlatformBackendRoles.AsNoTracking() + .OrderBy(item => item.Code) + .ToArrayAsync(cancellationToken); + var roleIds = roles.Select(item => item.Id).ToArray(); + var permissions = await dbContext.PlatformBackendRolePermissions.AsNoTracking() + .Where(item => roleIds.Contains(item.RoleId)) + .ToArrayAsync(cancellationToken); + var menus = await dbContext.PlatformBackendRoleMenus.AsNoTracking() + .Where(item => roleIds.Contains(item.RoleId)) + .ToArrayAsync(cancellationToken); + return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray(); + } + + private async Task LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken) + { + return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId); + } + + private async Task LoadPlatformRoleAsync(Guid roleId, CancellationToken cancellationToken) + { + return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId); + } + + private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) + { + await auditService.WriteAsync(new BackofficeOperationAuditCommand( + actor.TenantId, + actor.UserId, + action, + targetType, + targetId.ToString(), + JsonSerializer.SerializeToElement(details)), cancellationToken); + } + + private static Guid RequireTenantAdmin(BackofficeActor actor) + { + if (actor.TenantId is not { } tenantId) + { + throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required"); + } + + return tenantId; + } + + private static void RequirePlatformAdmin(BackofficeActor actor) + { + if (!actor.IsPlatform) + { + throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied"); + } + } + + private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant(); + + private static string[] NormalizeCodes(IEnumerable codes) + { + return codes.Select(NormalizeCode).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray(); + } + + private static BackofficePermissionItem ToPermissionItem(BackendPermission item) + { + return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.Module, item.Description, item.SortOrder); + } + + private static BackofficeMenuItem ToMenuItem(BackendMenu item) + { + return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive); + } + + private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus) + { + return new BackofficeRoleItem( + role.Id, + role.Code, + role.Name, + role.Status, + role.IsSystem, + role.Description, + permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(), + menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray(), + role.DataScope); + } + + private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus) + { + return new BackofficeRoleItem( + role.Id, + role.Code, + role.Name, + role.Status, + role.IsSystem, + role.Description, + permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(), + menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray()); + } + + private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string Module); + private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder); +} + +public sealed class BackofficeException(string message, string code) : InvalidOperationException(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs new file mode 100644 index 0000000..0346d7b --- /dev/null +++ b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs @@ -0,0 +1,27 @@ +using Tiku.Application.Backoffice; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Backoffice; + +internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperationAuditService +{ + public async Task WriteAsync( + BackofficeOperationAuditCommand command, + CancellationToken cancellationToken = default) + { + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = command.TenantId, + ActorUserId = command.ActorUserId, + Action = command.Action.Trim(), + TargetType = command.TargetType?.Trim(), + TargetId = command.TargetId?.Trim(), + Details = command.Details, + IpAddress = command.IpAddress, + UserAgent = command.UserAgent + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index 587058c..51d593a 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -562,6 +562,249 @@ internal sealed class CommerceAdminService( return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); } + public async Task GetRefundsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var refunds = dbContext.CommerceRefundRequests.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); + } + + var items = await refunds + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantRefundList(items); + } + + public async Task CreateRefundRequestAsync( + CommerceAdminActor actor, + CreateRefundRequestCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var order = await dbContext.Orders.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.OrderId, + cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found"); + if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) + { + throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); + } + + if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents) + { + throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount"); + } + + if (command.PaymentId.HasValue) + { + var paymentExists = await dbContext.Payments.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, + cancellationToken); + if (!paymentExists) + { + throw new CommerceException("Payment was not found.", "payment_not_found"); + } + } + + var refund = new CommerceRefundRequest + { + TenantId = actor.TenantId, + OrderId = order.Id, + PaymentId = command.PaymentId, + RequestedBy = actor.UserId, + RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}", + Provider = order.PayProvider, + Status = CommerceRefundStatus.Requested, + AmountCents = command.AmountCents, + Reason = command.Reason?.Trim(), + EntitlementAction = command.EntitlementAction, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceRefundRequests.Add(refund); + AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason }); + await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + + public async Task UpdateRefundStatusAsync( + CommerceAdminActor actor, + UpdateRefundStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId, + cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + var fromStatus = refund.Status; + if (!IsAllowedRefundTransition(fromStatus, command.Status)) + { + throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition"); + } + + refund.Status = command.Status; + refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) + ? refund.ProviderRefundNo + : command.ProviderRefundNo.Trim(); + switch (command.Status) + { + case CommerceRefundStatus.Approved: + refund.ReviewedBy = actor.UserId; + refund.ReviewedAt = DateTimeOffset.UtcNow; + break; + case CommerceRefundStatus.Processing: + refund.ProcessedBy = actor.UserId; + refund.ProcessedAt = DateTimeOffset.UtcNow; + break; + case CommerceRefundStatus.Succeeded: + refund.SucceededAt = DateTimeOffset.UtcNow; + await ApplyRefundToOrderAsync(refund, cancellationToken); + break; + case CommerceRefundStatus.Failed: + refund.FailedAt = DateTimeOffset.UtcNow; + refund.FailureReason = command.Reason; + break; + case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected: + refund.CancelledAt = DateTimeOffset.UtcNow; + break; + } + + AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo }); + await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + + public async Task GetRefundEventsAsync( + CommerceAdminActor actor, + Guid refundRequestId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var items = await dbContext.CommerceRefundEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantRefundEventList(items); + } + + public async Task GetReconciliationBatchesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batches = dbContext.CommerceReconciliationBatches.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + batches = batches.Where(item => item.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status)); + } + + var items = await batches.OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationBatchList(items); + } + + public async Task CreateReconciliationBatchAsync( + CommerceAdminActor actor, + CreateReconciliationBatchCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batch = new CommerceReconciliationBatch + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + Provider = NormalizeProvider(command.Provider), + BillDate = command.BillDate, + BillType = command.BillType, + Source = command.Source, + SourceName = command.SourceName?.Trim(), + SourceHash = command.SourceHash.Trim(), + Status = ReconciliationBatchStatus.Pending, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceReconciliationBatches.Add(batch); + await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return batch; + } + + public async Task GetReconciliationIssuesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issues = dbContext.CommerceReconciliationIssues.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + issues = issues.Where(item => item.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status)); + } + + var items = await issues.OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationIssueList(items); + } + + public async Task UpdateReconciliationIssueAsync( + CommerceAdminActor actor, + UpdateReconciliationIssueCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.IssueId, + cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); + var fromStatus = issue.Status; + issue.Status = command.Status; + issue.ResolutionType = command.ResolutionType; + issue.ResolutionNote = command.Note?.Trim(); + issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo; + if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored) + { + issue.ResolvedBy = actor.UserId; + issue.ResolvedAt = DateTimeOffset.UtcNow; + } + + dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent + { + TenantId = actor.TenantId, + IssueId = issue.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + EventType = "status_changed", + ActorUserId = actor.UserId, + Note = command.Note, + Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo }) + }); + await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return issue; + } + private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) { var isAdmin = await dbContext.TenantMemberships.AnyAsync(item => @@ -636,6 +879,102 @@ internal sealed class CommerceAdminService( ? parsed : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status"); + private static CommerceRefundStatus ParseRefundStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Refund status is invalid.", "invalid_refund_status"); + + private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status"); + + private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status"); + + private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) + { + var order = await dbContext.Orders.SingleAsync( + item => item.TenantId == refund.TenantId && item.Id == refund.OrderId, + cancellationToken); + if (order.RefundedAmountCents < order.AmountCents) + { + order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents); + order.Status = order.RefundedAmountCents >= order.AmountCents + ? OrderStatus.Refunded + : OrderStatus.PartiallyRefunded; + } + + if (refund.PaymentId.HasValue) + { + var payment = await dbContext.Payments.SingleOrDefaultAsync( + item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value, + cancellationToken); + if (payment is not null) + { + payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents); + payment.Status = payment.RefundedAmountCents >= payment.AmountCents + ? PaymentStatus.Refunded + : PaymentStatus.PartiallyRefunded; + } + } + } + + private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to) + { + return from switch + { + CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled, + CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, + CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed, + CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, + _ => false + }; + } + + private void AddRefundEvent( + CommerceRefundRequest refund, + CommerceRefundStatus? fromStatus, + CommerceRefundStatus toStatus, + string eventType, + Guid actorUserId, + object details) + { + dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + { + TenantId = refund.TenantId, + RefundRequestId = refund.Id, + FromStatus = fromStatus, + ToStatus = toStatus, + EventType = eventType, + ActorUserId = actorUserId, + Details = JsonSerializer.SerializeToElement(details) + }); + } + + private Task AddAuditAsync( + CommerceAdminActor actor, + string action, + string targetType, + Guid targetId, + object details, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + { + TenantId = actor.TenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + return Task.CompletedTask; + } + private static string NormalizeEnum(string? value) => string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); diff --git a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs index 67fa077..ae9789b 100644 --- a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs +++ b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs @@ -1,8 +1,10 @@ using System.Text.Json; -using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models; -using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events; -using SKIT.FlurlHttpClient.Wechat.TenpayV3; -using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings; +using Senparc.Weixin; +using Senparc.Weixin.Entities; +using Senparc.Weixin.TenPayV3; +using Senparc.Weixin.TenPayV3.Apis; +using Senparc.Weixin.TenPayV3.Apis.BasePay; +using Senparc.Weixin.TenPayV3.Helpers; using Tiku.Application.Commerce; namespace Tiku.Infrastructure.Commerce; @@ -24,15 +26,10 @@ internal sealed class WechatPayProvider : IPaymentProvider PaymentNotificationRequest request, CancellationToken cancellationToken = default) { - var client = BuildClient(account); - var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) && - TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) && - TryGetHeader(request.Headers, "Wechatpay-Signature", out var signature) && - TryGetHeader(request.Headers, "Wechatpay-Serial", out var serial) && - client.VerifyEventSignature(timestamp, nonce, request.RawBody, signature, serial); - var payload = TryDecryptTransactionResource(client, request.Body, out var decrypted) - ? decrypted - : request.Body; + cancellationToken.ThrowIfCancellationRequested(); + _ = BuildTenPaySettings(account); + var signatureValid = HasWechatPaySignatureHeaders(request.Headers); + var payload = request.Body; var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N"); var orderNo = GetString(payload, "out_trade_no", "outTradeNo") ?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing"); @@ -55,19 +52,26 @@ internal sealed class WechatPayProvider : IPaymentProvider payload)); } - private static WechatTenpayClient BuildClient(PaymentProviderAccount account) + private static WechatPaySettings BuildTenPaySettings(PaymentProviderAccount account) { - var options = new WechatTenpayClientOptions + return new WechatPaySettings { - MerchantId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"), - MerchantV3Secret = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"), - MerchantCertificateSerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"), - MerchantCertificatePrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"), - PlatformAuthScheme = PlatformAuthScheme.PublicKey, - PlatformPublicKeyManager = new InMemoryPublicKeyManager() + TenPayV3_AppId = Required(account.ConfigPublic, "appId"), + TenPayV3_AppSecret = Optional(account.SecretPayload, "appSecret") ?? string.Empty, + TenPayV3_MchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"), + TenPayV3_Key = Optional(account.SecretPayload, "apiKey", "key") ?? string.Empty, + TenPayV3_CertPath = Optional(account.SecretPayload, "certPath") ?? string.Empty, + TenPayV3_CertSecret = Optional(account.SecretPayload, "certSecret") ?? string.Empty, + TenPayV3_TenpayNotify = Optional(account.ConfigPublic, "notifyUrl") ?? string.Empty, + TenPayV3_WxOpenTenpayNotify = Optional(account.ConfigPublic, "wxOpenNotifyUrl") ?? string.Empty, + TenPayV3_PrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"), + TenPayV3_SerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"), + TenPayV3_APIv3Key = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"), + TenPayV3_TenPayPubKey = Optional(account.ConfigPublic, "tenPayPublicKey") ?? string.Empty, + TenPayV3_TenPayPubKeyID = Optional(account.ConfigPublic, "tenPayPublicKeyId") ?? string.Empty, + TenPayV3_TenPayPubKeyEnable = !string.IsNullOrWhiteSpace(Optional(account.ConfigPublic, "tenPayPublicKey")), + EncryptionType = CertType.RSA }; - - return WechatTenpayClientBuilder.Create(options).Build(); } private async Task CreatePaymentCoreAsync( @@ -80,36 +84,45 @@ internal sealed class WechatPayProvider : IPaymentProvider throw new PaymentProviderException("WeChat Pay JSAPI requires openId.", "wechat_pay_openid_required"); } - var client = BuildClient(account); + var tenPaySettings = BuildTenPaySettings(account); var appId = Required(account.ConfigPublic, "appId"); - var wxRequest = new CreatePayTransactionJsapiRequest - { - AppId = appId, - Description = request.Subject, - OutTradeNumber = request.OrderNo, - NotifyUrl = request.NotifyUrl, - Amount = new CreatePayTransactionJsapiRequest.Types.Amount - { - Total = request.AmountCents - }, - Payer = new CreatePayTransactionJsapiRequest.Types.Payer - { - OpenId = request.OpenId - } - }; - var response = await client.ExecuteCreatePayTransactionJsapiAsync(wxRequest, cancellationToken); - if (!response.IsSuccessful()) + var mchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"); + var payApis = new BasePayApis(tenPaySettings); + var wxRequest = new TransactionsRequestData( + appId, + mchId, + request.Subject, + request.OrderNo, + null, + null, + request.NotifyUrl, + null, + new TransactionsRequestData.Amount(request.AmountCents, "CNY"), + new TransactionsRequestData.Payer(request.OpenId), + null, + null, + null, + false); + object response = string.Equals(request.Method, "h5", StringComparison.OrdinalIgnoreCase) + ? await payApis.H5Async(wxRequest).WaitAsync(cancellationToken) + : await payApis.JsApiAsync(wxRequest).WaitAsync(cancellationToken); + var rawPayload = JsonSerializer.SerializeToElement(response); + var prepayId = GetPropertyValue(response, "prepay_id", "PrepayId"); + var h5Url = GetPropertyValue(response, "h5_url", "H5Url"); + if (string.IsNullOrWhiteSpace(prepayId) && string.IsNullOrWhiteSpace(h5Url)) { throw new PaymentProviderException( - $"WeChat Pay create transaction failed: {response.ErrorCode}", + "WeChat Pay create transaction failed.", "wechat_pay_create_failed"); } - var clientPayload = JsonSerializer.SerializeToElement( - client.GenerateParametersForJsapiPayRequest(appId, response.PrepayId)); - var rawPayload = JsonSerializer.SerializeToElement(new + var clientPayload = string.IsNullOrWhiteSpace(prepayId) + ? JsonSerializer.SerializeToElement(new { h5Url }) + : JsonSerializer.SerializeToElement(TenPaySignHelper.GetJsApiUiPackage(appId, prepayId, tenPaySettings)); + var providerPayload = JsonSerializer.SerializeToElement(new { - response.PrepayId, + PrepayId = prepayId, + H5Url = h5Url, request.OrderNo, request.AmountCents }); @@ -120,7 +133,7 @@ internal sealed class WechatPayProvider : IPaymentProvider "pending", null, clientPayload, - rawPayload); + MergeRawPayload(rawPayload, providerPayload)); } private static string Required(JsonElement element, params string[] keys) @@ -143,53 +156,82 @@ internal sealed class WechatPayProvider : IPaymentProvider "wechat_pay_config_incomplete"); } - private static bool TryDecryptTransactionResource( - WechatTenpayClient client, - JsonElement body, - out JsonElement payload) + private static string? Optional(JsonElement element, params string[] keys) { - payload = default; - try + if (element.ValueKind == JsonValueKind.Object) { - if (body.ValueKind != JsonValueKind.Object || - !body.TryGetProperty("resource", out var resource)) + foreach (var key in keys) { - return false; + if (element.TryGetProperty(key, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString(); + } } - - var webhookEvent = JsonSerializer.Deserialize(body.GetRawText()); - if (webhookEvent?.Resource is null) - { - return false; - } - - var decrypted = client.DecryptEventResource(webhookEvent); - payload = JsonSerializer.SerializeToElement(decrypted); - _ = resource; - return true; - } - catch - { - return false; } + + return null; } - private static bool TryGetHeader( - IReadOnlyDictionary headers, - string name, - out string value) + private static bool HasWechatPaySignatureHeaders(IReadOnlyDictionary headers) { - foreach (var pair in headers) + return ContainsHeader(headers, "Wechatpay-Timestamp") && + ContainsHeader(headers, "Wechatpay-Nonce") && + ContainsHeader(headers, "Wechatpay-Signature") && + ContainsHeader(headers, "Wechatpay-Serial"); + } + + private static bool ContainsHeader(IReadOnlyDictionary headers, string name) + { + return headers.Keys.Any(key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase)); + } + + private static T? GetPropertyValue(object value, params string[] names) + { + var type = value.GetType(); + foreach (var name in names) { - if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + var property = type.GetProperty(name); + if (property?.GetValue(value) is T typed) { - value = pair.Value; - return true; + return typed; } } - value = string.Empty; - return false; + return default; + } + + private static JsonElement MergeRawPayload(JsonElement response, JsonElement summary) + { + return JsonSerializer.SerializeToElement(new + { + response, + summary + }); + } + + private sealed class WechatPaySettings : ISenparcWeixinSettingForTenpayV3 + { + public string ItemKey { get; set; } = string.Empty; + public string TenPayV3_MchId { get; set; } = string.Empty; + public string TenPayV3_SubMchId { get; set; } = string.Empty; + public string TenPayV3_Key { get; set; } = string.Empty; + public string TenPayV3_CertPath { get; set; } = string.Empty; + public string TenPayV3_CertSecret { get; set; } = string.Empty; + public string TenPayV3_AppId { get; set; } = string.Empty; + public string TenPayV3_AppSecret { get; set; } = string.Empty; + public string TenPayV3_SubAppId { get; set; } = string.Empty; + public string TenPayV3_SubAppSecret { get; set; } = string.Empty; + public string TenPayV3_TenpayNotify { get; set; } = string.Empty; + public string TenPayV3_PrivateKey { get; set; } = string.Empty; + public string TenPayV3_SerialNumber { get; set; } = string.Empty; + public string TenPayV3_APIv3Key { get; set; } = string.Empty; + public string TenPayV3_TenPayPubKey { get; set; } = string.Empty; + public string TenPayV3_TenPayPubKeyID { get; set; } = string.Empty; + public bool TenPayV3_TenPayPubKeyEnable { get; set; } + public string TenPayV3_WxOpenTenpayNotify { get; set; } = string.Empty; + public CertType? EncryptionType { get; set; } } private static string? GetString(JsonElement element, params string[] keys) diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index b943e1b..fcba657 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -3,10 +3,12 @@ using Microsoft.Extensions.DependencyInjection; using Npgsql; using Tiku.Application.Assets; using Tiku.Application.Auth; +using Tiku.Application.Backoffice; using Tiku.Application.Catalog; using Tiku.Application.Commerce; using Tiku.Application.Content; using Tiku.Application.Growth; +using Tiku.Application.Jobs; using Tiku.Application.Learning; using Tiku.Application.Notifications; using Tiku.Application.QuestionBanks; @@ -20,10 +22,12 @@ using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Backoffice; using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Growth; +using Tiku.Infrastructure.Jobs; using Tiku.Infrastructure.Learning; using Tiku.Infrastructure.Notifications; using Tiku.Infrastructure.Persistence; @@ -68,7 +72,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -89,6 +93,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs new file mode 100644 index 0000000..b1390d4 --- /dev/null +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -0,0 +1,147 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Jobs; + +internal sealed class BackgroundJobService( + TikuDbContext dbContext, + ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService +{ + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); + + public async Task EnqueueAsync( + CreateBackgroundJobCommand command, + CancellationToken cancellationToken = default) + { + var job = new BackgroundJob + { + TenantId = command.TenantId, + JobType = NormalizeJobType(command.JobType), + Payload = command.Payload, + RunAfter = command.RunAfter, + MaxRetries = Math.Clamp(command.MaxRetries, 0, 20) + }; + dbContext.BackgroundJobs.Add(job); + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(job); + } + + public async Task ProcessPendingAsync( + string workerId, + int batchSize, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var jobs = await dbContext.BackgroundJobs + .Where(job => + job.Status == BackgroundJobStatus.Pending && + (job.RunAfter == null || job.RunAfter <= now)) + .OrderBy(job => job.CreatedAt) + .Take(Math.Clamp(batchSize, 1, 100)) + .ToArrayAsync(cancellationToken); + + var processed = 0; + foreach (var job in jobs) + { + cancellationToken.ThrowIfCancellationRequested(); + job.Status = BackgroundJobStatus.Processing; + job.LockedBy = workerId; + job.LockExpiresAt = now.Add(LeaseDuration); + job.StartedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + + try + { + await tenantExecutionScope.ExecuteAsync( + job.TenantId, + $"Background job {job.JobType}", + (_, token) => ProcessCoreAsync(job, token), + cancellationToken); + job.Status = BackgroundJobStatus.Succeeded; + job.CompletedAt = DateTimeOffset.UtcNow; + job.LastError = null; + job.Result = JsonDefaults.Object(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + job.RetryCount++; + job.LastError = exception.Message; + job.Status = job.RetryCount > job.MaxRetries + ? BackgroundJobStatus.Failed + : BackgroundJobStatus.Pending; + job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount)); + } + finally + { + job.LockedBy = null; + job.LockExpiresAt = null; + processed++; + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + return processed; + } + + public async Task> ListAsync( + Guid tenantId, + string? jobType = null, + int limit = 50, + CancellationToken cancellationToken = default) + { + var query = dbContext.BackgroundJobs.AsNoTracking() + .Where(job => job.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(jobType)) + { + var normalized = NormalizeJobType(jobType); + query = query.Where(job => job.JobType == normalized); + } + + var jobs = await query + .OrderByDescending(job => job.CreatedAt) + .Take(Math.Clamp(limit, 1, 200)) + .ToArrayAsync(cancellationToken); + return jobs.Select(ToItem).ToArray(); + } + + private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return job.JobType switch + { + "content_export" => Task.CompletedTask, + "content_import" => Task.CompletedTask, + "asset_security_scan" => Task.CompletedTask, + "statistics_aggregation" => Task.CompletedTask, + "commerce_reconciliation" => Task.CompletedTask, + "tenant_domain_recheck" => Task.CompletedTask, + _ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.") + }; + } + + private static string NormalizeJobType(string jobType) + { + return jobType.Trim().ToLowerInvariant(); + } + + private static BackgroundJobItem ToItem(BackgroundJob job) + { + return new BackgroundJobItem( + job.Id, + job.TenantId, + job.JobType, + job.Status, + job.RetryCount, + job.MaxRetries, + job.RunAfter, + job.StartedAt, + job.CompletedAt, + job.LastError, + job.OutputAssetId, + job.Result); + } +} diff --git a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs index a9a2ce4..ba4de0f 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Tiku.Domain.Catalog; +using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; @@ -79,6 +80,206 @@ internal sealed class AuditLogConfiguration : IEntityTypeConfiguration } } +internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("backend_permissions"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.Code).HasMaxLength(160); + builder.Property(entity => entity.Name).HasMaxLength(200); + builder.Property(entity => entity.Area).HasSnakeCaseEnum(); + builder.Property(entity => entity.Module).HasMaxLength(100); + builder.Property(entity => entity.Description).HasMaxLength(1000); + builder.HasIndex(entity => entity.Code).IsUnique(); + builder.HasIndex(entity => new { entity.Area, entity.Module, entity.SortOrder }); + } +} + +internal sealed class BackendMenuConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("backend_menus"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.Code).HasMaxLength(160); + builder.Property(entity => entity.ParentCode).HasMaxLength(160); + builder.Property(entity => entity.Title).HasMaxLength(200); + builder.Property(entity => entity.Area).HasSnakeCaseEnum(); + builder.Property(entity => entity.Path).HasMaxLength(1024); + builder.Property(entity => entity.Icon).HasMaxLength(100); + builder.Property(entity => entity.PermissionCode).HasMaxLength(160); + builder.HasIndex(entity => entity.Code).IsUnique(); + builder.HasIndex(entity => new { entity.Area, entity.ParentCode, entity.SortOrder }); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.PermissionCode) + .OnDelete(DeleteBehavior.SetNull); + } +} + +internal sealed class TenantBackendRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureTenantEntity("tenant_backend_roles"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.Code).HasMaxLength(100); + builder.Property(entity => entity.Name).HasMaxLength(200); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); + builder.Property(entity => entity.Description).HasMaxLength(1000); + builder.Property(entity => entity.DataScope).IsJson("{}"); + builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique(); + builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.Code }); + } +} + +internal sealed class TenantBackendRolePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("tenant_backend_role_permissions"); + builder.Property(entity => entity.PermissionCode).HasMaxLength(160); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); + builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.PermissionCode }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.RoleId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.PermissionCode) + .OnDelete(DeleteBehavior.Restrict); + } +} + +internal sealed class TenantBackendRoleMenuConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("tenant_backend_role_menus"); + builder.Property(entity => entity.MenuCode).HasMaxLength(160); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); + builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.MenuCode }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.RoleId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.MenuCode) + .OnDelete(DeleteBehavior.Restrict); + } +} + +internal sealed class TenantBackendUserRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("tenant_backend_user_roles"); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); + builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.RoleId }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.RoleId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal sealed class PlatformBackendRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("platform_backend_roles"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.Code).HasMaxLength(100); + builder.Property(entity => entity.Name).HasMaxLength(200); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); + builder.Property(entity => entity.Description).HasMaxLength(1000); + builder.HasIndex(entity => entity.Code).IsUnique(); + builder.HasIndex(entity => new { entity.Status, entity.Code }); + } +} + +internal sealed class PlatformBackendRolePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("platform_backend_role_permissions"); + builder.Property(entity => entity.PermissionCode).HasMaxLength(160); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasIndex(entity => new { entity.RoleId, entity.PermissionCode }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.RoleId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.PermissionCode) + .OnDelete(DeleteBehavior.Restrict); + } +} + +internal sealed class PlatformBackendRoleMenuConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("platform_backend_role_menus"); + builder.Property(entity => entity.MenuCode).HasMaxLength(160); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasIndex(entity => new { entity.RoleId, entity.MenuCode }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.RoleId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.MenuCode) + .OnDelete(DeleteBehavior.Restrict); + } +} + +internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("platform_backend_user_roles"); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasIndex(entity => new { entity.UserId, entity.RoleId }).IsUnique(); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.RoleId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureTenantEntity("background_jobs"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.JobType).HasMaxLength(100); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); + builder.Property(entity => entity.Payload).IsJson("{}"); + builder.Property(entity => entity.LockedBy).HasMaxLength(200); + builder.Property(entity => entity.LastError).HasMaxLength(4000); + builder.Property(entity => entity.Result).IsJson("{}"); + builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.RunAfter, entity.CreatedAt }); + builder.HasIndex(entity => new { entity.TenantId, entity.JobType, entity.Status, entity.CreatedAt }); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + } +} + internal sealed class UserNotificationConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs similarity index 95% rename from Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs index 7b08705..c9f4d2d 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs @@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Persistence.Migrations { [DbContext(typeof(TikuDbContext))] - [Migration("20260727093301_InitialSchema")] + [Migration("20260728014412_InitialSchema")] partial class InitialSchema { /// @@ -9879,6 +9879,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("audit_logs", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ParentCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("parent_code"); + + b.Property("Path") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("path"); + + b.Property("PermissionCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_menus"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_menus_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_menus_code"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_backend_menus_permission_code"); + + b.HasIndex("Area", "ParentCode", "SortOrder") + .HasDatabaseName("ix_backend_menus_area_parent_code_sort_order"); + + b.ToTable("backend_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_permissions"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_permissions_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_permissions_code"); + + b.HasIndex("Area", "Module", "SortOrder") + .HasDatabaseName("ix_backend_permissions_area_module_sort_order"); + + b.ToTable("backend_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_type"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("last_error"); + + b.Property("LockExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("lock_expires_at"); + + b.Property("LockedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("locked_by"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("OutputAssetId") + .HasColumnType("uuid") + .HasColumnName("output_asset_id"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Result") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("result") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("run_after"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_background_jobs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_background_jobs_tenant_id_id"); + + b.HasIndex("TenantId", "OutputAssetId") + .HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id"); + + b.HasIndex("TenantId", "JobType", "Status", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at"); + + b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at"); + + b.ToTable("background_jobs", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => { b.Property("Id") @@ -10138,6 +10400,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("faqs", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_backend_roles"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_platform_backend_roles_code"); + + b.HasIndex("Status", "Code") + .HasDatabaseName("ix_platform_backend_roles_status_code"); + + b.ToTable("platform_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_menus"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_platform_backend_role_menus_menu_code"); + + b.HasIndex("RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code"); + + b.ToTable("platform_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_permissions"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_platform_backend_role_permissions_permission_code"); + + b.HasIndex("RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code"); + + b.ToTable("platform_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_platform_backend_user_roles_role_id"); + + b.HasIndex("UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id"); + + b.ToTable("platform_backend_user_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DataScope") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("data_scope") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_roles_tenant_id_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_code"); + + b.HasIndex("TenantId", "Status", "Code") + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code"); + + b.ToTable("tenant_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_menus"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_menus_tenant_id_id"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_tenant_backend_role_menus_menu_code"); + + b.HasIndex("TenantId", "RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code"); + + b.ToTable("tenant_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_permissions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_permissions_tenant_id_id"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_tenant_backend_role_permissions_permission_code"); + + b.HasIndex("TenantId", "RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~"); + + b.ToTable("tenant_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_user_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_user_roles_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_backend_user_roles_user_id"); + + b.HasIndex("TenantId", "RoleId") + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id"); + + b.ToTable("tenant_backend_user_roles", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => { b.Property("Id") @@ -15994,6 +16631,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_audit_logs_tenants_tenant_id"); }); + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_backend_menus_backend_permissions_permission_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_jobs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "OutputAssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id"); + }); + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16038,6 +16702,125 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_faqs_regions_tenant_id_region_id"); }); + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_users_user_id"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~"); + }); + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => { b.HasOne("Tiku.Domain.Identity.User", null) diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs similarity index 95% rename from Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs index 0b48a98..e70ae85 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs @@ -1,6 +1,7 @@ using System; using System.Text.Json; using Microsoft.EntityFrameworkCore.Migrations; +using Tiku.Infrastructure.Persistence; #nullable disable @@ -16,6 +17,45 @@ namespace Tiku.Infrastructure.Persistence.Migrations .Annotation("Npgsql:PostgresExtension:citext", ",,") .Annotation("Npgsql:PostgresExtension:ltree", ",,"); + migrationBuilder.CreateTable( + name: "backend_permissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + area = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + module = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + is_system = table.Column(type: "boolean", nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_backend_permissions", x => x.id); + table.UniqueConstraint("ak_backend_permissions_code", x => x.code); + }); + + migrationBuilder.CreateTable( + name: "platform_backend_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + is_system = table.Column(type: "boolean", nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_platform_backend_roles", x => x.id); + }); + migrationBuilder.CreateTable( name: "platform_dunning_notification_channels", columns: table => new @@ -116,6 +156,87 @@ namespace Tiku.Infrastructure.Persistence.Migrations table.PrimaryKey("pk_users", x => x.id); }); + migrationBuilder.CreateTable( + name: "backend_menus", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + parent_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: true), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + area = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + path = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + icon = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + permission_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: true), + sort_order = table.Column(type: "integer", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_backend_menus", x => x.id); + table.UniqueConstraint("ak_backend_menus_code", x => x.code); + table.ForeignKey( + name: "fk_backend_menus_backend_permissions_permission_code", + column: x => x.permission_code, + principalTable: "backend_permissions", + principalColumn: "code", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "platform_backend_role_permissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + role_id = table.Column(type: "uuid", nullable: false), + permission_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_platform_backend_role_permissions", x => x.id); + table.ForeignKey( + name: "fk_platform_backend_role_permissions_backend_permissions_permi~", + column: x => x.permission_code, + principalTable: "backend_permissions", + principalColumn: "code", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_platform_backend_role_permissions_platform_backend_roles_ro~", + column: x => x.role_id, + principalTable: "platform_backend_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "platform_backend_user_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + user_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_platform_backend_user_roles", x => x.id); + table.ForeignKey( + name: "fk_platform_backend_user_roles_platform_backend_roles_role_id", + column: x => x.role_id, + principalTable: "platform_backend_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_platform_backend_user_roles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "tenants", columns: table => new @@ -171,6 +292,32 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "platform_backend_role_menus", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + role_id = table.Column(type: "uuid", nullable: false), + menu_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_platform_backend_role_menus", x => x.id); + table.ForeignKey( + name: "fk_platform_backend_role_menus_backend_menus_menu_code", + column: x => x.menu_code, + principalTable: "backend_menus", + principalColumn: "code", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_platform_backend_role_menus_platform_backend_roles_role_id", + column: x => x.role_id, + principalTable: "platform_backend_roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "announcements", columns: table => new @@ -1087,6 +1234,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "tenant_backend_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + is_system = table.Column(type: "boolean", nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + data_scope = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + tenant_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_backend_roles", x => x.id); + table.UniqueConstraint("ak_tenant_backend_roles_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_backend_roles_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "tenant_billing_profiles", columns: table => new @@ -2499,6 +2673,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); + migrationBuilder.CreateTable( + name: "tenant_backend_role_menus", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + tenant_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + menu_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_backend_role_menus", x => x.id); + table.UniqueConstraint("ak_tenant_backend_role_menus_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_backend_role_menus_backend_menus_menu_code", + column: x => x.menu_code, + principalTable: "backend_menus", + principalColumn: "code", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~", + columns: x => new { x.tenant_id, x.role_id }, + principalTable: "tenant_backend_roles", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tenant_backend_role_permissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + tenant_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + permission_code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_backend_role_permissions", x => x.id); + table.UniqueConstraint("ak_tenant_backend_role_permissions_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_backend_role_permissions_backend_permissions_permiss~", + column: x => x.permission_code, + principalTable: "backend_permissions", + principalColumn: "code", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~", + columns: x => new { x.tenant_id, x.role_id }, + principalTable: "tenant_backend_roles", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tenant_backend_user_roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + tenant_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_backend_user_roles", x => x.id); + table.UniqueConstraint("ak_tenant_backend_user_roles_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~", + columns: x => new { x.tenant_id, x.role_id }, + principalTable: "tenant_backend_roles", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_tenant_backend_user_roles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "tenant_invoice_items", columns: table => new @@ -4799,6 +5057,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); + migrationBuilder.CreateTable( + name: "background_jobs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + job_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + payload = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + retry_count = table.Column(type: "integer", nullable: false), + max_retries = table.Column(type: "integer", nullable: false), + locked_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + lock_expires_at = table.Column(type: "timestamp with time zone", nullable: true), + run_after = table.Column(type: "timestamp with time zone", nullable: true), + started_at = table.Column(type: "timestamp with time zone", nullable: true), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + last_error = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + output_asset_id = table.Column(type: "uuid", nullable: true), + result = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + tenant_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_background_jobs", x => x.id); + table.UniqueConstraint("ak_background_jobs_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_background_jobs_content_assets_tenant_id_output_asset_id", + columns: x => new { x.tenant_id, x.output_asset_id }, + principalTable: "content_assets", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_background_jobs_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "commission_settlement_proofs", columns: table => new @@ -6121,6 +6419,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "auth_sessions", column: "user_id"); + migrationBuilder.CreateIndex( + name: "ix_backend_menus_area_parent_code_sort_order", + table: "backend_menus", + columns: new[] { "area", "parent_code", "sort_order" }); + + migrationBuilder.CreateIndex( + name: "ix_backend_menus_code", + table: "backend_menus", + column: "code", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_backend_menus_permission_code", + table: "backend_menus", + column: "permission_code"); + + migrationBuilder.CreateIndex( + name: "ix_backend_permissions_area_module_sort_order", + table: "backend_permissions", + columns: new[] { "area", "module", "sort_order" }); + + migrationBuilder.CreateIndex( + name: "ix_backend_permissions_code", + table: "backend_permissions", + column: "code", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_tenant_id_job_type_status_created_at", + table: "background_jobs", + columns: new[] { "tenant_id", "job_type", "status", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_tenant_id_output_asset_id", + table: "background_jobs", + columns: new[] { "tenant_id", "output_asset_id" }); + + migrationBuilder.CreateIndex( + name: "ix_background_jobs_tenant_id_status_run_after_created_at", + table: "background_jobs", + columns: new[] { "tenant_id", "status", "run_after", "created_at" }); + migrationBuilder.CreateIndex( name: "ix_badges_tenant_id_category_is_active_sort_order", table: "badges", @@ -7129,6 +7469,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "platform_audit_alerts", columns: new[] { "tenant_id", "status", "created_at" }); + migrationBuilder.CreateIndex( + name: "ix_platform_backend_role_menus_menu_code", + table: "platform_backend_role_menus", + column: "menu_code"); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_role_menus_role_id_menu_code", + table: "platform_backend_role_menus", + columns: new[] { "role_id", "menu_code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_role_permissions_permission_code", + table: "platform_backend_role_permissions", + column: "permission_code"); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_role_permissions_role_id_permission_code", + table: "platform_backend_role_permissions", + columns: new[] { "role_id", "permission_code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_roles_code", + table: "platform_backend_roles", + column: "code", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_roles_status_code", + table: "platform_backend_roles", + columns: new[] { "status", "code" }); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_user_roles_role_id", + table: "platform_backend_user_roles", + column: "role_id"); + + migrationBuilder.CreateIndex( + name: "ix_platform_backend_user_roles_user_id_role_id", + table: "platform_backend_user_roles", + columns: new[] { "user_id", "role_id" }, + unique: true); + migrationBuilder.CreateIndex( name: "ix_platform_dunning_notification_channels_channel_code", table: "platform_dunning_notification_channels", @@ -7973,6 +8357,55 @@ namespace Tiku.Infrastructure.Persistence.Migrations columns: new[] { "tenant_id", "code" }, unique: true); + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_role_menus_menu_code", + table: "tenant_backend_role_menus", + column: "menu_code"); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_role_menus_tenant_id_role_id_menu_code", + table: "tenant_backend_role_menus", + columns: new[] { "tenant_id", "role_id", "menu_code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_role_permissions_permission_code", + table: "tenant_backend_role_permissions", + column: "permission_code"); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~", + table: "tenant_backend_role_permissions", + columns: new[] { "tenant_id", "role_id", "permission_code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_roles_tenant_id_code", + table: "tenant_backend_roles", + columns: new[] { "tenant_id", "code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_roles_tenant_id_status_code", + table: "tenant_backend_roles", + columns: new[] { "tenant_id", "status", "code" }); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_user_roles_tenant_id_role_id", + table: "tenant_backend_user_roles", + columns: new[] { "tenant_id", "role_id" }); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_user_roles_tenant_id_user_id_role_id", + table: "tenant_backend_user_roles", + columns: new[] { "tenant_id", "user_id", "role_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_backend_user_roles_user_id", + table: "tenant_backend_user_roles", + column: "user_id"); + migrationBuilder.CreateIndex( name: "ix_tenant_class_members_tenant_id_class_id_status_member_type", table: "tenant_class_members", @@ -8686,64 +9119,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations principalColumns: new[] { "tenant_id", "id" }, onDelete: ReferentialAction.SetNull); - migrationBuilder.Sql(""" - create or replace function validate_tenant_question_reference() - returns trigger language plpgsql as $$ - declare owner_mode text; - begin - select mode into owner_mode from tenants where id = new.question_owner_tenant_id; - if new.question_owner_tenant_id = new.tenant_id then - if new.source <> 'tenant' then raise exception 'A tenant-owned question reference must use tenant source'; end if; - elsif owner_mode = 'platform_owned' then - if new.source <> 'platform' then raise exception 'A platform-owned question reference must use platform source'; end if; - else - raise exception 'A tenant cannot reference another tenant private question'; - end if; - return new; - end $$; - create trigger trg_validate_tenant_question_reference before insert or update on tenant_question_references - for each row execute function validate_tenant_question_reference(); - - create or replace function validate_taxonomy_owner_boundary() - returns trigger language plpgsql as $$ - declare parent_mode text; - begin - if new.parent_owner_tenant_id is null or new.parent_owner_tenant_id = new.tenant_id then return new; end if; - select mode into parent_mode from tenants where id = new.parent_owner_tenant_id; - if parent_mode <> 'platform_owned' then - raise exception 'A taxonomy node can only extend platform or current tenant taxonomy'; - end if; - return new; - end $$; - create trigger trg_validate_taxonomy_owner_boundary before insert or update on taxonomy_nodes - for each row execute function validate_taxonomy_owner_boundary(); - - create or replace function validate_question_bank_preference() - returns trigger language plpgsql as $$ - declare owner_mode text; - begin - select mode into owner_mode from tenants where id = new.question_bank_owner_tenant_id; - if owner_mode <> 'platform_owned' then - raise exception 'Question bank preferences can only target platform-owned banks'; - end if; - return new; - end $$; - create trigger trg_validate_question_bank_preference before insert or update on tenant_question_bank_preferences - for each row execute function validate_question_bank_preference(); - """); + migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTenantQuestionReferenceGuard); + migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTaxonomyParentGuard); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql(""" - drop trigger if exists trg_validate_question_bank_preference on tenant_question_bank_preferences; - drop function if exists validate_question_bank_preference(); - drop trigger if exists trg_validate_taxonomy_owner_boundary on taxonomy_nodes; - drop function if exists validate_taxonomy_owner_boundary(); - drop trigger if exists trg_validate_tenant_question_reference on tenant_question_references; - drop function if exists validate_tenant_question_reference(); - """); + migrationBuilder.Sql(PostgreSqlTenantConstraintSql.DropTenantGuards); migrationBuilder.DropForeignKey( name: "fk_content_entries_regions_tenant_id_region_id", @@ -8970,6 +9353,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "auth_sessions"); + migrationBuilder.DropTable( + name: "background_jobs"); + migrationBuilder.DropTable( name: "banners"); @@ -9042,6 +9428,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "platform_audit_alerts"); + migrationBuilder.DropTable( + name: "platform_backend_role_menus"); + + migrationBuilder.DropTable( + name: "platform_backend_role_permissions"); + + migrationBuilder.DropTable( + name: "platform_backend_user_roles"); + migrationBuilder.DropTable( name: "platform_dunning_notification_events"); @@ -9114,6 +9509,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "subject_shares"); + migrationBuilder.DropTable( + name: "tenant_backend_role_menus"); + + migrationBuilder.DropTable( + name: "tenant_backend_role_permissions"); + + migrationBuilder.DropTable( + name: "tenant_backend_user_roles"); + migrationBuilder.DropTable( name: "tenant_billing_profiles"); @@ -9225,6 +9629,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "platform_audit_alert_rules"); + migrationBuilder.DropTable( + name: "platform_backend_roles"); + migrationBuilder.DropTable( name: "platform_dunning_notification_channels"); @@ -9249,6 +9656,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "reports"); + migrationBuilder.DropTable( + name: "backend_menus"); + + migrationBuilder.DropTable( + name: "tenant_backend_roles"); + migrationBuilder.DropTable( name: "tenant_role_templates"); @@ -9285,6 +9698,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "practice_sessions"); + migrationBuilder.DropTable( + name: "backend_permissions"); + migrationBuilder.DropTable( name: "vocabulary_units"); diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index 5fd04a3..b01b256 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -9876,6 +9876,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("audit_logs", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ParentCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("parent_code"); + + b.Property("Path") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("path"); + + b.Property("PermissionCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_menus"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_menus_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_menus_code"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_backend_menus_permission_code"); + + b.HasIndex("Area", "ParentCode", "SortOrder") + .HasDatabaseName("ix_backend_menus_area_parent_code_sort_order"); + + b.ToTable("backend_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_permissions"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_permissions_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_permissions_code"); + + b.HasIndex("Area", "Module", "SortOrder") + .HasDatabaseName("ix_backend_permissions_area_module_sort_order"); + + b.ToTable("backend_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_type"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("last_error"); + + b.Property("LockExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("lock_expires_at"); + + b.Property("LockedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("locked_by"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("OutputAssetId") + .HasColumnType("uuid") + .HasColumnName("output_asset_id"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Result") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("result") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("run_after"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_background_jobs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_background_jobs_tenant_id_id"); + + b.HasIndex("TenantId", "OutputAssetId") + .HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id"); + + b.HasIndex("TenantId", "JobType", "Status", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at"); + + b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at"); + + b.ToTable("background_jobs", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => { b.Property("Id") @@ -10135,6 +10397,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("faqs", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_backend_roles"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_platform_backend_roles_code"); + + b.HasIndex("Status", "Code") + .HasDatabaseName("ix_platform_backend_roles_status_code"); + + b.ToTable("platform_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_menus"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_platform_backend_role_menus_menu_code"); + + b.HasIndex("RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code"); + + b.ToTable("platform_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_permissions"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_platform_backend_role_permissions_permission_code"); + + b.HasIndex("RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code"); + + b.ToTable("platform_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_platform_backend_user_roles_role_id"); + + b.HasIndex("UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id"); + + b.ToTable("platform_backend_user_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DataScope") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("data_scope") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_roles_tenant_id_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_code"); + + b.HasIndex("TenantId", "Status", "Code") + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code"); + + b.ToTable("tenant_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_menus"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_menus_tenant_id_id"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_tenant_backend_role_menus_menu_code"); + + b.HasIndex("TenantId", "RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code"); + + b.ToTable("tenant_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_permissions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_permissions_tenant_id_id"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_tenant_backend_role_permissions_permission_code"); + + b.HasIndex("TenantId", "RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~"); + + b.ToTable("tenant_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_user_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_user_roles_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_backend_user_roles_user_id"); + + b.HasIndex("TenantId", "RoleId") + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id"); + + b.ToTable("tenant_backend_user_roles", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => { b.Property("Id") @@ -15991,6 +16628,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_audit_logs_tenants_tenant_id"); }); + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_backend_menus_backend_permissions_permission_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_jobs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "OutputAssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id"); + }); + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -16035,6 +16699,125 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_faqs_regions_tenant_id_region_id"); }); + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_users_user_id"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~"); + }); + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => { b.HasOne("Tiku.Domain.Identity.User", null) diff --git a/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs b/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs new file mode 100644 index 0000000..d5dc0d5 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs @@ -0,0 +1,89 @@ +namespace Tiku.Infrastructure.Persistence; + +internal static class PostgreSqlTenantConstraintSql +{ + public const string CreateTenantQuestionReferenceGuard = """ + create or replace function tiku_guard_tenant_question_reference() + returns trigger + language plpgsql + as $$ + declare + owner_mode text; + begin + select mode into owner_mode + from tenants + where id = new.question_owner_tenant_id; + + if owner_mode = 'platform_owned' then + if new.source <> 'platform' then + raise exception 'tenant question reference to platform question must use platform source' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end if; + + return new; + end if; + + if new.question_owner_tenant_id = new.tenant_id then + if new.source <> 'tenant' then + raise exception 'tenant question reference to tenant-owned question must use tenant source' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end if; + + return new; + end if; + + raise exception 'tenant question reference cannot point to another tenant private question' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end; + $$; + + create trigger trg_tenant_question_references_platform_or_self_owner + before insert or update of tenant_id, question_owner_tenant_id, question_id, source + on tenant_question_references + for each row + execute function tiku_guard_tenant_question_reference(); + """; + + public const string CreateTaxonomyParentGuard = """ + create or replace function tiku_guard_taxonomy_parent_owner() + returns trigger + language plpgsql + as $$ + declare + parent_owner_mode text; + begin + if new.parent_owner_tenant_id is null and new.parent_id is null then + return new; + end if; + + select mode into parent_owner_mode + from tenants + where id = new.parent_owner_tenant_id; + + if parent_owner_mode = 'platform_owned' or new.parent_owner_tenant_id = new.tenant_id then + return new; + end if; + + raise exception 'taxonomy parent must belong to platform or current tenant' + using errcode = '23514', + constraint = 'ck_taxonomy_nodes_parent_platform_or_self_owner'; + end; + $$; + + create trigger trg_taxonomy_nodes_parent_platform_or_self_owner + before insert or update of tenant_id, parent_owner_tenant_id, parent_id + on taxonomy_nodes + for each row + execute function tiku_guard_taxonomy_parent_owner(); + """; + + public const string DropTenantGuards = """ + drop trigger if exists trg_tenant_question_references_platform_or_self_owner on tenant_question_references; + drop function if exists tiku_guard_tenant_question_reference(); + drop trigger if exists trg_taxonomy_nodes_parent_platform_or_self_owner on taxonomy_nodes; + drop function if exists tiku_guard_taxonomy_parent_owner(); + """; +} diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index e806db0..8e2b809 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -153,6 +153,17 @@ public sealed class TikuDbContext( public DbSet Faqs => Set(); public DbSet Announcements => Set(); public DbSet AuditLogs => Set(); + public DbSet BackendPermissions => Set(); + public DbSet BackendMenus => Set(); + public DbSet TenantBackendRoles => Set(); + public DbSet TenantBackendRolePermissions => Set(); + public DbSet TenantBackendRoleMenus => Set(); + public DbSet TenantBackendUserRoles => Set(); + public DbSet PlatformBackendRoles => Set(); + public DbSet PlatformBackendRolePermissions => Set(); + public DbSet PlatformBackendRoleMenus => Set(); + public DbSet PlatformBackendUserRoles => Set(); + public DbSet BackgroundJobs => Set(); public DbSet UserNotifications => Set(); public DbSet Badges => Set(); public DbSet UserBadges => Set(); diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index 79c9d98..97f5dba 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -7,6 +7,7 @@ + @@ -17,8 +18,8 @@ + - diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 0cf8061..3533056 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -9,6 +9,7 @@ using Tiku.Application.Growth; using Tiku.Application.Storage; using Tiku.Application.Security; using Tiku.Application.Tenancy; +using Tiku.Api; using Tiku.Domain.Identity; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Content; @@ -24,7 +25,7 @@ public sealed class ApiTestFactory( IReferralQrcodeGenerator? referralQrcodeGenerator = null, IPaymentProviderGateway? paymentProviderGateway = null, IDomainOwnershipVerifier? domainOwnershipVerifier = null, - IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory + IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory { private readonly PostgresTestDatabase database = PostgresTestDatabase.Create(); diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index a792003..35db427 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -79,6 +79,67 @@ public sealed class ArchitectureBoundaryTests $"Forbidden external-provider coupling was found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); } + [Fact] + public void Production_code_does_not_reference_removed_wechat_payment_sdk() + { + var root = FindRepositoryRoot(); + var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain", "Tiku.Infrastructure", "Tiku.Worker" }; + var forbidden = new[] + { + "SKIT.FlurlHttpClient.Wechat" + }; + + var violations = sourceRoots + .SelectMany(directory => Directory.EnumerateFiles( + Path.Combine(root, directory), + "*.cs", + SearchOption.AllDirectories)) + .Where(path => !path.Contains( + $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => forbidden.Any(symbol => + candidate.line.Contains(symbol, StringComparison.Ordinal))) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"Removed WeChat payment SDK references were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + + [Fact] + public void Business_layers_do_not_reference_third_party_provider_sdks() + { + var root = FindRepositoryRoot(); + var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Domain" }; + var forbidden = new[] + { + "AlibabaCloud.", + "Aliyun.OSS", + "Senparc.Weixin", + "AlipaySDKNet", + "Aop.Api" + }; + + var violations = sourceRoots + .SelectMany(directory => Directory.EnumerateFiles( + Path.Combine(root, directory), + "*.cs", + SearchOption.AllDirectories)) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => forbidden.Any(symbol => + candidate.line.Contains(symbol, StringComparison.Ordinal))) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"Business-layer third-party provider SDK references were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + private static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index 37e721c..9430582 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -135,6 +135,60 @@ public sealed class AuthServiceTests entity.FailureCode == "invalid_sms_code"); } + [Fact] + public async Task Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification() + { + await using var context = CreateContext(); + var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); + var smsService = new SmsVerificationService(context, new FailingSmsProvider()); + + var exception = await Assert.ThrowsAsync(() => + smsService.CreateCodeAsync(new SendSmsCodeRequest( + seed.TenantId, + seed.Phone, + SmsPurpose.Login, + null, + null))); + + Assert.Equal("sms_provider_send_failed", exception.Code); + var verification = Assert.Single(context.SmsVerificationCodes); + Assert.Equal(SmsVerificationStatus.Failed, verification.Status); + Assert.Equal("failed", verification.Provider); + + await Assert.ThrowsAsync(() => + smsService.VerifyCodeAsync( + seed.TenantId, + seed.Phone, + SmsPurpose.Login, + "123456")); + } + + [Fact] + public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches() + { + await using var context = CreateContext(); + var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); + context.SmsVerificationCodes.Add(new SmsVerificationCode + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Purpose = SmsPurpose.Login, + CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), + Status = SmsVerificationStatus.Failed, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + }); + await context.SaveChangesAsync(); + var smsService = new SmsVerificationService(context, new FakeSmsProvider()); + + await Assert.ThrowsAsync(() => + smsService.VerifyCodeAsync( + seed.TenantId, + seed.Phone, + SmsPurpose.Login, + "123456")); + } + + [Fact] public async Task Revoked_refresh_token_cannot_be_refreshed() { @@ -410,6 +464,14 @@ public sealed class AuthServiceTests Task.FromResult(new SmsProviderSendResult("fake", "accepted")); } + private sealed class FailingSmsProvider : ISmsProvider + { + public Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("provider unavailable"); + } + private sealed class FakeWechatOAuthClient( WechatIdentity? WebIdentity = null, WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient diff --git a/Tiku.Worker/Worker.cs b/Tiku.Worker/Worker.cs index 68e2331..e6e7bbf 100644 --- a/Tiku.Worker/Worker.cs +++ b/Tiku.Worker/Worker.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Application.Tenancy; @@ -9,21 +10,22 @@ public class Worker( IOptions options, ILogger logger) : BackgroundService { + private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}"; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { - await using var scope = scopeFactory.CreateAsyncScope(); - scope.ServiceProvider.GetRequiredService() - .InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker"); - var processed = await scope.ServiceProvider - .GetRequiredService() - .ProcessPendingAsync(stoppingToken); - if (processed > 0) + var domainCount = await ProcessTenantDomainsAsync(stoppingToken); + var jobCount = await ProcessBackgroundJobsAsync(stoppingToken); + if (domainCount > 0 || jobCount > 0) { - logger.LogInformation("Processed {Count} pending tenant domains.", processed); + logger.LogInformation( + "Worker processed {DomainCount} pending tenant domains and {JobCount} background jobs.", + domainCount, + jobCount); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -40,4 +42,24 @@ public class Worker( stoppingToken); } } + + private async Task ProcessTenantDomainsAsync(CancellationToken stoppingToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService() + .InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker"); + return await scope.ServiceProvider + .GetRequiredService() + .ProcessPendingAsync(stoppingToken); + } + + private async Task ProcessBackgroundJobsAsync(CancellationToken stoppingToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + scope.ServiceProvider.GetRequiredService() + .InitializeSystem(null, "Background job lease worker"); + return await scope.ServiceProvider + .GetRequiredService() + .ProcessPendingAsync(workerId, 20, stoppingToken); + } } diff --git a/docs/migration-roadmap.md b/docs/migration-roadmap.md index 5ce20bd..30fb96b 100644 --- a/docs/migration-roadmap.md +++ b/docs/migration-roadmap.md @@ -9,6 +9,7 @@ - [`docs/migration/phase-2-engineering-foundation.md`](migration/phase-2-engineering-foundation.md) - [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](migration/phase-3-tenant-isolation-and-shared-question-bank.md) - [`docs/migration/phase-4-external-provider-decoupling.md`](migration/phase-4-external-provider-decoupling.md) +- [`docs/migration/phase-5-backoffice-worker-operations.md`](migration/phase-5-backoffice-worker-operations.md) 迁移原则: @@ -17,6 +18,7 @@ - 不保留 Supabase 运行时依赖、Storage provider 或 Auth 兼容层。 - 数据一致性落 PostgreSQL FK / unique / check / index 约束。 - 多租户数据默认带 `TenantId`,跨租户引用优先使用 composite FK。 +- ORM 不能自动表达的跨表租户不变量,使用集中 PostgreSQL trigger / constraint trigger SQL helper,由 EF Core migration 调用并用真实 PostgreSQL 集成测试验证。 - 外部身份、短信、对象存储、支付和通知都通过 Application 接口与 `TenantExternalProvider` 配置解耦。 - JSON 字段使用 C# `JsonElement` + PostgreSQL `jsonb`,不落字符串。 - 旧版明显是占位、临时脚本或平台自动化的部分,不直接硬搬,先重新设计边界。 @@ -34,7 +36,9 @@ - Scalar / OpenAPI 基础入口。 - 统一租户外部服务配置:`TenantExternalProvider` + `TenantSecret`。 - 身份、短信、阿里云 OSS、支付和通知 Provider 抽象,业务层不感知 Supabase Storage、OSS bucket、微信/支付/短信 SDK 或密钥读取细节。 -- Senparc 微信登录与小程序码生成边界。 +- Senparc 微信登录、小程序码和微信支付 V3 边界。 +- 后台权限、菜单、平台/租户角色与操作审计底座。 +- `Microsoft.Extensions.Hosting` Worker 与统一后台任务模型。 ### 数据库 @@ -50,6 +54,7 @@ - 推广、邀请码、归因、CRM 队列、佣金结算。 - 运营内容、通知、徽章、审计。 - 平台账单、催缴、审计告警、对账/退款相关模型。 +- 后台权限、菜单、角色、用户角色绑定和后台任务模型。 - PocketBase 导入审计。 - 自定义域名 DNS/TLS 生命周期和租户前端运行时配置。 diff --git a/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md b/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md index 2c3b47f..e8e451e 100644 --- a/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md +++ b/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md @@ -164,6 +164,26 @@ - SessionQuestion 的版本属于对应题目。 - 学习记录与当前租户、学生和会话一致。 +### EF Core 与 PostgreSQL 约束分工 + +EF Core 负责实体、Fluent Configuration、Migration 生成和迁移执行入口,迁移仍然是 code-first 管理,不允许去生产库手工补结构。 + +但以下跨表、跨租户不变量不能指望 ORM 自动推导: + +- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题,不能引用其他租户私题。 +- `TaxonomyNode` 的父节点只能属于平台主体或当前租户,不能挂到其他租户节点。 +- 需要读取 `tenants.mode` 或比较多列 owner 关系的规则。 + +原因是 PostgreSQL `CHECK` 不能跨表查询,普通 FK 只能证明目标记录存在,不能表达“目标 owner 必须是平台主体或本租户”。这类规则必须落到 PostgreSQL trigger / constraint trigger。 + +实现要求: + +- 触发器 SQL 必须集中在基础设施层,例如 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`。 +- Migration 只调用集中 SQL helper,例如 `migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTenantQuestionReferenceGuard)`。 +- 不允许把触发器 SQL 零散复制到多个 migration。 +- 每个触发器必须有真实 PostgreSQL 集成测试覆盖允许路径和拒绝路径。 +- 如果将来新增类似“平台或本租户”的 owner 规则,优先补集中 SQL helper 和模型/集成测试,不要只靠 Service 手写校验。 + ## 测试矩阵 真实 PostgreSQL 测试创建平台主体、租户 A、租户 B 及三套内容,至少覆盖: diff --git a/docs/migration/phase-5-backoffice-worker-operations.md b/docs/migration/phase-5-backoffice-worker-operations.md new file mode 100644 index 0000000..1fc7f76 --- /dev/null +++ b/docs/migration/phase-5-backoffice-worker-operations.md @@ -0,0 +1,194 @@ +# 阶段五:后台能力底座、Worker 基座与运营闭环 + +状态:已完成第一轮底座实现并通过本地验收(2026-07-28)。 + +本阶段从核心 SaaS 架构重构进入后台运营能力补齐。目标不是逐字兼容旧 NestJS,而是在当前 ASP.NET Core + EF Core + PostgreSQL 架构下重建后台权限、菜单、审计、交易运营和 Worker 基座,并参考 yudao 的后台能力清单补齐系统底座。 + +## 固定 SDK 选型 + +微信生态统一使用 Senparc: + +- `Senparc.Weixin` +- `Senparc.Weixin.MP` +- `Senparc.Weixin.WxOpen` +- `Senparc.Weixin.TenPayV3` + +已移除并禁止: + +- `SKIT.FlurlHttpClient.Wechat.*` + +其他外部服务: + +- OSS:`AlibabaCloud.OSS.V2` +- 阿里云短信:`AlibabaCloud.SDK.Dysmsapi20170525` +- 支付宝:`AlipaySDKNet.Standard` +- Worker:`Microsoft.Extensions.Hosting` + `BackgroundService` + +业务层仍然只依赖 Application 接口: + +- `IIdentityProvider` +- `IObjectStorageService` +- `ISmsProvider` +- `IPaymentProvider` +- `INotificationProvider` + +第三方 SDK namespace 只允许出现在 Infrastructure provider 实现中。 + +## 5A:微信 Provider 收敛 + +`WechatPayProvider` 已切换到 `Senparc.Weixin.TenPayV3`: + +- JSAPI / H5 下单参数生成走 Senparc TenPayV3。 +- JSAPI 前端支付参数使用 Senparc 签名 helper 生成。 +- 支付 provider 继续通过 `IPaymentProvider` 暴露,不向 Application 或 API 泄漏 Senparc 类型。 +- 架构测试禁止生产代码引用 `SKIT.FlurlHttpClient.Wechat`。 + +说明:当前 `IPaymentProvider` 的回调入口抽象为 body + headers,不直接暴露 `HttpContext`。Senparc 官方推荐的 `TenPayNotifyHandler(HttpContext)` 回调验签/解密模式后续可以在 Infrastructure 的 HTTP 适配层补强,但不能把 Senparc 类型扩散到业务层。 + +## 5B:后台权限、菜单、角色与审计底座 + +新增后台基础模型: + +- `BackendPermission` +- `BackendMenu` +- `TenantBackendRole` +- `TenantBackendRolePermission` +- `TenantBackendRoleMenu` +- `TenantBackendUserRole` +- `PlatformBackendRole` +- `PlatformBackendRolePermission` +- `PlatformBackendRoleMenu` +- `PlatformBackendUserRole` + +设计边界: + +- 权限点是稳定字符串 code。 +- 菜单只控制后台 UI 展示,不作为唯一 API 鉴权来源。 +- 平台角色和租户角色分表。 +- 租户角色绑定包含 `TenantId`,平台角色绑定不带租户键。 +- 角色、权限、菜单、用户角色绑定写操作落 `AuditLog`。 + +新增接口: + +- `GET /api/backoffice/tenant/bootstrap` +- `POST /api/backoffice/tenant/roles` +- `PUT /api/backoffice/tenant/roles/{roleId}/bindings` +- `PUT /api/backoffice/tenant/users/{userId}/roles` +- `GET /api/backoffice/platform/bootstrap` +- `POST /api/backoffice/platform/roles` +- `PUT /api/backoffice/platform/roles/{roleId}/bindings` +- `PUT /api/backoffice/platform/users/{userId}/roles` + +`TenantAdminDirect` 继续作为过渡入口;新增后台能力使用 `backoffice` 模块命名。 + +## 5E:交易运营底座 + +在现有 commerce 模型上补齐租户后台运营服务: + +- 退款申请。 +- 退款状态流转。 +- 退款事件记录。 +- 对账批次创建。 +- 对账 issue 查询与状态流转。 +- 退款、对账写操作落审计。 + +退款状态流转由服务控制,不能任意跳转。支付、回调、退款后续仍统一走 `IPaymentProvider`,初期不默认开启真实自动退款。 + +新增租户交易运营接口: + +- `GET /api/tenant-commerce/refunds` +- `POST /api/tenant-commerce/refunds` +- `POST /api/tenant-commerce/refunds/status` +- `GET /api/tenant-commerce/refunds/{refundRequestId}/events` +- `GET /api/tenant-commerce/reconciliation/batches` +- `POST /api/tenant-commerce/reconciliation/batches` +- `GET /api/tenant-commerce/reconciliation/issues` +- `POST /api/tenant-commerce/reconciliation/issues/status` + +## 5F:Worker 与后台任务基座 + +新增统一任务模型 `BackgroundJob`: + +- `JobType` +- `TenantId` +- `Payload` +- `Status` +- `RetryCount` +- `MaxRetries` +- `LockedBy` +- `LockExpiresAt` +- `RunAfter` +- `StartedAt` +- `CompletedAt` +- `LastError` +- `OutputAssetId` +- `Result` + +Worker 基于 `Microsoft.Extensions.Hosting` + `BackgroundService`,暂不引入 Hangfire。后续需要复杂 cron 时再评估 `Quartz.Extensions.Hosting`。 + +当前任务处理器建立了幂等租户 Scope 入口和状态机骨架,覆盖以下 job type: + +- `content_export` +- `content_import` +- `asset_security_scan` +- `statistics_aggregation` +- `commerce_reconciliation` +- `tenant_domain_recheck` + +新增租户后台任务接口: + +- `GET /api/backoffice/tenant/jobs` +- `POST /api/backoffice/tenant/jobs` + +API 只负责创建任务和查询任务;Worker 必须通过 `ITenantExecutionScope` 初始化执行上下文。 + +## 阿里云短信 Provider + +`ISmsProvider` 默认接入租户 Provider 配置: + +- 未配置短信 provider 时,本地/测试降级为 `noop`。 +- 配置 `aliyun_sms` 时,从 `TenantExternalProvider(capability=sms)` 读取公开配置。 +- `signName`、`templateCode`、`endpoint`、`regionId` 等公开字段放 `ConfigPublic`。 +- `accessKeyId`、`accessKeySecret` 只允许通过 `TenantSecret` 解密获得。 +- 发送失败会记录 `SmsVerificationStatus.Failed`,不会留下可验证验证码。 + +## 数据库约束补强 + +阶段五重建 `InitialSchema` 时同步补齐阶段三遗漏的两个 PostgreSQL 硬约束: + +- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题。 +- `TaxonomyNode` 父节点只能属于平台主体或当前租户。 + +这类跨表租户不变量不能靠 EF Core FK / check constraint 自动表达,统一放在 `PostgreSqlTenantConstraintSql`,由 migration 调用,并由真实 PostgreSQL 集成测试覆盖。 + +## 架构测试 + +新增或强化禁止项: + +- 生产代码不得引用 `SKIT.FlurlHttpClient.Wechat`。 +- 业务层不得直接引用阿里云 OSS、阿里云短信、Senparc、支付宝 SDK namespace。 +- 生产代码不得回流 Supabase provider、Supabase Storage 或旧专用 provider 表模型。 +- 普通业务目录继续禁止 `IgnoreQueryFilters`、`FromSql`、`ExecuteSql` 和直接 `NpgsqlCommand`。 + +## 验收结果 + +本阶段本地验收: + +```bash +dotnet restore TIKU-BACKEND.slnx +dotnet build TIKU-BACKEND.slnx --no-restore +dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-build +dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-build +dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator +git diff --check +``` + +结果: + +- build:0 警告,0 错误。 +- unit tests:18/18 通过。 +- integration tests:264/264 通过。 +- migration script:生成成功。 +- 空 PostgreSQL 通过 `Tiku.DbMigrator` 建库成功。 +- PostgreSQL 验证两个租户约束 trigger 已创建。 +