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