feat: add phase five operations foundation

This commit is contained in:
2026-07-28 09:57:50 +08:00
parent 71793a2de0
commit f22f329d33
39 changed files with 4810 additions and 160 deletions

View File

@@ -25,6 +25,7 @@
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
@@ -32,9 +33,9 @@
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Senparc.Weixin" Version="6.25.0" />
<PackageVersion Include="Senparc.Weixin.MP" Version="16.25.1" />
<PackageVersion Include="Senparc.Weixin.TenPayV3" Version="2.0.0" />
<PackageVersion Include="Senparc.Weixin.WxOpen" Version="3.28.1" />
<PackageVersion Include="AlipaySDKNet.Standard" Version="4.9.1234" />
<PackageVersion Include="SKIT.FlurlHttpClient.Wechat.TenpayV3" Version="3.16.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />

View File

@@ -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 测试兜底,禁止去数据库手工补。
## 还剩多少待迁移

View File

@@ -0,0 +1,3 @@
namespace Tiku.Api;
public sealed class ApiProgramMarker;

View File

@@ -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);
}
}

View File

@@ -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<string> PermissionCodes { get; set; } = [];
public IReadOnlyCollection<string> MenuCodes { get; set; } = [];
public ReplaceRoleBindingsCommand ToCommand(Guid roleId)
{
return new ReplaceRoleBindingsCommand(roleId, PermissionCodes, MenuCodes);
}
}
public sealed class ReplaceUserRolesDto
{
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
public ReplaceUserRolesCommand ToCommand(Guid userId)
{
return new ReplaceUserRolesCommand(userId, RoleIds);
}
}

View File

@@ -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]

View File

@@ -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<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
[FromQuery] string? jobType,
[FromQuery] int? limit,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
}
[HttpPost]
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackgroundJobItem>> 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.");
}
}

View File

@@ -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<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken));
}
[HttpPost("tenant/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> 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<IActionResult> 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<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken));
}
[HttpPost("platform/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> 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<IActionResult> 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);
}
}

View File

@@ -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<TenantRefundList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantRefundList>> Refunds(
[FromQuery] TenantCommerceQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.GetRefundsAsync(
ResolveActor(),
ToQuery(query),
cancellationToken));
}
[HttpPost("refunds")]
[EndpointSummary("创建退款申请")]
[ProducesResponseType<CommerceRefundRequest>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceRefundRequest>> CreateRefund(
CreateRefundRequestDto request,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.CreateRefundRequestAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpPost("refunds/status")]
[EndpointSummary("更新退款状态")]
[ProducesResponseType<CommerceRefundRequest>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceRefundRequest>> UpdateRefundStatus(
UpdateRefundStatusDto request,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.UpdateRefundStatusAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("refunds/{refundRequestId:guid}/events")]
[EndpointSummary("查询退款事件")]
[ProducesResponseType<TenantRefundEventList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantRefundEventList>> RefundEvents(
Guid refundRequestId,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.GetRefundEventsAsync(
ResolveActor(),
refundRequestId,
cancellationToken));
}
[HttpGet("reconciliation/batches")]
[EndpointSummary("查询对账批次")]
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
[FromQuery] TenantCommerceQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.GetReconciliationBatchesAsync(
ResolveActor(),
ToQuery(query),
cancellationToken));
}
[HttpPost("reconciliation/batches")]
[EndpointSummary("创建对账批次")]
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
CreateReconciliationBatchDto request,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.CreateReconciliationBatchAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("reconciliation/issues")]
[EndpointSummary("查询对账异常")]
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
[FromQuery] TenantCommerceQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await commerceAdminService.GetReconciliationIssuesAsync(
ResolveActor(),
ToQuery(query),
cancellationToken));
}
[HttpPost("reconciliation/issues/status")]
[EndpointSummary("更新对账异常状态")]
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationIssue>> 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)

View File

@@ -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<SmsProviderSendResult> SendAsync(

View File

@@ -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<BackofficePermissionItem> Permissions,
IReadOnlyCollection<BackofficeMenuItem> Menus,
IReadOnlyCollection<BackofficeRoleItem> 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<string> PermissionCodes,
IReadOnlyCollection<string> 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<string> PermissionCodes,
IReadOnlyCollection<string> MenuCodes);
public sealed record ReplaceUserRolesCommand(
Guid UserId,
IReadOnlyCollection<Guid> RoleIds);
public sealed record BackofficeOperationAuditCommand(
Guid? TenantId,
Guid? ActorUserId,
string Action,
string? TargetType,
string? TargetId,
JsonElement Details,
string? IpAddress = null,
string? UserAgent = null);

View File

@@ -0,0 +1,49 @@
namespace Tiku.Application.Backoffice;
public interface IBackofficeService
{
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default);
Task<BackofficeBootstrap> GetPlatformBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default);
Task<BackofficeRoleItem> UpsertTenantRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default);
Task<BackofficeRoleItem> UpsertPlatformRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default);
Task<BackofficeRoleItem> ReplaceTenantRoleBindingsAsync(
BackofficeActor actor,
ReplaceRoleBindingsCommand command,
CancellationToken cancellationToken = default);
Task<BackofficeRoleItem> 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);
}

View File

@@ -152,6 +152,42 @@ public sealed record TenantCouponList(IReadOnlyCollection<Coupon> Items);
public sealed record TenantCouponRedemptionList(IReadOnlyCollection<CouponRedemption> 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<CommerceRefundRequest> Items);
public sealed record TenantRefundEventList(IReadOnlyCollection<CommerceRefundEvent> Items);
public sealed record CreateReconciliationBatchCommand(
string Provider,
DateOnly BillDate,
ReconciliationBillType BillType,
ReconciliationSource Source,
string? SourceName,
string SourceHash,
JsonElement Metadata);
public sealed record TenantReconciliationBatchList(IReadOnlyCollection<CommerceReconciliationBatch> Items);
public sealed record TenantReconciliationIssueList(IReadOnlyCollection<CommerceReconciliationIssue> Items);
public sealed record UpdateReconciliationIssueCommand(
Guid IssueId,
ReconciliationIssueStatus Status,
ReconciliationResolutionType ResolutionType,
string? Note,
Guid? AssignedTo = null);
public interface ICommerceAdminService
{
Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
@@ -248,4 +284,44 @@ public interface ICommerceAdminService
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default);
Task<TenantRefundList> GetRefundsAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default);
Task<CommerceRefundRequest> CreateRefundRequestAsync(
CommerceAdminActor actor,
CreateRefundRequestCommand command,
CancellationToken cancellationToken = default);
Task<CommerceRefundRequest> UpdateRefundStatusAsync(
CommerceAdminActor actor,
UpdateRefundStatusCommand command,
CancellationToken cancellationToken = default);
Task<TenantRefundEventList> GetRefundEventsAsync(
CommerceAdminActor actor,
Guid refundRequestId,
CancellationToken cancellationToken = default);
Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default);
Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
CommerceAdminActor actor,
CreateReconciliationBatchCommand command,
CancellationToken cancellationToken = default);
Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default);
Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
CommerceAdminActor actor,
UpdateReconciliationIssueCommand command,
CancellationToken cancellationToken = default);
}

View File

@@ -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<BackgroundJobItem> EnqueueAsync(
CreateBackgroundJobCommand command,
CancellationToken cancellationToken = default);
Task<int> ProcessPendingAsync(
string workerId,
int batchSize,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
Guid tenantId,
string? jobType = null,
int limit = 50,
CancellationToken cancellationToken = default);
}

View File

@@ -7,6 +7,7 @@ public enum TenantResolutionSource
TenantCode,
Jwt,
RefreshToken,
Worker,
System
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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<SmsProviderSendResult> 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<string, string>
{
[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;
}
}

View File

@@ -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");
}

View File

@@ -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<BackofficeBootstrap> 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<BackofficeBootstrap> 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<BackofficeRoleItem> 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<BackofficeRoleItem> 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<BackofficeRoleItem> 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<BackofficeRoleItem> 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<string> permissionCodes,
IReadOnlyCollection<string> 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<string> permissionCodes,
IReadOnlyCollection<string> 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<BackofficeRoleItem[]> 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<BackofficeRoleItem[]> 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<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken)
{
return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
}
private async Task<BackofficeRoleItem> 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<string> 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;
}

View File

@@ -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);
}
}

View File

@@ -562,6 +562,249 @@ internal sealed class CommerceAdminService(
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
}
public async Task<TenantRefundList> 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<CommerceRefundRequest> 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<CommerceRefundRequest> 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<TenantRefundEventList> 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<TenantReconciliationBatchList> 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<CommerceReconciliationBatch> 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<TenantReconciliationIssueList> 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<CommerceReconciliationIssue> 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<CommerceRefundStatus>(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<ReconciliationBatchStatus>(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<ReconciliationIssueStatus>(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));

View File

@@ -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<CreatePaymentProviderResult> 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<string?>(response, "prepay_id", "PrepayId");
var h5Url = GetPropertyValue<string?>(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<WechatTenpayEvent>(body.GetRawText());
if (webhookEvent?.Resource is null)
{
return false;
}
var decrypted = client.DecryptEventResource<TransactionResource>(webhookEvent);
payload = JsonSerializer.SerializeToElement(decrypted);
_ = resource;
return true;
}
catch
{
return false;
}
return null;
}
private static bool TryGetHeader(
IReadOnlyDictionary<string, string> headers,
string name,
out string value)
private static bool HasWechatPaySignatureHeaders(IReadOnlyDictionary<string, string> 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<string, string> headers, string name)
{
return headers.Keys.Any(key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase));
}
private static T? GetPropertyValue<T>(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)

View File

@@ -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<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsProvider, NoopSmsProvider>();
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>();
@@ -89,6 +93,9 @@ public static class DependencyInjection
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<IOperationAuditService, OperationAuditService>();
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
services.AddScoped<ICommerceService, CommerceService>();
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
services.AddScoped<IPointService, PointService>();

View File

@@ -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<BackgroundJobItem> 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<int> 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<IReadOnlyCollection<BackgroundJobItem>> 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);
}
}

View File

@@ -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<AuditLog>
}
}
internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration<BackendPermission>
{
public void Configure(EntityTypeBuilder<BackendPermission> 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<BackendMenu>
{
public void Configure(EntityTypeBuilder<BackendMenu> 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<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantBackendRoleConfiguration : IEntityTypeConfiguration<TenantBackendRole>
{
public void Configure(EntityTypeBuilder<TenantBackendRole> 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<TenantBackendRolePermission>
{
public void Configure(EntityTypeBuilder<TenantBackendRolePermission> 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<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantBackendRoleMenuConfiguration : IEntityTypeConfiguration<TenantBackendRoleMenu>
{
public void Configure(EntityTypeBuilder<TenantBackendRoleMenu> 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<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendMenu>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.MenuCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantBackendUserRoleConfiguration : IEntityTypeConfiguration<TenantBackendUserRole>
{
public void Configure(EntityTypeBuilder<TenantBackendUserRole> 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<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PlatformBackendRoleConfiguration : IEntityTypeConfiguration<PlatformBackendRole>
{
public void Configure(EntityTypeBuilder<PlatformBackendRole> 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<PlatformBackendRolePermission>
{
public void Configure(EntityTypeBuilder<PlatformBackendRolePermission> 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<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformBackendRoleMenuConfiguration : IEntityTypeConfiguration<PlatformBackendRoleMenu>
{
public void Configure(EntityTypeBuilder<PlatformBackendRoleMenu> 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<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendMenu>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.MenuCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfiguration<PlatformBackendUserRole>
{
public void Configure(EntityTypeBuilder<PlatformBackendUserRole> 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<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<BackgroundJob>
{
public void Configure(EntityTypeBuilder<BackgroundJob> 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<ContentAsset>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
{
public void Configure(EntityTypeBuilder<UserNotification> builder)

View File

@@ -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
{
/// <inheritdoc />
@@ -9879,6 +9879,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("icon");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<string>("ParentCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("parent_code");
b.Property<string>("Path")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<string>("PermissionCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("module");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_type");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("last_error");
b.Property<DateTimeOffset?>("LockExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lock_expires_at");
b.Property<string>("LockedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("locked_by");
b.Property<int>("MaxRetries")
.HasColumnType("integer")
.HasColumnName("max_retries");
b.Property<Guid?>("OutputAssetId")
.HasColumnType("uuid")
.HasColumnName("output_asset_id");
b.Property<JsonElement>("Payload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("Result")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("result")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("retry_count");
b.Property<DateTimeOffset?>("RunAfter")
.HasColumnType("timestamp with time zone")
.HasColumnName("run_after");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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<Guid>("Id")
@@ -10138,6 +10400,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("faqs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("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<Guid>("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)

View File

@@ -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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
area = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
module = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
is_system = table.Column<bool>(type: "boolean", nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
is_system = table.Column<bool>(type: "boolean", nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
parent_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
area = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
path = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
icon = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
menu_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
is_system = table.Column<bool>(type: "boolean", nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
data_scope = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
menu_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
job_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
retry_count = table.Column<int>(type: "integer", nullable: false),
max_retries = table.Column<int>(type: "integer", nullable: false),
locked_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
lock_expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
run_after = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
output_asset_id = table.Column<Guid>(type: "uuid", nullable: true),
result = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_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);
}
/// <inheritdoc />
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");

View File

@@ -9876,6 +9876,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("icon");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<string>("ParentCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("parent_code");
b.Property<string>("Path")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<string>("PermissionCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("module");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_type");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("last_error");
b.Property<DateTimeOffset?>("LockExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lock_expires_at");
b.Property<string>("LockedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("locked_by");
b.Property<int>("MaxRetries")
.HasColumnType("integer")
.HasColumnName("max_retries");
b.Property<Guid?>("OutputAssetId")
.HasColumnType("uuid")
.HasColumnName("output_asset_id");
b.Property<JsonElement>("Payload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("Result")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("result")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("retry_count");
b.Property<DateTimeOffset?>("RunAfter")
.HasColumnType("timestamp with time zone")
.HasColumnName("run_after");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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<Guid>("Id")
@@ -10135,6 +10397,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("faqs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("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<Guid>("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)

View File

@@ -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();
""";
}

View File

@@ -153,6 +153,17 @@ public sealed class TikuDbContext(
public DbSet<Faq> Faqs => Set<Faq>();
public DbSet<Announcement> Announcements => Set<Announcement>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<BackendPermission> BackendPermissions => Set<BackendPermission>();
public DbSet<BackendMenu> BackendMenus => Set<BackendMenu>();
public DbSet<TenantBackendRole> TenantBackendRoles => Set<TenantBackendRole>();
public DbSet<TenantBackendRolePermission> TenantBackendRolePermissions => Set<TenantBackendRolePermission>();
public DbSet<TenantBackendRoleMenu> TenantBackendRoleMenus => Set<TenantBackendRoleMenu>();
public DbSet<TenantBackendUserRole> TenantBackendUserRoles => Set<TenantBackendUserRole>();
public DbSet<PlatformBackendRole> PlatformBackendRoles => Set<PlatformBackendRole>();
public DbSet<PlatformBackendRolePermission> PlatformBackendRolePermissions => Set<PlatformBackendRolePermission>();
public DbSet<PlatformBackendRoleMenu> PlatformBackendRoleMenus => Set<PlatformBackendRoleMenu>();
public DbSet<PlatformBackendUserRole> PlatformBackendUserRoles => Set<PlatformBackendUserRole>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<Badge> Badges => Set<Badge>();
public DbSet<UserBadge> UserBadges => Set<UserBadge>();

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="AlibabaCloud.OSS.V2" />
<PackageReference Include="AlibabaCloud.SDK.Dysmsapi20170525" />
<PackageReference Include="AlipaySDKNet.Standard" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
@@ -17,8 +18,8 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Senparc.Weixin" />
<PackageReference Include="Senparc.Weixin.MP" />
<PackageReference Include="Senparc.Weixin.TenPayV3" />
<PackageReference Include="Senparc.Weixin.WxOpen" />
<PackageReference Include="SKIT.FlurlHttpClient.Wechat.TenpayV3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>

View File

@@ -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<Program>
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<ApiProgramMarker>
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();

View File

@@ -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);

View File

@@ -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<SmsProviderException>(() =>
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<InvalidCredentialsException>(() =>
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<InvalidCredentialsException>(() =>
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<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("provider unavailable");
}
private sealed class FakeWechatOAuthClient(
WechatIdentity? WebIdentity = null,
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient

View File

@@ -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<DomainLifecycleOptions> options,
ILogger<Worker> 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<ITenantContextInitializer>()
.InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker");
var processed = await scope.ServiceProvider
.GetRequiredService<ITenantDomainLifecycleService>()
.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<int> ProcessTenantDomainsAsync(CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker");
return await scope.ServiceProvider
.GetRequiredService<ITenantDomainLifecycleService>()
.ProcessPendingAsync(stoppingToken);
}
private async Task<int> ProcessBackgroundJobsAsync(CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, "Background job lease worker");
return await scope.ServiceProvider
.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync(workerId, 20, stoppingToken);
}
}

View File

@@ -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 生命周期和租户前端运行时配置。

View File

@@ -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 及三套内容,至少覆盖:

View File

@@ -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`
## 5FWorker 与后台任务基座
新增统一任务模型 `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
```
结果:
- build0 警告0 错误。
- unit tests18/18 通过。
- integration tests264/264 通过。
- migration script生成成功。
- 空 PostgreSQL 通过 `Tiku.DbMigrator` 建库成功。
- PostgreSQL 验证两个租户约束 trigger 已创建。