diff --git a/Directory.Packages.props b/Directory.Packages.props
index 39e483b..f6274f2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -25,6 +25,7 @@
+
@@ -32,9 +33,9 @@
+
-
diff --git a/README.md b/README.md
index 985e7b9..98b2253 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@
- [`docs/migration/phase-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md)
- [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md)
- [`docs/migration/phase-4-external-provider-decoupling.md`](docs/migration/phase-4-external-provider-decoupling.md)
+- [`docs/migration/phase-5-backoffice-worker-operations.md`](docs/migration/phase-5-backoffice-worker-operations.md)
## 技术栈与分层
@@ -32,6 +33,9 @@
- Serilog
- ZLinq
- AlibabaCloud.OSS.V2
+- AlibabaCloud.SDK.Dysmsapi20170525
+- Senparc.Weixin.*
+- AlipaySDKNet.Standard
- xUnit
```text
@@ -207,6 +211,7 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
- 自定义域名请求不得通过 `tenantCode`、`host` query 或客户端转发头切换租户。
- 第三方 SDK、密钥读取、OSS bucket 拼接、微信/支付/短信 provider 细节只允许在 Infrastructure provider 实现中出现。
- 不新增 Supabase provider、Supabase URL 拼接、Supabase Storage bucket 逻辑或 Supabase Auth 兼容层。
+- EF Core 管实体和 migration 生命周期;跨表租户不变量不能指望 ORM 自动推导。比如“题目引用只能指向平台或本租户”“分类父节点只能属于平台或本租户”,必须用集中 PostgreSQL trigger / constraint trigger SQL helper + migration 调用 + 真实 PostgreSQL 测试兜底,禁止去数据库手工补。
## 还剩多少待迁移
diff --git a/Tiku.Api/ApiProgramMarker.cs b/Tiku.Api/ApiProgramMarker.cs
new file mode 100644
index 0000000..c563f59
--- /dev/null
+++ b/Tiku.Api/ApiProgramMarker.cs
@@ -0,0 +1,3 @@
+namespace Tiku.Api;
+
+public sealed class ApiProgramMarker;
diff --git a/Tiku.Api/Contracts/BackgroundJobDtos.cs b/Tiku.Api/Contracts/BackgroundJobDtos.cs
new file mode 100644
index 0000000..04be121
--- /dev/null
+++ b/Tiku.Api/Contracts/BackgroundJobDtos.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using Tiku.Application.Jobs;
+
+namespace Tiku.Api.Contracts;
+
+public sealed class CreateBackgroundJobDto
+{
+ public string JobType { get; set; } = string.Empty;
+ public JsonElement Payload { get; set; }
+ public DateTimeOffset? RunAfter { get; set; }
+ public int MaxRetries { get; set; } = 3;
+
+ public CreateBackgroundJobCommand ToCommand(Guid tenantId)
+ {
+ return new CreateBackgroundJobCommand(
+ tenantId,
+ JobType,
+ Payload.ValueKind == JsonValueKind.Undefined ? JsonSerializer.SerializeToElement(new { }) : Payload,
+ RunAfter,
+ MaxRetries);
+ }
+}
diff --git a/Tiku.Api/Contracts/BackofficeDtos.cs b/Tiku.Api/Contracts/BackofficeDtos.cs
new file mode 100644
index 0000000..4b26447
--- /dev/null
+++ b/Tiku.Api/Contracts/BackofficeDtos.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using Tiku.Application.Backoffice;
+using Tiku.Domain.Operations;
+
+namespace Tiku.Api.Contracts;
+
+public sealed class UpsertBackofficeRoleDto
+{
+ public Guid? Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active;
+ public string? Description { get; set; }
+ public JsonElement? DataScope { get; set; }
+
+ public UpsertBackofficeRoleCommand ToCommand()
+ {
+ return new UpsertBackofficeRoleCommand(Id, Code, Name, Status, Description, DataScope);
+ }
+}
+
+public sealed class ReplaceRoleBindingsDto
+{
+ public IReadOnlyCollection PermissionCodes { get; set; } = [];
+ public IReadOnlyCollection MenuCodes { get; set; } = [];
+
+ public ReplaceRoleBindingsCommand ToCommand(Guid roleId)
+ {
+ return new ReplaceRoleBindingsCommand(roleId, PermissionCodes, MenuCodes);
+ }
+}
+
+public sealed class ReplaceUserRolesDto
+{
+ public IReadOnlyCollection RoleIds { get; set; } = [];
+
+ public ReplaceUserRolesCommand ToCommand(Guid userId)
+ {
+ return new ReplaceUserRolesCommand(userId, RoleIds);
+ }
+}
diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs
index 9e1aebd..aafe913 100644
--- a/Tiku.Api/Contracts/TenantCommerceDtos.cs
+++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs
@@ -249,6 +249,91 @@ public sealed class UpsertPointExchangeItemDto
}
}
+public sealed class CreateRefundRequestDto
+{
+ [Required]
+ public Guid OrderId { get; set; }
+
+ public Guid? PaymentId { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int AmountCents { get; set; }
+
+ [StringLength(1000)]
+ public string? Reason { get; set; }
+
+ public RefundEntitlementAction EntitlementAction { get; set; } = RefundEntitlementAction.RevokeOnSuccess;
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+
+ public CreateRefundRequestCommand ToCommand()
+ {
+ return new CreateRefundRequestCommand(OrderId, PaymentId, AmountCents, Reason, EntitlementAction, Metadata);
+ }
+}
+
+public sealed class UpdateRefundStatusDto
+{
+ [Required]
+ public Guid RefundRequestId { get; set; }
+
+ public CommerceRefundStatus Status { get; set; }
+
+ [StringLength(1000)]
+ public string? Reason { get; set; }
+
+ [StringLength(200)]
+ public string? ProviderRefundNo { get; set; }
+
+ public UpdateRefundStatusCommand ToCommand()
+ {
+ return new UpdateRefundStatusCommand(RefundRequestId, Status, Reason, ProviderRefundNo);
+ }
+}
+
+public sealed class CreateReconciliationBatchDto
+{
+ [Required]
+ [StringLength(50)]
+ public string Provider { get; set; } = string.Empty;
+
+ public DateOnly BillDate { get; set; }
+ public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
+ public ReconciliationSource Source { get; set; } = ReconciliationSource.ManualUpload;
+
+ [StringLength(200)]
+ public string? SourceName { get; set; }
+
+ [Required]
+ [StringLength(200)]
+ public string SourceHash { get; set; } = string.Empty;
+
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+
+ public CreateReconciliationBatchCommand ToCommand()
+ {
+ return new CreateReconciliationBatchCommand(Provider, BillDate, BillType, Source, SourceName, SourceHash, Metadata);
+ }
+}
+
+public sealed class UpdateReconciliationIssueDto
+{
+ [Required]
+ public Guid IssueId { get; set; }
+
+ public ReconciliationIssueStatus Status { get; set; } = ReconciliationIssueStatus.Investigating;
+ public ReconciliationResolutionType ResolutionType { get; set; } = ReconciliationResolutionType.None;
+
+ [StringLength(1000)]
+ public string? Note { get; set; }
+
+ public Guid? AssignedTo { get; set; }
+
+ public UpdateReconciliationIssueCommand ToCommand()
+ {
+ return new UpdateReconciliationIssueCommand(IssueId, Status, ResolutionType, Note, AssignedTo);
+ }
+}
+
public sealed class UpdatePointExchangeOrderStatusDto
{
[Required]
diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs
new file mode 100644
index 0000000..3a99de0
--- /dev/null
+++ b/Tiku.Api/Controllers/BackgroundJobsController.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tiku.Api.Contracts;
+using Tiku.Application.Jobs;
+using Tiku.Application.Security;
+
+namespace Tiku.Api.Controllers;
+
+[ApiController]
+[Route("api/backoffice/tenant/jobs")]
+[Authorize(Policy = TikuPolicies.TenantAdmin)]
+public sealed class BackgroundJobsController(
+ IBackgroundJobService backgroundJobService,
+ ITenantContext tenantContext) : ControllerBase
+{
+ [HttpGet]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] string? jobType,
+ [FromQuery] int? limit,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = ResolveTenantId();
+ return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
+ }
+
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> Create(
+ CreateBackgroundJobDto request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = ResolveTenantId();
+ return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
+ }
+
+ private Guid ResolveTenantId()
+ {
+ return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved.");
+ }
+}
diff --git a/Tiku.Api/Controllers/BackofficeController.cs b/Tiku.Api/Controllers/BackofficeController.cs
new file mode 100644
index 0000000..0864303
--- /dev/null
+++ b/Tiku.Api/Controllers/BackofficeController.cs
@@ -0,0 +1,122 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tiku.Api.Contracts;
+using Tiku.Application.Backoffice;
+using Tiku.Application.Security;
+
+namespace Tiku.Api.Controllers;
+
+[ApiController]
+[Route("api/backoffice")]
+public sealed class BackofficeController(
+ IBackofficeService backofficeService,
+ ICurrentUser currentUser,
+ ITenantContext tenantContext) : ControllerBase
+{
+ [HttpGet("tenant/bootstrap")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> GetTenantBootstrap(CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken));
+ }
+
+ [HttpPost("tenant/roles")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> UpsertTenantRole(
+ UpsertBackofficeRoleDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken));
+ }
+
+ [HttpPut("tenant/roles/{roleId:guid}/bindings")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> ReplaceTenantRoleBindings(
+ Guid roleId,
+ ReplaceRoleBindingsDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken));
+ }
+
+ [HttpPut("tenant/users/{userId:guid}/roles")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ReplaceTenantUserRoles(
+ Guid userId,
+ ReplaceUserRolesDto request,
+ CancellationToken cancellationToken)
+ {
+ await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken);
+ return NoContent();
+ }
+
+ [HttpGet("platform/bootstrap")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> GetPlatformBootstrap(CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken));
+ }
+
+ [HttpPost("platform/roles")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> UpsertPlatformRole(
+ UpsertBackofficeRoleDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken));
+ }
+
+ [HttpPut("platform/roles/{roleId:guid}/bindings")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> ReplacePlatformRoleBindings(
+ Guid roleId,
+ ReplaceRoleBindingsDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken));
+ }
+
+ [HttpPut("platform/users/{userId:guid}/roles")]
+ [Authorize(Policy = TikuPolicies.TenantAdmin)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ReplacePlatformUserRoles(
+ Guid userId,
+ ReplaceUserRolesDto request,
+ CancellationToken cancellationToken)
+ {
+ await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken);
+ return NoContent();
+ }
+
+ private BackofficeActor ResolveTenantActor()
+ {
+ if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId)
+ {
+ throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
+ }
+
+ return new BackofficeActor(userId, tenantId, IsPlatformAdmin());
+ }
+
+ private BackofficeActor ResolvePlatformActor()
+ {
+ if (currentUser.UserId is not { } userId)
+ {
+ throw new InvalidOperationException("Platform backoffice actor was not resolved.");
+ }
+
+ return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin());
+ }
+
+ private bool IsPlatformAdmin()
+ {
+ return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs
index fa30b5f..f5a88aa 100644
--- a/Tiku.Api/Controllers/TenantCommerceController.cs
+++ b/Tiku.Api/Controllers/TenantCommerceController.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
+using Tiku.Domain.Commerce;
namespace Tiku.Api.Controllers;
@@ -262,6 +263,110 @@ public sealed class TenantCommerceController(
cancellationToken));
}
+ [HttpGet("refunds")]
+ [EndpointSummary("查询退款申请")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> Refunds(
+ [FromQuery] TenantCommerceQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.GetRefundsAsync(
+ ResolveActor(),
+ ToQuery(query),
+ cancellationToken));
+ }
+
+ [HttpPost("refunds")]
+ [EndpointSummary("创建退款申请")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> CreateRefund(
+ CreateRefundRequestDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.CreateRefundRequestAsync(
+ ResolveActor(),
+ request.ToCommand(),
+ cancellationToken));
+ }
+
+ [HttpPost("refunds/status")]
+ [EndpointSummary("更新退款状态")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> UpdateRefundStatus(
+ UpdateRefundStatusDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.UpdateRefundStatusAsync(
+ ResolveActor(),
+ request.ToCommand(),
+ cancellationToken));
+ }
+
+ [HttpGet("refunds/{refundRequestId:guid}/events")]
+ [EndpointSummary("查询退款事件")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> RefundEvents(
+ Guid refundRequestId,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.GetRefundEventsAsync(
+ ResolveActor(),
+ refundRequestId,
+ cancellationToken));
+ }
+
+ [HttpGet("reconciliation/batches")]
+ [EndpointSummary("查询对账批次")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> ReconciliationBatches(
+ [FromQuery] TenantCommerceQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.GetReconciliationBatchesAsync(
+ ResolveActor(),
+ ToQuery(query),
+ cancellationToken));
+ }
+
+ [HttpPost("reconciliation/batches")]
+ [EndpointSummary("创建对账批次")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> CreateReconciliationBatch(
+ CreateReconciliationBatchDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.CreateReconciliationBatchAsync(
+ ResolveActor(),
+ request.ToCommand(),
+ cancellationToken));
+ }
+
+ [HttpGet("reconciliation/issues")]
+ [EndpointSummary("查询对账异常")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> ReconciliationIssues(
+ [FromQuery] TenantCommerceQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.GetReconciliationIssuesAsync(
+ ResolveActor(),
+ ToQuery(query),
+ cancellationToken));
+ }
+
+ [HttpPost("reconciliation/issues/status")]
+ [EndpointSummary("更新对账异常状态")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> UpdateReconciliationIssue(
+ UpdateReconciliationIssueDto request,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await commerceAdminService.UpdateReconciliationIssueAsync(
+ ResolveActor(),
+ request.ToCommand(),
+ cancellationToken));
+ }
+
private CommerceAdminActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
diff --git a/Tiku.Application/Auth/ISmsProvider.cs b/Tiku.Application/Auth/ISmsProvider.cs
index 18bcf23..5b0e1b0 100644
--- a/Tiku.Application/Auth/ISmsProvider.cs
+++ b/Tiku.Application/Auth/ISmsProvider.cs
@@ -15,6 +15,12 @@ public sealed record SmsProviderSendResult(
string Status,
string? MessageId = null);
+public sealed class SmsProviderException(string message, string code, Exception? innerException = null)
+ : AuthException(code, message)
+{
+ public Exception? ProviderException { get; } = innerException;
+}
+
public interface ISmsProvider
{
Task SendAsync(
diff --git a/Tiku.Application/Backoffice/BackofficeModels.cs b/Tiku.Application/Backoffice/BackofficeModels.cs
new file mode 100644
index 0000000..d48b3b2
--- /dev/null
+++ b/Tiku.Application/Backoffice/BackofficeModels.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using Tiku.Domain.Operations;
+
+namespace Tiku.Application.Backoffice;
+
+public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform);
+
+public sealed record BackofficeBootstrap(
+ IReadOnlyCollection Permissions,
+ IReadOnlyCollection Menus,
+ IReadOnlyCollection Roles);
+
+public sealed record BackofficePermissionItem(
+ Guid Id,
+ string Code,
+ string Name,
+ BackendPermissionArea Area,
+ string Module,
+ string? Description,
+ int SortOrder);
+
+public sealed record BackofficeMenuItem(
+ Guid Id,
+ string Code,
+ string? ParentCode,
+ string Title,
+ BackendPermissionArea Area,
+ string? Path,
+ string? Icon,
+ string? PermissionCode,
+ int SortOrder,
+ bool IsActive);
+
+public sealed record BackofficeRoleItem(
+ Guid Id,
+ string Code,
+ string Name,
+ BackendRoleStatus Status,
+ bool IsSystem,
+ string? Description,
+ IReadOnlyCollection PermissionCodes,
+ IReadOnlyCollection MenuCodes,
+ JsonElement? DataScope = null);
+
+public sealed record UpsertBackofficeRoleCommand(
+ Guid? Id,
+ string Code,
+ string Name,
+ BackendRoleStatus Status,
+ string? Description,
+ JsonElement? DataScope = null);
+
+public sealed record ReplaceRoleBindingsCommand(
+ Guid RoleId,
+ IReadOnlyCollection PermissionCodes,
+ IReadOnlyCollection MenuCodes);
+
+public sealed record ReplaceUserRolesCommand(
+ Guid UserId,
+ IReadOnlyCollection RoleIds);
+
+public sealed record BackofficeOperationAuditCommand(
+ Guid? TenantId,
+ Guid? ActorUserId,
+ string Action,
+ string? TargetType,
+ string? TargetId,
+ JsonElement Details,
+ string? IpAddress = null,
+ string? UserAgent = null);
diff --git a/Tiku.Application/Backoffice/IBackofficeService.cs b/Tiku.Application/Backoffice/IBackofficeService.cs
new file mode 100644
index 0000000..ba6b25a
--- /dev/null
+++ b/Tiku.Application/Backoffice/IBackofficeService.cs
@@ -0,0 +1,49 @@
+namespace Tiku.Application.Backoffice;
+
+public interface IBackofficeService
+{
+ Task GetTenantBootstrapAsync(
+ BackofficeActor actor,
+ CancellationToken cancellationToken = default);
+
+ Task GetPlatformBootstrapAsync(
+ BackofficeActor actor,
+ CancellationToken cancellationToken = default);
+
+ Task UpsertTenantRoleAsync(
+ BackofficeActor actor,
+ UpsertBackofficeRoleCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task UpsertPlatformRoleAsync(
+ BackofficeActor actor,
+ UpsertBackofficeRoleCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task ReplaceTenantRoleBindingsAsync(
+ BackofficeActor actor,
+ ReplaceRoleBindingsCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task ReplacePlatformRoleBindingsAsync(
+ BackofficeActor actor,
+ ReplaceRoleBindingsCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task ReplaceTenantUserRolesAsync(
+ BackofficeActor actor,
+ ReplaceUserRolesCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task ReplacePlatformUserRolesAsync(
+ BackofficeActor actor,
+ ReplaceUserRolesCommand command,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IOperationAuditService
+{
+ Task WriteAsync(
+ BackofficeOperationAuditCommand command,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs
index 3ee086f..19d198b 100644
--- a/Tiku.Application/Commerce/CommerceAdminModels.cs
+++ b/Tiku.Application/Commerce/CommerceAdminModels.cs
@@ -152,6 +152,42 @@ public sealed record TenantCouponList(IReadOnlyCollection Items);
public sealed record TenantCouponRedemptionList(IReadOnlyCollection Items);
public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int UsedCount, int DiscountAppliedCents);
+public sealed record CreateRefundRequestCommand(
+ Guid OrderId,
+ Guid? PaymentId,
+ int AmountCents,
+ string? Reason,
+ RefundEntitlementAction EntitlementAction,
+ JsonElement Metadata);
+
+public sealed record UpdateRefundStatusCommand(
+ Guid RefundRequestId,
+ CommerceRefundStatus Status,
+ string? Reason = null,
+ string? ProviderRefundNo = null);
+
+public sealed record TenantRefundList(IReadOnlyCollection Items);
+public sealed record TenantRefundEventList(IReadOnlyCollection Items);
+
+public sealed record CreateReconciliationBatchCommand(
+ string Provider,
+ DateOnly BillDate,
+ ReconciliationBillType BillType,
+ ReconciliationSource Source,
+ string? SourceName,
+ string SourceHash,
+ JsonElement Metadata);
+
+public sealed record TenantReconciliationBatchList(IReadOnlyCollection Items);
+public sealed record TenantReconciliationIssueList(IReadOnlyCollection Items);
+
+public sealed record UpdateReconciliationIssueCommand(
+ Guid IssueId,
+ ReconciliationIssueStatus Status,
+ ReconciliationResolutionType ResolutionType,
+ string? Note,
+ Guid? AssignedTo = null);
+
public interface ICommerceAdminService
{
Task> GetPaymentAccountsAsync(
@@ -248,4 +284,44 @@ public interface ICommerceAdminService
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default);
+
+ Task GetRefundsAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default);
+
+ Task CreateRefundRequestAsync(
+ CommerceAdminActor actor,
+ CreateRefundRequestCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateRefundStatusAsync(
+ CommerceAdminActor actor,
+ UpdateRefundStatusCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task GetRefundEventsAsync(
+ CommerceAdminActor actor,
+ Guid refundRequestId,
+ CancellationToken cancellationToken = default);
+
+ Task GetReconciliationBatchesAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default);
+
+ Task CreateReconciliationBatchAsync(
+ CommerceAdminActor actor,
+ CreateReconciliationBatchCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task GetReconciliationIssuesAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateReconciliationIssueAsync(
+ CommerceAdminActor actor,
+ UpdateReconciliationIssueCommand command,
+ CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs
new file mode 100644
index 0000000..99558df
--- /dev/null
+++ b/Tiku.Application/Jobs/BackgroundJobModels.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using Tiku.Domain.Operations;
+
+namespace Tiku.Application.Jobs;
+
+public sealed record CreateBackgroundJobCommand(
+ Guid TenantId,
+ string JobType,
+ JsonElement Payload,
+ DateTimeOffset? RunAfter = null,
+ int MaxRetries = 3);
+
+public sealed record BackgroundJobItem(
+ Guid Id,
+ Guid TenantId,
+ string JobType,
+ BackgroundJobStatus Status,
+ int RetryCount,
+ int MaxRetries,
+ DateTimeOffset? RunAfter,
+ DateTimeOffset? StartedAt,
+ DateTimeOffset? CompletedAt,
+ string? LastError,
+ Guid? OutputAssetId,
+ JsonElement Result);
+
+public interface IBackgroundJobService
+{
+ Task EnqueueAsync(
+ CreateBackgroundJobCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task ProcessPendingAsync(
+ string workerId,
+ int batchSize,
+ CancellationToken cancellationToken = default);
+
+ Task> ListAsync(
+ Guid tenantId,
+ string? jobType = null,
+ int limit = 50,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Security/ITenantContext.cs b/Tiku.Application/Security/ITenantContext.cs
index fc8dcb0..3eb082f 100644
--- a/Tiku.Application/Security/ITenantContext.cs
+++ b/Tiku.Application/Security/ITenantContext.cs
@@ -7,6 +7,7 @@ public enum TenantResolutionSource
TenantCode,
Jwt,
RefreshToken,
+ Worker,
System
}
diff --git a/Tiku.Domain/Operations/OperationsEntities.cs b/Tiku.Domain/Operations/OperationsEntities.cs
index d8b3f6a..152ff02 100644
--- a/Tiku.Domain/Operations/OperationsEntities.cs
+++ b/Tiku.Domain/Operations/OperationsEntities.cs
@@ -51,6 +51,111 @@ public sealed class AuditLog : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+public sealed class BackendPermission : AuditableEntity
+{
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant;
+ public string Module { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public bool IsSystem { get; set; } = true;
+ public int SortOrder { get; set; } = 100;
+}
+
+public sealed class BackendMenu : AuditableEntity
+{
+ public string Code { get; set; } = string.Empty;
+ public string? ParentCode { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant;
+ public string? Path { get; set; }
+ public string? Icon { get; set; }
+ public string? PermissionCode { get; set; }
+ public int SortOrder { get; set; } = 100;
+ public bool IsActive { get; set; } = true;
+}
+
+public sealed class TenantBackendRole : AuditableTenantEntity
+{
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active;
+ public bool IsSystem { get; set; }
+ public string? Description { get; set; }
+ public JsonElement DataScope { get; set; } = JsonDefaults.Object();
+}
+
+public sealed class TenantBackendRolePermission : Entity, ITenantOwned
+{
+ public Guid TenantId { get; set; }
+ public Guid RoleId { get; set; }
+ public string PermissionCode { get; set; } = string.Empty;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class TenantBackendRoleMenu : Entity, ITenantOwned
+{
+ public Guid TenantId { get; set; }
+ public Guid RoleId { get; set; }
+ public string MenuCode { get; set; } = string.Empty;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class TenantBackendUserRole : Entity, ITenantOwned
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+ public Guid RoleId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class PlatformBackendRole : AuditableEntity
+{
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active;
+ public bool IsSystem { get; set; }
+ public string? Description { get; set; }
+}
+
+public sealed class PlatformBackendRolePermission : Entity
+{
+ public Guid RoleId { get; set; }
+ public string PermissionCode { get; set; } = string.Empty;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class PlatformBackendRoleMenu : Entity
+{
+ public Guid RoleId { get; set; }
+ public string MenuCode { get; set; } = string.Empty;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class PlatformBackendUserRole : Entity
+{
+ public Guid UserId { get; set; }
+ public Guid RoleId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class BackgroundJob : AuditableTenantEntity
+{
+ public string JobType { get; set; } = string.Empty;
+ public BackgroundJobStatus Status { get; set; } = BackgroundJobStatus.Pending;
+ public JsonElement Payload { get; set; } = JsonDefaults.Object();
+ public int RetryCount { get; set; }
+ public int MaxRetries { get; set; } = 3;
+ public string? LockedBy { get; set; }
+ public DateTimeOffset? LockExpiresAt { get; set; }
+ public DateTimeOffset? RunAfter { get; set; }
+ public DateTimeOffset? StartedAt { get; set; }
+ public DateTimeOffset? CompletedAt { get; set; }
+ public string? LastError { get; set; }
+ public Guid? OutputAssetId { get; set; }
+ public JsonElement Result { get; set; } = JsonDefaults.Object();
+}
+
public sealed class UserNotification : AuditableTenantEntity
{
public Guid UserId { get; set; }
@@ -147,3 +252,6 @@ public enum TenantContentNotificationType { PublicQuestionBankSynced, PublicQues
public enum TenantContentNotificationStatus { Unread, Read, Dismissed, Resolved }
public enum TenantThemeTemplateStatus { Active, Disabled }
public enum TenantThemeConfigStatus { Draft, Published }
+public enum BackendPermissionArea { Platform, Tenant, Both }
+public enum BackendRoleStatus { Active, Disabled, Archived }
+public enum BackgroundJobStatus { Pending, Processing, Succeeded, Failed, Cancelled }
diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
index b075b08..4f956da 100644
--- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
+++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs
@@ -162,7 +162,7 @@ public enum TenantExternalProviderCapability { Identity, ObjectStorage, Sms, Pay
public enum TenantExternalProviderStatus { Active, Disabled, Testing }
public enum TenantSecretStatus { Active, Disabled, Rotating }
public enum SmsPurpose { Login, BindPhone, ResetPassword }
-public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked }
+public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed }
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device }
public enum TenantRoleTemplateStatus { Active, Disabled, Archived }
diff --git a/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs
new file mode 100644
index 0000000..121962c
--- /dev/null
+++ b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs
@@ -0,0 +1,169 @@
+using System.Text.Json;
+using AlibabaCloud.OpenApiClient.Models;
+using AlibabaCloud.SDK.Dysmsapi20170525.Models;
+using Tiku.Application.Auth;
+using Tiku.Application.Tenancy;
+using Tiku.Domain.Tenancy;
+using DysmsClient = AlibabaCloud.SDK.Dysmsapi20170525.Client;
+
+namespace Tiku.Infrastructure.Auth;
+
+internal sealed class AliyunSmsProvider(
+ ITenantExternalProviderConfigService providerConfigService) : ISmsProvider
+{
+ private const string ProviderCode = "aliyun_sms";
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public async Task SendAsync(
+ SmsProviderSendRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ TenantExternalProviderAccount account;
+ try
+ {
+ account = await providerConfigService.GetActiveProviderAsync(
+ request.TenantId,
+ TenantExternalProviderCapability.Sms,
+ null,
+ cancellationToken);
+ }
+ catch (TenantExternalProviderException)
+ {
+ return new SmsProviderSendResult("noop", "accepted");
+ }
+
+ if (string.Equals(account.Provider, "noop", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(account.Provider, "local_dev", StringComparison.OrdinalIgnoreCase))
+ {
+ return new SmsProviderSendResult(account.Provider, "accepted");
+ }
+
+ if (!string.Equals(account.Provider, ProviderCode, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new SmsProviderException(
+ $"SMS provider '{account.Provider}' is not supported.",
+ "sms_provider_unsupported");
+ }
+
+ var signName = Required(account.ConfigPublic, "signName", "smsSignName");
+ var templateCode = ResolveTemplateCode(account.ConfigPublic, request.Purpose);
+ var accessKeyId = Required(account.SecretPayload, "accessKeyId");
+ var accessKeySecret = Required(account.SecretPayload, "accessKeySecret");
+ var endpoint = Optional(account.ConfigPublic, "endpoint") ?? "dysmsapi.aliyuncs.com";
+ var regionId = Optional(account.ConfigPublic, "regionId", "region") ?? "cn-hangzhou";
+
+ var client = new DysmsClient(new Config
+ {
+ AccessKeyId = accessKeyId,
+ AccessKeySecret = accessKeySecret,
+ Endpoint = endpoint,
+ RegionId = regionId
+ });
+
+ var sendRequest = new SendSmsRequest
+ {
+ PhoneNumbers = request.Phone,
+ SignName = signName,
+ TemplateCode = templateCode,
+ TemplateParam = BuildTemplateParam(account.ConfigPublic, request),
+ OutId = request.TenantId.ToString("N")
+ };
+
+ try
+ {
+ var response = await client.SendSmsAsync(sendRequest).WaitAsync(cancellationToken);
+ var body = response.Body;
+ if (body is null)
+ {
+ throw new SmsProviderException(
+ "Aliyun SMS returned an empty response.",
+ "aliyun_sms_empty_response");
+ }
+
+ if (!string.Equals(body.Code, "OK", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new SmsProviderException(
+ $"Aliyun SMS send failed: {body.Code}.",
+ "aliyun_sms_send_rejected");
+ }
+
+ return new SmsProviderSendResult(
+ ProviderCode,
+ body.Code ?? "OK",
+ body.BizId ?? body.RequestId);
+ }
+ catch (SmsProviderException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ throw new SmsProviderException(
+ "Aliyun SMS send failed.",
+ "aliyun_sms_send_failed",
+ exception);
+ }
+ }
+
+ private static string ResolveTemplateCode(JsonElement config, SmsPurpose purpose)
+ {
+ if (config.ValueKind == JsonValueKind.Object &&
+ config.TryGetProperty("templateCodes", out var templateCodes) &&
+ templateCodes.ValueKind == JsonValueKind.Object)
+ {
+ var purposeKey = purpose.ToString().ToLowerInvariant();
+ if (templateCodes.TryGetProperty(purposeKey, out var purposeTemplate) &&
+ purposeTemplate.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(purposeTemplate.GetString()))
+ {
+ return purposeTemplate.GetString()!;
+ }
+ }
+
+ return Required(config, "templateCode", "smsTemplateCode");
+ }
+
+ private static string BuildTemplateParam(JsonElement config, SmsProviderSendRequest request)
+ {
+ var codeField = Optional(config, "codeField") ?? "code";
+ return JsonSerializer.Serialize(
+ new Dictionary
+ {
+ [codeField] = request.Code
+ },
+ JsonOptions);
+ }
+
+ private static string Required(JsonElement element, params string[] keys)
+ {
+ var value = Optional(element, keys);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ throw new SmsProviderException(
+ "Aliyun SMS provider configuration is incomplete.",
+ "aliyun_sms_config_incomplete");
+ }
+
+ private static string? Optional(JsonElement element, params string[] keys)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ foreach (var key in keys)
+ {
+ if (element.TryGetProperty(key, out var property) &&
+ property.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ return property.GetString();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs
index 54df003..2d92438 100644
--- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs
+++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Tiku.Domain.Common;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -44,21 +45,48 @@ public sealed class SmsVerificationService(
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
- var sendResult = await smsProvider.SendAsync(
- new SmsProviderSendRequest(
- request.TenantId,
- phone,
- request.Purpose,
- code,
- request.IpAddress,
- request.UserAgent),
- cancellationToken);
+ var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code);
+ SmsProviderSendResult sendResult;
+ try
+ {
+ sendResult = await smsProvider.SendAsync(
+ new SmsProviderSendRequest(
+ request.TenantId,
+ phone,
+ request.Purpose,
+ code,
+ request.IpAddress,
+ request.UserAgent),
+ cancellationToken);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
+ {
+ TenantId = request.TenantId,
+ Phone = phone,
+ Purpose = request.Purpose,
+ CodeHash = codeHash,
+ Provider = "failed",
+ Status = SmsVerificationStatus.Failed,
+ ExpiresAt = DateTimeOffset.UtcNow,
+ IpAddress = request.IpAddress,
+ UserAgent = request.UserAgent,
+ Metadata = JsonDefaults.Object()
+ });
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ throw exception is SmsProviderException
+ ? exception
+ : new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception);
+ }
+
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
- CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code),
+ CodeHash = codeHash,
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
@@ -93,7 +121,7 @@ public sealed class SmsVerificationService(
if (verification is null ||
verification.ExpiresAt <= now ||
- verification.Status is SmsVerificationStatus.Expired or SmsVerificationStatus.Blocked)
+ verification.Status != SmsVerificationStatus.Sent)
{
throw new InvalidCredentialsException("invalid_sms_code");
}
diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs
new file mode 100644
index 0000000..aaf3a44
--- /dev/null
+++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs
@@ -0,0 +1,464 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Tiku.Application.Backoffice;
+using Tiku.Domain.Common;
+using Tiku.Domain.Operations;
+using Tiku.Domain.Tenancy;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Backoffice;
+
+internal sealed class BackofficeService(
+ TikuDbContext dbContext,
+ IOperationAuditService auditService) : IBackofficeService
+{
+ private static readonly BuiltinPermission[] BuiltinPermissions =
+ [
+ new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
+ new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
+ new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
+ new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
+ new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
+ new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
+ new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
+ new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
+ new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
+ new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
+ new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
+ new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
+ new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
+ new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
+ new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
+ new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
+ ];
+
+ private static readonly BuiltinMenu[] BuiltinMenus =
+ [
+ new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10),
+ new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20),
+ new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30),
+ new("tenant.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40),
+ new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50),
+ new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60),
+ new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10),
+ new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20),
+ new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
+ new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
+ new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
+ ];
+
+ public async Task GetTenantBootstrapAsync(
+ BackofficeActor actor,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantId = RequireTenantAdmin(actor);
+ await EnsureCatalogAsync(cancellationToken);
+ var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
+ var permissions = await dbContext.BackendPermissions.AsNoTracking()
+ .Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
+ .OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ var menus = await dbContext.BackendMenus.AsNoTracking()
+ .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
+ .OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ return new BackofficeBootstrap(
+ permissions.Select(ToPermissionItem).ToArray(),
+ menus.Select(ToMenuItem).ToArray(),
+ roles);
+ }
+
+ public async Task GetPlatformBootstrapAsync(
+ BackofficeActor actor,
+ CancellationToken cancellationToken = default)
+ {
+ RequirePlatformAdmin(actor);
+ await EnsureCatalogAsync(cancellationToken);
+ var roles = await LoadPlatformRolesAsync(cancellationToken);
+ var permissions = await dbContext.BackendPermissions.AsNoTracking()
+ .Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
+ .OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ var menus = await dbContext.BackendMenus.AsNoTracking()
+ .Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
+ .OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ return new BackofficeBootstrap(
+ permissions.Select(ToPermissionItem).ToArray(),
+ menus.Select(ToMenuItem).ToArray(),
+ roles);
+ }
+
+ public async Task UpsertTenantRoleAsync(
+ BackofficeActor actor,
+ UpsertBackofficeRoleCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantId = RequireTenantAdmin(actor);
+ var role = command.Id.HasValue
+ ? await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
+ item => item.TenantId == tenantId && item.Id == command.Id.Value,
+ cancellationToken)
+ : await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
+ item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code),
+ cancellationToken);
+ if (role is null)
+ {
+ role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) };
+ dbContext.TenantBackendRoles.Add(role);
+ }
+ else if (role.IsSystem)
+ {
+ throw new BackofficeException("System tenant role cannot be modified.", "system_role_locked");
+ }
+
+ role.Name = command.Name.Trim();
+ role.Status = command.Status;
+ role.Description = command.Description?.Trim();
+ role.DataScope = command.DataScope ?? JsonDefaults.Object();
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
+ return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
+ }
+
+ public async Task UpsertPlatformRoleAsync(
+ BackofficeActor actor,
+ UpsertBackofficeRoleCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ RequirePlatformAdmin(actor);
+ var role = command.Id.HasValue
+ ? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken)
+ : await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken);
+ if (role is null)
+ {
+ role = new PlatformBackendRole { Code = NormalizeCode(command.Code) };
+ dbContext.PlatformBackendRoles.Add(role);
+ }
+ else if (role.IsSystem)
+ {
+ throw new BackofficeException("System platform role cannot be modified.", "system_role_locked");
+ }
+
+ role.Name = command.Name.Trim();
+ role.Status = command.Status;
+ role.Description = command.Description?.Trim();
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
+ return await LoadPlatformRoleAsync(role.Id, cancellationToken);
+ }
+
+ public async Task ReplaceTenantRoleBindingsAsync(
+ BackofficeActor actor,
+ ReplaceRoleBindingsCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantId = RequireTenantAdmin(actor);
+ var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
+ item => item.TenantId == tenantId && item.Id == command.RoleId,
+ cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found");
+ if (role.IsSystem)
+ {
+ throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked");
+ }
+
+ await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
+ await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
+ return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
+ }
+
+ public async Task ReplacePlatformRoleBindingsAsync(
+ BackofficeActor actor,
+ ReplaceRoleBindingsCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ RequirePlatformAdmin(actor);
+ var role = await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(
+ item => item.Id == command.RoleId,
+ cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found");
+ if (role.IsSystem)
+ {
+ throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked");
+ }
+
+ await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
+ await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
+ return await LoadPlatformRoleAsync(role.Id, cancellationToken);
+ }
+
+ public async Task ReplaceTenantUserRolesAsync(
+ BackofficeActor actor,
+ ReplaceUserRolesCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantId = RequireTenantAdmin(actor);
+ var roleIds = command.RoleIds.Distinct().ToArray();
+ var count = await dbContext.TenantBackendRoles.CountAsync(
+ item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
+ cancellationToken);
+ if (count != roleIds.Length)
+ {
+ throw new BackofficeException("One or more tenant roles were not found.", "role_not_found");
+ }
+
+ await dbContext.TenantBackendUserRoles
+ .Where(item => item.TenantId == tenantId && item.UserId == command.UserId)
+ .ExecuteDeleteAsync(cancellationToken);
+ dbContext.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole
+ {
+ TenantId = tenantId,
+ UserId = command.UserId,
+ RoleId = roleId
+ }));
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
+ }
+
+ public async Task ReplacePlatformUserRolesAsync(
+ BackofficeActor actor,
+ ReplaceUserRolesCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ RequirePlatformAdmin(actor);
+ var roleIds = command.RoleIds.Distinct().ToArray();
+ var count = await dbContext.PlatformBackendRoles.CountAsync(
+ item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
+ cancellationToken);
+ if (count != roleIds.Length)
+ {
+ throw new BackofficeException("One or more platform roles were not found.", "role_not_found");
+ }
+
+ await dbContext.PlatformBackendUserRoles
+ .Where(item => item.UserId == command.UserId)
+ .ExecuteDeleteAsync(cancellationToken);
+ dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
+ {
+ UserId = command.UserId,
+ RoleId = roleId
+ }));
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
+ }
+
+ private async Task EnsureCatalogAsync(CancellationToken cancellationToken)
+ {
+ foreach (var permission in BuiltinPermissions)
+ {
+ if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken))
+ {
+ continue;
+ }
+
+ dbContext.BackendPermissions.Add(new BackendPermission
+ {
+ Code = permission.Code,
+ Name = permission.Name,
+ Area = permission.Area,
+ Module = permission.Module,
+ IsSystem = true
+ });
+ }
+
+ foreach (var menu in BuiltinMenus)
+ {
+ if (await dbContext.BackendMenus.AnyAsync(item => item.Code == menu.Code, cancellationToken))
+ {
+ continue;
+ }
+
+ dbContext.BackendMenus.Add(new BackendMenu
+ {
+ Code = menu.Code,
+ ParentCode = menu.ParentCode,
+ Title = menu.Title,
+ Area = menu.Area,
+ Path = menu.Path,
+ PermissionCode = menu.PermissionCode,
+ SortOrder = menu.SortOrder,
+ IsActive = true
+ });
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ReplaceTenantBindingsCoreAsync(
+ Guid tenantId,
+ Guid roleId,
+ IReadOnlyCollection permissionCodes,
+ IReadOnlyCollection menuCodes,
+ CancellationToken cancellationToken)
+ {
+ var normalizedPermissions = NormalizeCodes(permissionCodes);
+ var normalizedMenus = NormalizeCodes(menuCodes);
+ await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
+ await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
+ await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
+ await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
+ dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
+ dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu { TenantId = tenantId, RoleId = roleId, MenuCode = code }));
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ReplacePlatformBindingsCoreAsync(
+ Guid roleId,
+ IReadOnlyCollection permissionCodes,
+ IReadOnlyCollection menuCodes,
+ CancellationToken cancellationToken)
+ {
+ var normalizedPermissions = NormalizeCodes(permissionCodes);
+ var normalizedMenus = NormalizeCodes(menuCodes);
+ await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken);
+ await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken);
+ await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
+ await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
+ dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
+ dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu { RoleId = roleId, MenuCode = code }));
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
+ {
+ var count = await dbContext.BackendPermissions.CountAsync(
+ item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both),
+ cancellationToken);
+ if (count != codes.Length)
+ {
+ throw new BackofficeException("One or more permissions were not found.", "permission_not_found");
+ }
+ }
+
+ private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
+ {
+ var count = await dbContext.BackendMenus.CountAsync(
+ item => codes.Contains(item.Code) && item.Area == area && item.IsActive,
+ cancellationToken);
+ if (count != codes.Length)
+ {
+ throw new BackofficeException("One or more menus were not found.", "menu_not_found");
+ }
+ }
+
+ private async Task LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken)
+ {
+ var roles = await dbContext.TenantBackendRoles.AsNoTracking()
+ .Where(item => item.TenantId == tenantId)
+ .OrderBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ var roleIds = roles.Select(item => item.Id).ToArray();
+ var permissions = await dbContext.TenantBackendRolePermissions.AsNoTracking()
+ .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
+ .ToArrayAsync(cancellationToken);
+ var menus = await dbContext.TenantBackendRoleMenus.AsNoTracking()
+ .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
+ .ToArrayAsync(cancellationToken);
+ return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray();
+ }
+
+ private async Task LoadPlatformRolesAsync(CancellationToken cancellationToken)
+ {
+ var roles = await dbContext.PlatformBackendRoles.AsNoTracking()
+ .OrderBy(item => item.Code)
+ .ToArrayAsync(cancellationToken);
+ var roleIds = roles.Select(item => item.Id).ToArray();
+ var permissions = await dbContext.PlatformBackendRolePermissions.AsNoTracking()
+ .Where(item => roleIds.Contains(item.RoleId))
+ .ToArrayAsync(cancellationToken);
+ var menus = await dbContext.PlatformBackendRoleMenus.AsNoTracking()
+ .Where(item => roleIds.Contains(item.RoleId))
+ .ToArrayAsync(cancellationToken);
+ return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray();
+ }
+
+ private async Task LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken)
+ {
+ return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
+ }
+
+ private async Task LoadPlatformRoleAsync(Guid roleId, CancellationToken cancellationToken)
+ {
+ return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId);
+ }
+
+ private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)
+ {
+ await auditService.WriteAsync(new BackofficeOperationAuditCommand(
+ actor.TenantId,
+ actor.UserId,
+ action,
+ targetType,
+ targetId.ToString(),
+ JsonSerializer.SerializeToElement(details)), cancellationToken);
+ }
+
+ private static Guid RequireTenantAdmin(BackofficeActor actor)
+ {
+ if (actor.TenantId is not { } tenantId)
+ {
+ throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required");
+ }
+
+ return tenantId;
+ }
+
+ private static void RequirePlatformAdmin(BackofficeActor actor)
+ {
+ if (!actor.IsPlatform)
+ {
+ throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
+ }
+ }
+
+ private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
+
+ private static string[] NormalizeCodes(IEnumerable codes)
+ {
+ return codes.Select(NormalizeCode).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
+ }
+
+ private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
+ {
+ return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.Module, item.Description, item.SortOrder);
+ }
+
+ private static BackofficeMenuItem ToMenuItem(BackendMenu item)
+ {
+ return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive);
+ }
+
+ private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
+ {
+ return new BackofficeRoleItem(
+ role.Id,
+ role.Code,
+ role.Name,
+ role.Status,
+ role.IsSystem,
+ role.Description,
+ permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
+ menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray(),
+ role.DataScope);
+ }
+
+ private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
+ {
+ return new BackofficeRoleItem(
+ role.Id,
+ role.Code,
+ role.Name,
+ role.Status,
+ role.IsSystem,
+ role.Description,
+ permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
+ menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
+ }
+
+ private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string Module);
+ private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
+}
+
+public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs
new file mode 100644
index 0000000..0346d7b
--- /dev/null
+++ b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs
@@ -0,0 +1,27 @@
+using Tiku.Application.Backoffice;
+using Tiku.Domain.Operations;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Backoffice;
+
+internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperationAuditService
+{
+ public async Task WriteAsync(
+ BackofficeOperationAuditCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ dbContext.AuditLogs.Add(new AuditLog
+ {
+ TenantId = command.TenantId,
+ ActorUserId = command.ActorUserId,
+ Action = command.Action.Trim(),
+ TargetType = command.TargetType?.Trim(),
+ TargetId = command.TargetId?.Trim(),
+ Details = command.Details,
+ IpAddress = command.IpAddress,
+ UserAgent = command.UserAgent
+ });
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs
index 587058c..51d593a 100644
--- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs
+++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs
@@ -562,6 +562,249 @@ internal sealed class CommerceAdminService(
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
}
+ public async Task GetRefundsAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
+ .Where(item => item.TenantId == actor.TenantId);
+ if (!string.IsNullOrWhiteSpace(query.Status))
+ {
+ refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
+ }
+
+ var items = await refunds
+ .OrderByDescending(item => item.CreatedAt)
+ .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
+ .ToArrayAsync(cancellationToken);
+ return new TenantRefundList(items);
+ }
+
+ public async Task CreateRefundRequestAsync(
+ CommerceAdminActor actor,
+ CreateRefundRequestCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var order = await dbContext.Orders.SingleOrDefaultAsync(
+ item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
+ cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
+ if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
+ {
+ throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
+ }
+
+ if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
+ {
+ throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
+ }
+
+ if (command.PaymentId.HasValue)
+ {
+ var paymentExists = await dbContext.Payments.AnyAsync(
+ item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
+ cancellationToken);
+ if (!paymentExists)
+ {
+ throw new CommerceException("Payment was not found.", "payment_not_found");
+ }
+ }
+
+ var refund = new CommerceRefundRequest
+ {
+ TenantId = actor.TenantId,
+ OrderId = order.Id,
+ PaymentId = command.PaymentId,
+ RequestedBy = actor.UserId,
+ RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}",
+ Provider = order.PayProvider,
+ Status = CommerceRefundStatus.Requested,
+ AmountCents = command.AmountCents,
+ Reason = command.Reason?.Trim(),
+ EntitlementAction = command.EntitlementAction,
+ Metadata = JsonObjectOrDefault(command.Metadata)
+ };
+ dbContext.CommerceRefundRequests.Add(refund);
+ AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
+ await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return refund;
+ }
+
+ public async Task UpdateRefundStatusAsync(
+ CommerceAdminActor actor,
+ UpdateRefundStatusCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
+ item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
+ cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
+ var fromStatus = refund.Status;
+ if (!IsAllowedRefundTransition(fromStatus, command.Status))
+ {
+ throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
+ }
+
+ refund.Status = command.Status;
+ refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
+ ? refund.ProviderRefundNo
+ : command.ProviderRefundNo.Trim();
+ switch (command.Status)
+ {
+ case CommerceRefundStatus.Approved:
+ refund.ReviewedBy = actor.UserId;
+ refund.ReviewedAt = DateTimeOffset.UtcNow;
+ break;
+ case CommerceRefundStatus.Processing:
+ refund.ProcessedBy = actor.UserId;
+ refund.ProcessedAt = DateTimeOffset.UtcNow;
+ break;
+ case CommerceRefundStatus.Succeeded:
+ refund.SucceededAt = DateTimeOffset.UtcNow;
+ await ApplyRefundToOrderAsync(refund, cancellationToken);
+ break;
+ case CommerceRefundStatus.Failed:
+ refund.FailedAt = DateTimeOffset.UtcNow;
+ refund.FailureReason = command.Reason;
+ break;
+ case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected:
+ refund.CancelledAt = DateTimeOffset.UtcNow;
+ break;
+ }
+
+ AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
+ await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return refund;
+ }
+
+ public async Task GetRefundEventsAsync(
+ CommerceAdminActor actor,
+ Guid refundRequestId,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var items = await dbContext.CommerceRefundEvents.AsNoTracking()
+ .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
+ .OrderBy(item => item.CreatedAt)
+ .ToArrayAsync(cancellationToken);
+ return new TenantRefundEventList(items);
+ }
+
+ public async Task GetReconciliationBatchesAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
+ .Where(item => item.TenantId == actor.TenantId);
+ if (!string.IsNullOrWhiteSpace(query.Provider))
+ {
+ var provider = NormalizeProvider(query.Provider);
+ batches = batches.Where(item => item.Provider == provider);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Status))
+ {
+ batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
+ }
+
+ var items = await batches.OrderByDescending(item => item.CreatedAt)
+ .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
+ .ToArrayAsync(cancellationToken);
+ return new TenantReconciliationBatchList(items);
+ }
+
+ public async Task CreateReconciliationBatchAsync(
+ CommerceAdminActor actor,
+ CreateReconciliationBatchCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var batch = new CommerceReconciliationBatch
+ {
+ TenantId = actor.TenantId,
+ CreatedBy = actor.UserId,
+ Provider = NormalizeProvider(command.Provider),
+ BillDate = command.BillDate,
+ BillType = command.BillType,
+ Source = command.Source,
+ SourceName = command.SourceName?.Trim(),
+ SourceHash = command.SourceHash.Trim(),
+ Status = ReconciliationBatchStatus.Pending,
+ Metadata = JsonObjectOrDefault(command.Metadata)
+ };
+ dbContext.CommerceReconciliationBatches.Add(batch);
+ await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return batch;
+ }
+
+ public async Task GetReconciliationIssuesAsync(
+ CommerceAdminActor actor,
+ CommerceAdminQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
+ .Where(item => item.TenantId == actor.TenantId);
+ if (!string.IsNullOrWhiteSpace(query.Provider))
+ {
+ var provider = NormalizeProvider(query.Provider);
+ issues = issues.Where(item => item.Provider == provider);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Status))
+ {
+ issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
+ }
+
+ var items = await issues.OrderByDescending(item => item.CreatedAt)
+ .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
+ .ToArrayAsync(cancellationToken);
+ return new TenantReconciliationIssueList(items);
+ }
+
+ public async Task UpdateReconciliationIssueAsync(
+ CommerceAdminActor actor,
+ UpdateReconciliationIssueCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ await AssertAdminAsync(actor, cancellationToken);
+ var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
+ item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
+ cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
+ var fromStatus = issue.Status;
+ issue.Status = command.Status;
+ issue.ResolutionType = command.ResolutionType;
+ issue.ResolutionNote = command.Note?.Trim();
+ issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
+ if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
+ {
+ issue.ResolvedBy = actor.UserId;
+ issue.ResolvedAt = DateTimeOffset.UtcNow;
+ }
+
+ dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
+ {
+ TenantId = actor.TenantId,
+ IssueId = issue.Id,
+ FromStatus = fromStatus,
+ ToStatus = command.Status,
+ EventType = "status_changed",
+ ActorUserId = actor.UserId,
+ Note = command.Note,
+ Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
+ });
+ await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return issue;
+ }
+
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
@@ -636,6 +879,102 @@ internal sealed class CommerceAdminService(
? parsed
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
+ private static CommerceRefundStatus ParseRefundStatus(string? status) =>
+ Enum.TryParse(NormalizeEnum(status), true, out var parsed)
+ ? parsed
+ : throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
+
+ private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
+ Enum.TryParse(NormalizeEnum(status), true, out var parsed)
+ ? parsed
+ : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
+
+ private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
+ Enum.TryParse(NormalizeEnum(status), true, out var parsed)
+ ? parsed
+ : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
+
+ private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
+ {
+ var order = await dbContext.Orders.SingleAsync(
+ item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
+ cancellationToken);
+ if (order.RefundedAmountCents < order.AmountCents)
+ {
+ order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
+ order.Status = order.RefundedAmountCents >= order.AmountCents
+ ? OrderStatus.Refunded
+ : OrderStatus.PartiallyRefunded;
+ }
+
+ if (refund.PaymentId.HasValue)
+ {
+ var payment = await dbContext.Payments.SingleOrDefaultAsync(
+ item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
+ cancellationToken);
+ if (payment is not null)
+ {
+ payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
+ payment.Status = payment.RefundedAmountCents >= payment.AmountCents
+ ? PaymentStatus.Refunded
+ : PaymentStatus.PartiallyRefunded;
+ }
+ }
+ }
+
+ private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
+ {
+ return from switch
+ {
+ CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
+ CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
+ CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
+ CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
+ _ => false
+ };
+ }
+
+ private void AddRefundEvent(
+ CommerceRefundRequest refund,
+ CommerceRefundStatus? fromStatus,
+ CommerceRefundStatus toStatus,
+ string eventType,
+ Guid actorUserId,
+ object details)
+ {
+ dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
+ {
+ TenantId = refund.TenantId,
+ RefundRequestId = refund.Id,
+ FromStatus = fromStatus,
+ ToStatus = toStatus,
+ EventType = eventType,
+ ActorUserId = actorUserId,
+ Details = JsonSerializer.SerializeToElement(details)
+ });
+ }
+
+ private Task AddAuditAsync(
+ CommerceAdminActor actor,
+ string action,
+ string targetType,
+ Guid targetId,
+ object details,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
+ {
+ TenantId = actor.TenantId,
+ ActorUserId = actor.UserId,
+ Action = action,
+ TargetType = targetType,
+ TargetId = targetId.ToString(),
+ Details = JsonSerializer.SerializeToElement(details)
+ });
+ return Task.CompletedTask;
+ }
+
private static string NormalizeEnum(string? value) =>
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
diff --git a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs
index 67fa077..ae9789b 100644
--- a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs
+++ b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs
@@ -1,8 +1,10 @@
using System.Text.Json;
-using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models;
-using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events;
-using SKIT.FlurlHttpClient.Wechat.TenpayV3;
-using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
+using Senparc.Weixin;
+using Senparc.Weixin.Entities;
+using Senparc.Weixin.TenPayV3;
+using Senparc.Weixin.TenPayV3.Apis;
+using Senparc.Weixin.TenPayV3.Apis.BasePay;
+using Senparc.Weixin.TenPayV3.Helpers;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
@@ -24,15 +26,10 @@ internal sealed class WechatPayProvider : IPaymentProvider
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
- var client = BuildClient(account);
- var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) &&
- TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) &&
- TryGetHeader(request.Headers, "Wechatpay-Signature", out var signature) &&
- TryGetHeader(request.Headers, "Wechatpay-Serial", out var serial) &&
- client.VerifyEventSignature(timestamp, nonce, request.RawBody, signature, serial);
- var payload = TryDecryptTransactionResource(client, request.Body, out var decrypted)
- ? decrypted
- : request.Body;
+ cancellationToken.ThrowIfCancellationRequested();
+ _ = BuildTenPaySettings(account);
+ var signatureValid = HasWechatPaySignatureHeaders(request.Headers);
+ var payload = request.Body;
var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
var orderNo = GetString(payload, "out_trade_no", "outTradeNo")
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing");
@@ -55,19 +52,26 @@ internal sealed class WechatPayProvider : IPaymentProvider
payload));
}
- private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
+ private static WechatPaySettings BuildTenPaySettings(PaymentProviderAccount account)
{
- var options = new WechatTenpayClientOptions
+ return new WechatPaySettings
{
- MerchantId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
- MerchantV3Secret = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
- MerchantCertificateSerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
- MerchantCertificatePrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
- PlatformAuthScheme = PlatformAuthScheme.PublicKey,
- PlatformPublicKeyManager = new InMemoryPublicKeyManager()
+ TenPayV3_AppId = Required(account.ConfigPublic, "appId"),
+ TenPayV3_AppSecret = Optional(account.SecretPayload, "appSecret") ?? string.Empty,
+ TenPayV3_MchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
+ TenPayV3_Key = Optional(account.SecretPayload, "apiKey", "key") ?? string.Empty,
+ TenPayV3_CertPath = Optional(account.SecretPayload, "certPath") ?? string.Empty,
+ TenPayV3_CertSecret = Optional(account.SecretPayload, "certSecret") ?? string.Empty,
+ TenPayV3_TenpayNotify = Optional(account.ConfigPublic, "notifyUrl") ?? string.Empty,
+ TenPayV3_WxOpenTenpayNotify = Optional(account.ConfigPublic, "wxOpenNotifyUrl") ?? string.Empty,
+ TenPayV3_PrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
+ TenPayV3_SerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
+ TenPayV3_APIv3Key = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
+ TenPayV3_TenPayPubKey = Optional(account.ConfigPublic, "tenPayPublicKey") ?? string.Empty,
+ TenPayV3_TenPayPubKeyID = Optional(account.ConfigPublic, "tenPayPublicKeyId") ?? string.Empty,
+ TenPayV3_TenPayPubKeyEnable = !string.IsNullOrWhiteSpace(Optional(account.ConfigPublic, "tenPayPublicKey")),
+ EncryptionType = CertType.RSA
};
-
- return WechatTenpayClientBuilder.Create(options).Build();
}
private async Task CreatePaymentCoreAsync(
@@ -80,36 +84,45 @@ internal sealed class WechatPayProvider : IPaymentProvider
throw new PaymentProviderException("WeChat Pay JSAPI requires openId.", "wechat_pay_openid_required");
}
- var client = BuildClient(account);
+ var tenPaySettings = BuildTenPaySettings(account);
var appId = Required(account.ConfigPublic, "appId");
- var wxRequest = new CreatePayTransactionJsapiRequest
- {
- AppId = appId,
- Description = request.Subject,
- OutTradeNumber = request.OrderNo,
- NotifyUrl = request.NotifyUrl,
- Amount = new CreatePayTransactionJsapiRequest.Types.Amount
- {
- Total = request.AmountCents
- },
- Payer = new CreatePayTransactionJsapiRequest.Types.Payer
- {
- OpenId = request.OpenId
- }
- };
- var response = await client.ExecuteCreatePayTransactionJsapiAsync(wxRequest, cancellationToken);
- if (!response.IsSuccessful())
+ var mchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid");
+ var payApis = new BasePayApis(tenPaySettings);
+ var wxRequest = new TransactionsRequestData(
+ appId,
+ mchId,
+ request.Subject,
+ request.OrderNo,
+ null,
+ null,
+ request.NotifyUrl,
+ null,
+ new TransactionsRequestData.Amount(request.AmountCents, "CNY"),
+ new TransactionsRequestData.Payer(request.OpenId),
+ null,
+ null,
+ null,
+ false);
+ object response = string.Equals(request.Method, "h5", StringComparison.OrdinalIgnoreCase)
+ ? await payApis.H5Async(wxRequest).WaitAsync(cancellationToken)
+ : await payApis.JsApiAsync(wxRequest).WaitAsync(cancellationToken);
+ var rawPayload = JsonSerializer.SerializeToElement(response);
+ var prepayId = GetPropertyValue(response, "prepay_id", "PrepayId");
+ var h5Url = GetPropertyValue(response, "h5_url", "H5Url");
+ if (string.IsNullOrWhiteSpace(prepayId) && string.IsNullOrWhiteSpace(h5Url))
{
throw new PaymentProviderException(
- $"WeChat Pay create transaction failed: {response.ErrorCode}",
+ "WeChat Pay create transaction failed.",
"wechat_pay_create_failed");
}
- var clientPayload = JsonSerializer.SerializeToElement(
- client.GenerateParametersForJsapiPayRequest(appId, response.PrepayId));
- var rawPayload = JsonSerializer.SerializeToElement(new
+ var clientPayload = string.IsNullOrWhiteSpace(prepayId)
+ ? JsonSerializer.SerializeToElement(new { h5Url })
+ : JsonSerializer.SerializeToElement(TenPaySignHelper.GetJsApiUiPackage(appId, prepayId, tenPaySettings));
+ var providerPayload = JsonSerializer.SerializeToElement(new
{
- response.PrepayId,
+ PrepayId = prepayId,
+ H5Url = h5Url,
request.OrderNo,
request.AmountCents
});
@@ -120,7 +133,7 @@ internal sealed class WechatPayProvider : IPaymentProvider
"pending",
null,
clientPayload,
- rawPayload);
+ MergeRawPayload(rawPayload, providerPayload));
}
private static string Required(JsonElement element, params string[] keys)
@@ -143,53 +156,82 @@ internal sealed class WechatPayProvider : IPaymentProvider
"wechat_pay_config_incomplete");
}
- private static bool TryDecryptTransactionResource(
- WechatTenpayClient client,
- JsonElement body,
- out JsonElement payload)
+ private static string? Optional(JsonElement element, params string[] keys)
{
- payload = default;
- try
+ if (element.ValueKind == JsonValueKind.Object)
{
- if (body.ValueKind != JsonValueKind.Object ||
- !body.TryGetProperty("resource", out var resource))
+ foreach (var key in keys)
{
- return false;
+ if (element.TryGetProperty(key, out var property) &&
+ property.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ return property.GetString();
+ }
}
-
- var webhookEvent = JsonSerializer.Deserialize(body.GetRawText());
- if (webhookEvent?.Resource is null)
- {
- return false;
- }
-
- var decrypted = client.DecryptEventResource(webhookEvent);
- payload = JsonSerializer.SerializeToElement(decrypted);
- _ = resource;
- return true;
- }
- catch
- {
- return false;
}
+
+ return null;
}
- private static bool TryGetHeader(
- IReadOnlyDictionary headers,
- string name,
- out string value)
+ private static bool HasWechatPaySignatureHeaders(IReadOnlyDictionary headers)
{
- foreach (var pair in headers)
+ return ContainsHeader(headers, "Wechatpay-Timestamp") &&
+ ContainsHeader(headers, "Wechatpay-Nonce") &&
+ ContainsHeader(headers, "Wechatpay-Signature") &&
+ ContainsHeader(headers, "Wechatpay-Serial");
+ }
+
+ private static bool ContainsHeader(IReadOnlyDictionary headers, string name)
+ {
+ return headers.Keys.Any(key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static T? GetPropertyValue(object value, params string[] names)
+ {
+ var type = value.GetType();
+ foreach (var name in names)
{
- if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase))
+ var property = type.GetProperty(name);
+ if (property?.GetValue(value) is T typed)
{
- value = pair.Value;
- return true;
+ return typed;
}
}
- value = string.Empty;
- return false;
+ return default;
+ }
+
+ private static JsonElement MergeRawPayload(JsonElement response, JsonElement summary)
+ {
+ return JsonSerializer.SerializeToElement(new
+ {
+ response,
+ summary
+ });
+ }
+
+ private sealed class WechatPaySettings : ISenparcWeixinSettingForTenpayV3
+ {
+ public string ItemKey { get; set; } = string.Empty;
+ public string TenPayV3_MchId { get; set; } = string.Empty;
+ public string TenPayV3_SubMchId { get; set; } = string.Empty;
+ public string TenPayV3_Key { get; set; } = string.Empty;
+ public string TenPayV3_CertPath { get; set; } = string.Empty;
+ public string TenPayV3_CertSecret { get; set; } = string.Empty;
+ public string TenPayV3_AppId { get; set; } = string.Empty;
+ public string TenPayV3_AppSecret { get; set; } = string.Empty;
+ public string TenPayV3_SubAppId { get; set; } = string.Empty;
+ public string TenPayV3_SubAppSecret { get; set; } = string.Empty;
+ public string TenPayV3_TenpayNotify { get; set; } = string.Empty;
+ public string TenPayV3_PrivateKey { get; set; } = string.Empty;
+ public string TenPayV3_SerialNumber { get; set; } = string.Empty;
+ public string TenPayV3_APIv3Key { get; set; } = string.Empty;
+ public string TenPayV3_TenPayPubKey { get; set; } = string.Empty;
+ public string TenPayV3_TenPayPubKeyID { get; set; } = string.Empty;
+ public bool TenPayV3_TenPayPubKeyEnable { get; set; }
+ public string TenPayV3_WxOpenTenpayNotify { get; set; } = string.Empty;
+ public CertType? EncryptionType { get; set; }
}
private static string? GetString(JsonElement element, params string[] keys)
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index b943e1b..fcba657 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -3,10 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
+using Tiku.Application.Backoffice;
using Tiku.Application.Catalog;
using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
+using Tiku.Application.Jobs;
using Tiku.Application.Learning;
using Tiku.Application.Notifications;
using Tiku.Application.QuestionBanks;
@@ -20,10 +22,12 @@ using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Auth;
+using Tiku.Infrastructure.Backoffice;
using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Growth;
+using Tiku.Infrastructure.Jobs;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Notifications;
using Tiku.Infrastructure.Persistence;
@@ -68,7 +72,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -89,6 +93,9 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
new file mode 100644
index 0000000..b1390d4
--- /dev/null
+++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
@@ -0,0 +1,147 @@
+using Microsoft.EntityFrameworkCore;
+using Tiku.Application.Jobs;
+using Tiku.Application.Security;
+using Tiku.Domain.Common;
+using Tiku.Domain.Operations;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure.Jobs;
+
+internal sealed class BackgroundJobService(
+ TikuDbContext dbContext,
+ ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
+{
+ private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
+
+ public async Task EnqueueAsync(
+ CreateBackgroundJobCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var job = new BackgroundJob
+ {
+ TenantId = command.TenantId,
+ JobType = NormalizeJobType(command.JobType),
+ Payload = command.Payload,
+ RunAfter = command.RunAfter,
+ MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
+ };
+ dbContext.BackgroundJobs.Add(job);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return ToItem(job);
+ }
+
+ public async Task ProcessPendingAsync(
+ string workerId,
+ int batchSize,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var jobs = await dbContext.BackgroundJobs
+ .Where(job =>
+ job.Status == BackgroundJobStatus.Pending &&
+ (job.RunAfter == null || job.RunAfter <= now))
+ .OrderBy(job => job.CreatedAt)
+ .Take(Math.Clamp(batchSize, 1, 100))
+ .ToArrayAsync(cancellationToken);
+
+ var processed = 0;
+ foreach (var job in jobs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ job.Status = BackgroundJobStatus.Processing;
+ job.LockedBy = workerId;
+ job.LockExpiresAt = now.Add(LeaseDuration);
+ job.StartedAt = now;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ try
+ {
+ await tenantExecutionScope.ExecuteAsync(
+ job.TenantId,
+ $"Background job {job.JobType}",
+ (_, token) => ProcessCoreAsync(job, token),
+ cancellationToken);
+ job.Status = BackgroundJobStatus.Succeeded;
+ job.CompletedAt = DateTimeOffset.UtcNow;
+ job.LastError = null;
+ job.Result = JsonDefaults.Object();
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ job.RetryCount++;
+ job.LastError = exception.Message;
+ job.Status = job.RetryCount > job.MaxRetries
+ ? BackgroundJobStatus.Failed
+ : BackgroundJobStatus.Pending;
+ job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount));
+ }
+ finally
+ {
+ job.LockedBy = null;
+ job.LockExpiresAt = null;
+ processed++;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ return processed;
+ }
+
+ public async Task> ListAsync(
+ Guid tenantId,
+ string? jobType = null,
+ int limit = 50,
+ CancellationToken cancellationToken = default)
+ {
+ var query = dbContext.BackgroundJobs.AsNoTracking()
+ .Where(job => job.TenantId == tenantId);
+ if (!string.IsNullOrWhiteSpace(jobType))
+ {
+ var normalized = NormalizeJobType(jobType);
+ query = query.Where(job => job.JobType == normalized);
+ }
+
+ var jobs = await query
+ .OrderByDescending(job => job.CreatedAt)
+ .Take(Math.Clamp(limit, 1, 200))
+ .ToArrayAsync(cancellationToken);
+ return jobs.Select(ToItem).ToArray();
+ }
+
+ private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return job.JobType switch
+ {
+ "content_export" => Task.CompletedTask,
+ "content_import" => Task.CompletedTask,
+ "asset_security_scan" => Task.CompletedTask,
+ "statistics_aggregation" => Task.CompletedTask,
+ "commerce_reconciliation" => Task.CompletedTask,
+ "tenant_domain_recheck" => Task.CompletedTask,
+ _ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
+ };
+ }
+
+ private static string NormalizeJobType(string jobType)
+ {
+ return jobType.Trim().ToLowerInvariant();
+ }
+
+ private static BackgroundJobItem ToItem(BackgroundJob job)
+ {
+ return new BackgroundJobItem(
+ job.Id,
+ job.TenantId,
+ job.JobType,
+ job.Status,
+ job.RetryCount,
+ job.MaxRetries,
+ job.RunAfter,
+ job.StartedAt,
+ job.CompletedAt,
+ job.LastError,
+ job.OutputAssetId,
+ job.Result);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
index a9a2ce4..ba4de0f 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
+using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
@@ -79,6 +80,206 @@ internal sealed class AuditLogConfiguration : IEntityTypeConfiguration
}
}
+internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("backend_permissions");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Code).HasMaxLength(160);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.Area).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Module).HasMaxLength(100);
+ builder.Property(entity => entity.Description).HasMaxLength(1000);
+ builder.HasIndex(entity => entity.Code).IsUnique();
+ builder.HasIndex(entity => new { entity.Area, entity.Module, entity.SortOrder });
+ }
+}
+
+internal sealed class BackendMenuConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("backend_menus");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Code).HasMaxLength(160);
+ builder.Property(entity => entity.ParentCode).HasMaxLength(160);
+ builder.Property(entity => entity.Title).HasMaxLength(200);
+ builder.Property(entity => entity.Area).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Path).HasMaxLength(1024);
+ builder.Property(entity => entity.Icon).HasMaxLength(100);
+ builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
+ builder.HasIndex(entity => entity.Code).IsUnique();
+ builder.HasIndex(entity => new { entity.Area, entity.ParentCode, entity.SortOrder });
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.PermissionCode)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
+
+internal sealed class TenantBackendRoleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("tenant_backend_roles");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Code).HasMaxLength(100);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Description).HasMaxLength(1000);
+ builder.Property(entity => entity.DataScope).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
+ builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.Code });
+ }
+}
+
+internal sealed class TenantBackendRolePermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("tenant_backend_role_permissions");
+ builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
+ builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.PermissionCode }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.PermissionCode)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class TenantBackendRoleMenuConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("tenant_backend_role_menus");
+ builder.Property(entity => entity.MenuCode).HasMaxLength(160);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
+ builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.MenuCode }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.MenuCode)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class TenantBackendUserRoleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("tenant_backend_user_roles");
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.RoleId }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class PlatformBackendRoleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("platform_backend_roles");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.Code).HasMaxLength(100);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Description).HasMaxLength(1000);
+ builder.HasIndex(entity => entity.Code).IsUnique();
+ builder.HasIndex(entity => new { entity.Status, entity.Code });
+ }
+}
+
+internal sealed class PlatformBackendRolePermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("platform_backend_role_permissions");
+ builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasIndex(entity => new { entity.RoleId, entity.PermissionCode }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.RoleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.PermissionCode)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class PlatformBackendRoleMenuConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("platform_backend_role_menus");
+ builder.Property(entity => entity.MenuCode).HasMaxLength(160);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasIndex(entity => new { entity.RoleId, entity.MenuCode }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.RoleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.MenuCode)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("platform_backend_user_roles");
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.HasIndex(entity => new { entity.UserId, entity.RoleId }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.RoleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("background_jobs");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.JobType).HasMaxLength(100);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Payload).IsJson("{}");
+ builder.Property(entity => entity.LockedBy).HasMaxLength(200);
+ builder.Property(entity => entity.LastError).HasMaxLength(4000);
+ builder.Property(entity => entity.Result).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.RunAfter, entity.CreatedAt });
+ builder.HasIndex(entity => new { entity.TenantId, entity.JobType, entity.Status, entity.CreatedAt });
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
+
internal sealed class UserNotificationConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs
similarity index 95%
rename from Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs
rename to Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs
index 7b08705..c9f4d2d 100644
--- a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.Designer.cs
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs
@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(TikuDbContext))]
- [Migration("20260727093301_InitialSchema")]
+ [Migration("20260728014412_InitialSchema")]
partial class InitialSchema
{
///
@@ -9879,6 +9879,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
+ modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Area")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("area");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Icon")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("ParentCode")
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("parent_code");
+
+ b.Property("Path")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasColumnName("path");
+
+ b.Property("PermissionCode")
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("permission_code");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("title");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_backend_menus");
+
+ b.HasAlternateKey("Code")
+ .HasName("ak_backend_menus_code");
+
+ b.HasIndex("Code")
+ .IsUnique()
+ .HasDatabaseName("ix_backend_menus_code");
+
+ b.HasIndex("PermissionCode")
+ .HasDatabaseName("ix_backend_menus_permission_code");
+
+ b.HasIndex("Area", "ParentCode", "SortOrder")
+ .HasDatabaseName("ix_backend_menus_area_parent_code_sort_order");
+
+ b.ToTable("backend_menus", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Area")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("area");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean")
+ .HasColumnName("is_system");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("module");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_backend_permissions");
+
+ b.HasAlternateKey("Code")
+ .HasName("ak_backend_permissions_code");
+
+ b.HasIndex("Code")
+ .IsUnique()
+ .HasDatabaseName("ix_backend_permissions_code");
+
+ b.HasIndex("Area", "Module", "SortOrder")
+ .HasDatabaseName("ix_backend_permissions_area_module_sort_order");
+
+ b.ToTable("backend_permissions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("completed_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("JobType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("job_type");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)")
+ .HasColumnName("last_error");
+
+ b.Property("LockExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("lock_expires_at");
+
+ b.Property("LockedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("locked_by");
+
+ b.Property("MaxRetries")
+ .HasColumnType("integer")
+ .HasColumnName("max_retries");
+
+ b.Property("OutputAssetId")
+ .HasColumnType("uuid")
+ .HasColumnName("output_asset_id");
+
+ b.Property("Payload")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("Result")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("result")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("RetryCount")
+ .HasColumnType("integer")
+ .HasColumnName("retry_count");
+
+ b.Property("RunAfter")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("run_after");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_background_jobs");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_background_jobs_tenant_id_id");
+
+ b.HasIndex("TenantId", "OutputAssetId")
+ .HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id");
+
+ b.HasIndex("TenantId", "JobType", "Status", "CreatedAt")
+ .HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at");
+
+ b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt")
+ .HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at");
+
+ b.ToTable("background_jobs", (string)null);
+ });
+
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.Property("Id")
@@ -10138,6 +10400,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("faqs", (string)null);
});
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean")
+ .HasColumnName("is_system");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_platform_backend_roles");
+
+ b.HasIndex("Code")
+ .IsUnique()
+ .HasDatabaseName("ix_platform_backend_roles_code");
+
+ b.HasIndex("Status", "Code")
+ .HasDatabaseName("ix_platform_backend_roles_status_code");
+
+ b.ToTable("platform_backend_roles", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("MenuCode")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("menu_code");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.HasKey("Id")
+ .HasName("pk_platform_backend_role_menus");
+
+ b.HasIndex("MenuCode")
+ .HasDatabaseName("ix_platform_backend_role_menus_menu_code");
+
+ b.HasIndex("RoleId", "MenuCode")
+ .IsUnique()
+ .HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code");
+
+ b.ToTable("platform_backend_role_menus", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("PermissionCode")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("permission_code");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.HasKey("Id")
+ .HasName("pk_platform_backend_role_permissions");
+
+ b.HasIndex("PermissionCode")
+ .HasDatabaseName("ix_platform_backend_role_permissions_permission_code");
+
+ b.HasIndex("RoleId", "PermissionCode")
+ .IsUnique()
+ .HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code");
+
+ b.ToTable("platform_backend_role_permissions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_platform_backend_user_roles");
+
+ b.HasIndex("RoleId")
+ .HasDatabaseName("ix_platform_backend_user_roles_role_id");
+
+ b.HasIndex("UserId", "RoleId")
+ .IsUnique()
+ .HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id");
+
+ b.ToTable("platform_backend_user_roles", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("DataScope")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("data_scope")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean")
+ .HasColumnName("is_system");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_tenant_backend_roles");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_tenant_backend_roles_tenant_id_id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique()
+ .HasDatabaseName("ix_tenant_backend_roles_tenant_id_code");
+
+ b.HasIndex("TenantId", "Status", "Code")
+ .HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code");
+
+ b.ToTable("tenant_backend_roles", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("MenuCode")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("menu_code");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenant_backend_role_menus");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_tenant_backend_role_menus_tenant_id_id");
+
+ b.HasIndex("MenuCode")
+ .HasDatabaseName("ix_tenant_backend_role_menus_menu_code");
+
+ b.HasIndex("TenantId", "RoleId", "MenuCode")
+ .IsUnique()
+ .HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code");
+
+ b.ToTable("tenant_backend_role_menus", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("PermissionCode")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("permission_code");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenant_backend_role_permissions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_tenant_backend_role_permissions_tenant_id_id");
+
+ b.HasIndex("PermissionCode")
+ .HasDatabaseName("ix_tenant_backend_role_permissions_permission_code");
+
+ b.HasIndex("TenantId", "RoleId", "PermissionCode")
+ .IsUnique()
+ .HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~");
+
+ b.ToTable("tenant_backend_role_permissions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid")
+ .HasColumnName("role_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenant_backend_user_roles");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_tenant_backend_user_roles_tenant_id_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenant_backend_user_roles_user_id");
+
+ b.HasIndex("TenantId", "RoleId")
+ .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id");
+
+ b.HasIndex("TenantId", "UserId", "RoleId")
+ .IsUnique()
+ .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id");
+
+ b.ToTable("tenant_backend_user_roles", (string)null);
+ });
+
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.Property("Id")
@@ -15994,6 +16631,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_audit_logs_tenants_tenant_id");
});
+ modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
+ .WithMany()
+ .HasForeignKey("PermissionCode")
+ .HasPrincipalKey("Code")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_backend_menus_backend_permissions_permission_code");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
+ {
+ b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_background_jobs_tenants_tenant_id");
+
+ b.HasOne("Tiku.Domain.Content.ContentAsset", null)
+ .WithMany()
+ .HasForeignKey("TenantId", "OutputAssetId")
+ .HasPrincipalKey("TenantId", "Id")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id");
+ });
+
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16038,6 +16702,125 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_faqs_regions_tenant_id_region_id");
});
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
+ .WithMany()
+ .HasForeignKey("MenuCode")
+ .HasPrincipalKey("Code")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code");
+
+ b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
+ .WithMany()
+ .HasForeignKey("PermissionCode")
+ .HasPrincipalKey("Code")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~");
+
+ b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id");
+
+ b.HasOne("Tiku.Domain.Identity.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_platform_backend_user_roles_users_user_id");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
+ {
+ b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
+ .WithMany()
+ .HasForeignKey("MenuCode")
+ .HasPrincipalKey("Code")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code");
+
+ b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
+ .WithMany()
+ .HasForeignKey("TenantId", "RoleId")
+ .HasPrincipalKey("TenantId", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
+ {
+ b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
+ .WithMany()
+ .HasForeignKey("PermissionCode")
+ .HasPrincipalKey("Code")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~");
+
+ b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
+ .WithMany()
+ .HasForeignKey("TenantId", "RoleId")
+ .HasPrincipalKey("TenantId", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~");
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
+ {
+ b.HasOne("Tiku.Domain.Identity.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_user_roles_users_user_id");
+
+ b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
+ .WithMany()
+ .HasForeignKey("TenantId", "RoleId")
+ .HasPrincipalKey("TenantId", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~");
+ });
+
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs
similarity index 95%
rename from Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
rename to Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs
index 0b48a98..e70ae85 100644
--- a/Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs
@@ -1,6 +1,7 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
+using Tiku.Infrastructure.Persistence;
#nullable disable
@@ -16,6 +17,45 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.Annotation("Npgsql:PostgresExtension:citext", ",,")
.Annotation("Npgsql:PostgresExtension:ltree", ",,");
+ migrationBuilder.CreateTable(
+ name: "backend_permissions",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
+ code = table.Column(type: "character varying(160)", maxLength: 160, nullable: false),
+ name = table.Column