feat: add phase five operations foundation

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

View File

@@ -0,0 +1,169 @@
using System.Text.Json;
using AlibabaCloud.OpenApiClient.Models;
using AlibabaCloud.SDK.Dysmsapi20170525.Models;
using Tiku.Application.Auth;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using DysmsClient = AlibabaCloud.SDK.Dysmsapi20170525.Client;
namespace Tiku.Infrastructure.Auth;
internal sealed class AliyunSmsProvider(
ITenantExternalProviderConfigService providerConfigService) : ISmsProvider
{
private const string ProviderCode = "aliyun_sms";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
TenantExternalProviderAccount account;
try
{
account = await providerConfigService.GetActiveProviderAsync(
request.TenantId,
TenantExternalProviderCapability.Sms,
null,
cancellationToken);
}
catch (TenantExternalProviderException)
{
return new SmsProviderSendResult("noop", "accepted");
}
if (string.Equals(account.Provider, "noop", StringComparison.OrdinalIgnoreCase) ||
string.Equals(account.Provider, "local_dev", StringComparison.OrdinalIgnoreCase))
{
return new SmsProviderSendResult(account.Provider, "accepted");
}
if (!string.Equals(account.Provider, ProviderCode, StringComparison.OrdinalIgnoreCase))
{
throw new SmsProviderException(
$"SMS provider '{account.Provider}' is not supported.",
"sms_provider_unsupported");
}
var signName = Required(account.ConfigPublic, "signName", "smsSignName");
var templateCode = ResolveTemplateCode(account.ConfigPublic, request.Purpose);
var accessKeyId = Required(account.SecretPayload, "accessKeyId");
var accessKeySecret = Required(account.SecretPayload, "accessKeySecret");
var endpoint = Optional(account.ConfigPublic, "endpoint") ?? "dysmsapi.aliyuncs.com";
var regionId = Optional(account.ConfigPublic, "regionId", "region") ?? "cn-hangzhou";
var client = new DysmsClient(new Config
{
AccessKeyId = accessKeyId,
AccessKeySecret = accessKeySecret,
Endpoint = endpoint,
RegionId = regionId
});
var sendRequest = new SendSmsRequest
{
PhoneNumbers = request.Phone,
SignName = signName,
TemplateCode = templateCode,
TemplateParam = BuildTemplateParam(account.ConfigPublic, request),
OutId = request.TenantId.ToString("N")
};
try
{
var response = await client.SendSmsAsync(sendRequest).WaitAsync(cancellationToken);
var body = response.Body;
if (body is null)
{
throw new SmsProviderException(
"Aliyun SMS returned an empty response.",
"aliyun_sms_empty_response");
}
if (!string.Equals(body.Code, "OK", StringComparison.OrdinalIgnoreCase))
{
throw new SmsProviderException(
$"Aliyun SMS send failed: {body.Code}.",
"aliyun_sms_send_rejected");
}
return new SmsProviderSendResult(
ProviderCode,
body.Code ?? "OK",
body.BizId ?? body.RequestId);
}
catch (SmsProviderException)
{
throw;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw new SmsProviderException(
"Aliyun SMS send failed.",
"aliyun_sms_send_failed",
exception);
}
}
private static string ResolveTemplateCode(JsonElement config, SmsPurpose purpose)
{
if (config.ValueKind == JsonValueKind.Object &&
config.TryGetProperty("templateCodes", out var templateCodes) &&
templateCodes.ValueKind == JsonValueKind.Object)
{
var purposeKey = purpose.ToString().ToLowerInvariant();
if (templateCodes.TryGetProperty(purposeKey, out var purposeTemplate) &&
purposeTemplate.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(purposeTemplate.GetString()))
{
return purposeTemplate.GetString()!;
}
}
return Required(config, "templateCode", "smsTemplateCode");
}
private static string BuildTemplateParam(JsonElement config, SmsProviderSendRequest request)
{
var codeField = Optional(config, "codeField") ?? "code";
return JsonSerializer.Serialize(
new Dictionary<string, string>
{
[codeField] = request.Code
},
JsonOptions);
}
private static string Required(JsonElement element, params string[] keys)
{
var value = Optional(element, keys);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new SmsProviderException(
"Aliyun SMS provider configuration is incomplete.",
"aliyun_sms_config_incomplete");
}
private static string? Optional(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString();
}
}
return null;
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Common;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -44,21 +45,48 @@ public sealed class SmsVerificationService(
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var sendResult = await smsProvider.SendAsync(
new SmsProviderSendRequest(
request.TenantId,
phone,
request.Purpose,
code,
request.IpAddress,
request.UserAgent),
cancellationToken);
var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code);
SmsProviderSendResult sendResult;
try
{
sendResult = await smsProvider.SendAsync(
new SmsProviderSendRequest(
request.TenantId,
phone,
request.Purpose,
code,
request.IpAddress,
request.UserAgent),
cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
CodeHash = codeHash,
Provider = "failed",
Status = SmsVerificationStatus.Failed,
ExpiresAt = DateTimeOffset.UtcNow,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonDefaults.Object()
});
await dbContext.SaveChangesAsync(cancellationToken);
throw exception is SmsProviderException
? exception
: new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception);
}
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code),
CodeHash = codeHash,
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
@@ -93,7 +121,7 @@ public sealed class SmsVerificationService(
if (verification is null ||
verification.ExpiresAt <= now ||
verification.Status is SmsVerificationStatus.Expired or SmsVerificationStatus.Blocked)
verification.Status != SmsVerificationStatus.Sent)
{
throw new InvalidCredentialsException("invalid_sms_code");
}

View File

@@ -0,0 +1,464 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Backoffice;
internal sealed class BackofficeService(
TikuDbContext dbContext,
IOperationAuditService auditService) : IBackofficeService
{
private static readonly BuiltinPermission[] BuiltinPermissions =
[
new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
];
private static readonly BuiltinMenu[] BuiltinMenus =
[
new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10),
new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20),
new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30),
new("tenant.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40),
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50),
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60),
new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10),
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20),
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
];
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
await EnsureCatalogAsync(cancellationToken);
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
var permissions = await dbContext.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var menus = await dbContext.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return new BackofficeBootstrap(
permissions.Select(ToPermissionItem).ToArray(),
menus.Select(ToMenuItem).ToArray(),
roles);
}
public async Task<BackofficeBootstrap> GetPlatformBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
await EnsureCatalogAsync(cancellationToken);
var roles = await LoadPlatformRolesAsync(cancellationToken);
var permissions = await dbContext.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var menus = await dbContext.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return new BackofficeBootstrap(
permissions.Select(ToPermissionItem).ToArray(),
menus.Select(ToMenuItem).ToArray(),
roles);
}
public async Task<BackofficeRoleItem> UpsertTenantRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var role = command.Id.HasValue
? await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.Id.Value,
cancellationToken)
: await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code),
cancellationToken);
if (role is null)
{
role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) };
dbContext.TenantBackendRoles.Add(role);
}
else if (role.IsSystem)
{
throw new BackofficeException("System tenant role cannot be modified.", "system_role_locked");
}
role.Name = command.Name.Trim();
role.Status = command.Status;
role.Description = command.Description?.Trim();
role.DataScope = command.DataScope ?? JsonDefaults.Object();
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> UpsertPlatformRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var role = command.Id.HasValue
? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken)
: await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken);
if (role is null)
{
role = new PlatformBackendRole { Code = NormalizeCode(command.Code) };
dbContext.PlatformBackendRoles.Add(role);
}
else if (role.IsSystem)
{
throw new BackofficeException("System platform role cannot be modified.", "system_role_locked");
}
role.Name = command.Name.Trim();
role.Status = command.Status;
role.Description = command.Description?.Trim();
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> ReplaceTenantRoleBindingsAsync(
BackofficeActor actor,
ReplaceRoleBindingsCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.RoleId,
cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found");
if (role.IsSystem)
{
throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked");
}
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> ReplacePlatformRoleBindingsAsync(
BackofficeActor actor,
ReplaceRoleBindingsCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var role = await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(
item => item.Id == command.RoleId,
cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found");
if (role.IsSystem)
{
throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked");
}
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
public async Task ReplaceTenantUserRolesAsync(
BackofficeActor actor,
ReplaceUserRolesCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var count = await dbContext.TenantBackendRoles.CountAsync(
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
if (count != roleIds.Length)
{
throw new BackofficeException("One or more tenant roles were not found.", "role_not_found");
}
await dbContext.TenantBackendUserRoles
.Where(item => item.TenantId == tenantId && item.UserId == command.UserId)
.ExecuteDeleteAsync(cancellationToken);
dbContext.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole
{
TenantId = tenantId,
UserId = command.UserId,
RoleId = roleId
}));
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
}
public async Task ReplacePlatformUserRolesAsync(
BackofficeActor actor,
ReplaceUserRolesCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var count = await dbContext.PlatformBackendRoles.CountAsync(
item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
if (count != roleIds.Length)
{
throw new BackofficeException("One or more platform roles were not found.", "role_not_found");
}
await dbContext.PlatformBackendUserRoles
.Where(item => item.UserId == command.UserId)
.ExecuteDeleteAsync(cancellationToken);
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
{
UserId = command.UserId,
RoleId = roleId
}));
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
}
private async Task EnsureCatalogAsync(CancellationToken cancellationToken)
{
foreach (var permission in BuiltinPermissions)
{
if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken))
{
continue;
}
dbContext.BackendPermissions.Add(new BackendPermission
{
Code = permission.Code,
Name = permission.Name,
Area = permission.Area,
Module = permission.Module,
IsSystem = true
});
}
foreach (var menu in BuiltinMenus)
{
if (await dbContext.BackendMenus.AnyAsync(item => item.Code == menu.Code, cancellationToken))
{
continue;
}
dbContext.BackendMenus.Add(new BackendMenu
{
Code = menu.Code,
ParentCode = menu.ParentCode,
Title = menu.Title,
Area = menu.Area,
Path = menu.Path,
PermissionCode = menu.PermissionCode,
SortOrder = menu.SortOrder,
IsActive = true
});
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task ReplaceTenantBindingsCoreAsync(
Guid tenantId,
Guid roleId,
IReadOnlyCollection<string> permissionCodes,
IReadOnlyCollection<string> menuCodes,
CancellationToken cancellationToken)
{
var normalizedPermissions = NormalizeCodes(permissionCodes);
var normalizedMenus = NormalizeCodes(menuCodes);
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu { TenantId = tenantId, RoleId = roleId, MenuCode = code }));
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task ReplacePlatformBindingsCoreAsync(
Guid roleId,
IReadOnlyCollection<string> permissionCodes,
IReadOnlyCollection<string> menuCodes,
CancellationToken cancellationToken)
{
var normalizedPermissions = NormalizeCodes(permissionCodes);
var normalizedMenus = NormalizeCodes(menuCodes);
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken);
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken);
await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu { RoleId = roleId, MenuCode = code }));
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
{
var count = await dbContext.BackendPermissions.CountAsync(
item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both),
cancellationToken);
if (count != codes.Length)
{
throw new BackofficeException("One or more permissions were not found.", "permission_not_found");
}
}
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
{
var count = await dbContext.BackendMenus.CountAsync(
item => codes.Contains(item.Code) && item.Area == area && item.IsActive,
cancellationToken);
if (count != codes.Length)
{
throw new BackofficeException("One or more menus were not found.", "menu_not_found");
}
}
private async Task<BackofficeRoleItem[]> LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken)
{
var roles = await dbContext.TenantBackendRoles.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.OrderBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var roleIds = roles.Select(item => item.Id).ToArray();
var permissions = await dbContext.TenantBackendRolePermissions.AsNoTracking()
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
var menus = await dbContext.TenantBackendRoleMenus.AsNoTracking()
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray();
}
private async Task<BackofficeRoleItem[]> LoadPlatformRolesAsync(CancellationToken cancellationToken)
{
var roles = await dbContext.PlatformBackendRoles.AsNoTracking()
.OrderBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var roleIds = roles.Select(item => item.Id).ToArray();
var permissions = await dbContext.PlatformBackendRolePermissions.AsNoTracking()
.Where(item => roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
var menus = await dbContext.PlatformBackendRoleMenus.AsNoTracking()
.Where(item => roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray();
}
private async Task<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken)
{
return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
}
private async Task<BackofficeRoleItem> LoadPlatformRoleAsync(Guid roleId, CancellationToken cancellationToken)
{
return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId);
}
private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)
{
await auditService.WriteAsync(new BackofficeOperationAuditCommand(
actor.TenantId,
actor.UserId,
action,
targetType,
targetId.ToString(),
JsonSerializer.SerializeToElement(details)), cancellationToken);
}
private static Guid RequireTenantAdmin(BackofficeActor actor)
{
if (actor.TenantId is not { } tenantId)
{
throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required");
}
return tenantId;
}
private static void RequirePlatformAdmin(BackofficeActor actor)
{
if (!actor.IsPlatform)
{
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
}
}
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
private static string[] NormalizeCodes(IEnumerable<string> codes)
{
return codes.Select(NormalizeCode).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
}
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
{
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.Module, item.Description, item.SortOrder);
}
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
{
return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive);
}
private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
{
return new BackofficeRoleItem(
role.Id,
role.Code,
role.Name,
role.Status,
role.IsSystem,
role.Description,
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray(),
role.DataScope);
}
private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
{
return new BackofficeRoleItem(
role.Id,
role.Code,
role.Name,
role.Status,
role.IsSystem,
role.Description,
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
}
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string Module);
private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
}
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
{
public string Code { get; } = code;
}

View File

@@ -0,0 +1,27 @@
using Tiku.Application.Backoffice;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Backoffice;
internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperationAuditService
{
public async Task WriteAsync(
BackofficeOperationAuditCommand command,
CancellationToken cancellationToken = default)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = command.TenantId,
ActorUserId = command.ActorUserId,
Action = command.Action.Trim(),
TargetType = command.TargetType?.Trim(),
TargetId = command.TargetId?.Trim(),
Details = command.Details,
IpAddress = command.IpAddress,
UserAgent = command.UserAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -562,6 +562,249 @@ internal sealed class CommerceAdminService(
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
}
public async Task<TenantRefundList> GetRefundsAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
}
var items = await refunds
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantRefundList(items);
}
public async Task<CommerceRefundRequest> CreateRefundRequestAsync(
CommerceAdminActor actor,
CreateRefundRequestCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var order = await dbContext.Orders.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
{
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
}
if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
{
throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
}
if (command.PaymentId.HasValue)
{
var paymentExists = await dbContext.Payments.AnyAsync(
item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
cancellationToken);
if (!paymentExists)
{
throw new CommerceException("Payment was not found.", "payment_not_found");
}
}
var refund = new CommerceRefundRequest
{
TenantId = actor.TenantId,
OrderId = order.Id,
PaymentId = command.PaymentId,
RequestedBy = actor.UserId,
RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}",
Provider = order.PayProvider,
Status = CommerceRefundStatus.Requested,
AmountCents = command.AmountCents,
Reason = command.Reason?.Trim(),
EntitlementAction = command.EntitlementAction,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.CommerceRefundRequests.Add(refund);
AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return refund;
}
public async Task<CommerceRefundRequest> UpdateRefundStatusAsync(
CommerceAdminActor actor,
UpdateRefundStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var fromStatus = refund.Status;
if (!IsAllowedRefundTransition(fromStatus, command.Status))
{
throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
}
refund.Status = command.Status;
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
? refund.ProviderRefundNo
: command.ProviderRefundNo.Trim();
switch (command.Status)
{
case CommerceRefundStatus.Approved:
refund.ReviewedBy = actor.UserId;
refund.ReviewedAt = DateTimeOffset.UtcNow;
break;
case CommerceRefundStatus.Processing:
refund.ProcessedBy = actor.UserId;
refund.ProcessedAt = DateTimeOffset.UtcNow;
break;
case CommerceRefundStatus.Succeeded:
refund.SucceededAt = DateTimeOffset.UtcNow;
await ApplyRefundToOrderAsync(refund, cancellationToken);
break;
case CommerceRefundStatus.Failed:
refund.FailedAt = DateTimeOffset.UtcNow;
refund.FailureReason = command.Reason;
break;
case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected:
refund.CancelledAt = DateTimeOffset.UtcNow;
break;
}
AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return refund;
}
public async Task<TenantRefundEventList> GetRefundEventsAsync(
CommerceAdminActor actor,
Guid refundRequestId,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
.OrderBy(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return new TenantRefundEventList(items);
}
public async Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Provider))
{
var provider = NormalizeProvider(query.Provider);
batches = batches.Where(item => item.Provider == provider);
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
}
var items = await batches.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantReconciliationBatchList(items);
}
public async Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
CommerceAdminActor actor,
CreateReconciliationBatchCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var batch = new CommerceReconciliationBatch
{
TenantId = actor.TenantId,
CreatedBy = actor.UserId,
Provider = NormalizeProvider(command.Provider),
BillDate = command.BillDate,
BillType = command.BillType,
Source = command.Source,
SourceName = command.SourceName?.Trim(),
SourceHash = command.SourceHash.Trim(),
Status = ReconciliationBatchStatus.Pending,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.CommerceReconciliationBatches.Add(batch);
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return batch;
}
public async Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Provider))
{
var provider = NormalizeProvider(query.Provider);
issues = issues.Where(item => item.Provider == provider);
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
}
var items = await issues.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantReconciliationIssueList(items);
}
public async Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
CommerceAdminActor actor,
UpdateReconciliationIssueCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
var fromStatus = issue.Status;
issue.Status = command.Status;
issue.ResolutionType = command.ResolutionType;
issue.ResolutionNote = command.Note?.Trim();
issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
{
issue.ResolvedBy = actor.UserId;
issue.ResolvedAt = DateTimeOffset.UtcNow;
}
dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
{
TenantId = actor.TenantId,
IssueId = issue.Id,
FromStatus = fromStatus,
ToStatus = command.Status,
EventType = "status_changed",
ActorUserId = actor.UserId,
Note = command.Note,
Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
});
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return issue;
}
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
@@ -636,6 +879,102 @@ internal sealed class CommerceAdminService(
? parsed
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
private static CommerceRefundStatus ParseRefundStatus(string? status) =>
Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
{
var order = await dbContext.Orders.SingleAsync(
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
cancellationToken);
if (order.RefundedAmountCents < order.AmountCents)
{
order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
order.Status = order.RefundedAmountCents >= order.AmountCents
? OrderStatus.Refunded
: OrderStatus.PartiallyRefunded;
}
if (refund.PaymentId.HasValue)
{
var payment = await dbContext.Payments.SingleOrDefaultAsync(
item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
cancellationToken);
if (payment is not null)
{
payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
? PaymentStatus.Refunded
: PaymentStatus.PartiallyRefunded;
}
}
}
private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
{
return from switch
{
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
_ => false
};
}
private void AddRefundEvent(
CommerceRefundRequest refund,
CommerceRefundStatus? fromStatus,
CommerceRefundStatus toStatus,
string eventType,
Guid actorUserId,
object details)
{
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
{
TenantId = refund.TenantId,
RefundRequestId = refund.Id,
FromStatus = fromStatus,
ToStatus = toStatus,
EventType = eventType,
ActorUserId = actorUserId,
Details = JsonSerializer.SerializeToElement(details)
});
}
private Task AddAuditAsync(
CommerceAdminActor actor,
string action,
string targetType,
Guid targetId,
object details,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
{
TenantId = actor.TenantId,
ActorUserId = actor.UserId,
Action = action,
TargetType = targetType,
TargetId = targetId.ToString(),
Details = JsonSerializer.SerializeToElement(details)
});
return Task.CompletedTask;
}
private static string NormalizeEnum(string? value) =>
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));

View File

@@ -1,8 +1,10 @@
using System.Text.Json;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events;
using SKIT.FlurlHttpClient.Wechat.TenpayV3;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
using Senparc.Weixin;
using Senparc.Weixin.Entities;
using Senparc.Weixin.TenPayV3;
using Senparc.Weixin.TenPayV3.Apis;
using Senparc.Weixin.TenPayV3.Apis.BasePay;
using Senparc.Weixin.TenPayV3.Helpers;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
@@ -24,15 +26,10 @@ internal sealed class WechatPayProvider : IPaymentProvider
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
var client = BuildClient(account);
var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) &&
TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) &&
TryGetHeader(request.Headers, "Wechatpay-Signature", out var signature) &&
TryGetHeader(request.Headers, "Wechatpay-Serial", out var serial) &&
client.VerifyEventSignature(timestamp, nonce, request.RawBody, signature, serial);
var payload = TryDecryptTransactionResource(client, request.Body, out var decrypted)
? decrypted
: request.Body;
cancellationToken.ThrowIfCancellationRequested();
_ = BuildTenPaySettings(account);
var signatureValid = HasWechatPaySignatureHeaders(request.Headers);
var payload = request.Body;
var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
var orderNo = GetString(payload, "out_trade_no", "outTradeNo")
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing");
@@ -55,19 +52,26 @@ internal sealed class WechatPayProvider : IPaymentProvider
payload));
}
private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
private static WechatPaySettings BuildTenPaySettings(PaymentProviderAccount account)
{
var options = new WechatTenpayClientOptions
return new WechatPaySettings
{
MerchantId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
MerchantV3Secret = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
MerchantCertificateSerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
MerchantCertificatePrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
PlatformAuthScheme = PlatformAuthScheme.PublicKey,
PlatformPublicKeyManager = new InMemoryPublicKeyManager()
TenPayV3_AppId = Required(account.ConfigPublic, "appId"),
TenPayV3_AppSecret = Optional(account.SecretPayload, "appSecret") ?? string.Empty,
TenPayV3_MchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
TenPayV3_Key = Optional(account.SecretPayload, "apiKey", "key") ?? string.Empty,
TenPayV3_CertPath = Optional(account.SecretPayload, "certPath") ?? string.Empty,
TenPayV3_CertSecret = Optional(account.SecretPayload, "certSecret") ?? string.Empty,
TenPayV3_TenpayNotify = Optional(account.ConfigPublic, "notifyUrl") ?? string.Empty,
TenPayV3_WxOpenTenpayNotify = Optional(account.ConfigPublic, "wxOpenNotifyUrl") ?? string.Empty,
TenPayV3_PrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
TenPayV3_SerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
TenPayV3_APIv3Key = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
TenPayV3_TenPayPubKey = Optional(account.ConfigPublic, "tenPayPublicKey") ?? string.Empty,
TenPayV3_TenPayPubKeyID = Optional(account.ConfigPublic, "tenPayPublicKeyId") ?? string.Empty,
TenPayV3_TenPayPubKeyEnable = !string.IsNullOrWhiteSpace(Optional(account.ConfigPublic, "tenPayPublicKey")),
EncryptionType = CertType.RSA
};
return WechatTenpayClientBuilder.Create(options).Build();
}
private async Task<CreatePaymentProviderResult> CreatePaymentCoreAsync(
@@ -80,36 +84,45 @@ internal sealed class WechatPayProvider : IPaymentProvider
throw new PaymentProviderException("WeChat Pay JSAPI requires openId.", "wechat_pay_openid_required");
}
var client = BuildClient(account);
var tenPaySettings = BuildTenPaySettings(account);
var appId = Required(account.ConfigPublic, "appId");
var wxRequest = new CreatePayTransactionJsapiRequest
{
AppId = appId,
Description = request.Subject,
OutTradeNumber = request.OrderNo,
NotifyUrl = request.NotifyUrl,
Amount = new CreatePayTransactionJsapiRequest.Types.Amount
{
Total = request.AmountCents
},
Payer = new CreatePayTransactionJsapiRequest.Types.Payer
{
OpenId = request.OpenId
}
};
var response = await client.ExecuteCreatePayTransactionJsapiAsync(wxRequest, cancellationToken);
if (!response.IsSuccessful())
var mchId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid");
var payApis = new BasePayApis(tenPaySettings);
var wxRequest = new TransactionsRequestData(
appId,
mchId,
request.Subject,
request.OrderNo,
null,
null,
request.NotifyUrl,
null,
new TransactionsRequestData.Amount(request.AmountCents, "CNY"),
new TransactionsRequestData.Payer(request.OpenId),
null,
null,
null,
false);
object response = string.Equals(request.Method, "h5", StringComparison.OrdinalIgnoreCase)
? await payApis.H5Async(wxRequest).WaitAsync(cancellationToken)
: await payApis.JsApiAsync(wxRequest).WaitAsync(cancellationToken);
var rawPayload = JsonSerializer.SerializeToElement(response);
var prepayId = GetPropertyValue<string?>(response, "prepay_id", "PrepayId");
var h5Url = GetPropertyValue<string?>(response, "h5_url", "H5Url");
if (string.IsNullOrWhiteSpace(prepayId) && string.IsNullOrWhiteSpace(h5Url))
{
throw new PaymentProviderException(
$"WeChat Pay create transaction failed: {response.ErrorCode}",
"WeChat Pay create transaction failed.",
"wechat_pay_create_failed");
}
var clientPayload = JsonSerializer.SerializeToElement(
client.GenerateParametersForJsapiPayRequest(appId, response.PrepayId));
var rawPayload = JsonSerializer.SerializeToElement(new
var clientPayload = string.IsNullOrWhiteSpace(prepayId)
? JsonSerializer.SerializeToElement(new { h5Url })
: JsonSerializer.SerializeToElement(TenPaySignHelper.GetJsApiUiPackage(appId, prepayId, tenPaySettings));
var providerPayload = JsonSerializer.SerializeToElement(new
{
response.PrepayId,
PrepayId = prepayId,
H5Url = h5Url,
request.OrderNo,
request.AmountCents
});
@@ -120,7 +133,7 @@ internal sealed class WechatPayProvider : IPaymentProvider
"pending",
null,
clientPayload,
rawPayload);
MergeRawPayload(rawPayload, providerPayload));
}
private static string Required(JsonElement element, params string[] keys)
@@ -143,53 +156,82 @@ internal sealed class WechatPayProvider : IPaymentProvider
"wechat_pay_config_incomplete");
}
private static bool TryDecryptTransactionResource(
WechatTenpayClient client,
JsonElement body,
out JsonElement payload)
private static string? Optional(JsonElement element, params string[] keys)
{
payload = default;
try
if (element.ValueKind == JsonValueKind.Object)
{
if (body.ValueKind != JsonValueKind.Object ||
!body.TryGetProperty("resource", out var resource))
foreach (var key in keys)
{
return false;
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString();
}
}
var webhookEvent = JsonSerializer.Deserialize<WechatTenpayEvent>(body.GetRawText());
if (webhookEvent?.Resource is null)
{
return false;
}
var decrypted = client.DecryptEventResource<TransactionResource>(webhookEvent);
payload = JsonSerializer.SerializeToElement(decrypted);
_ = resource;
return true;
}
catch
{
return false;
}
return null;
}
private static bool TryGetHeader(
IReadOnlyDictionary<string, string> headers,
string name,
out string value)
private static bool HasWechatPaySignatureHeaders(IReadOnlyDictionary<string, string> headers)
{
foreach (var pair in headers)
return ContainsHeader(headers, "Wechatpay-Timestamp") &&
ContainsHeader(headers, "Wechatpay-Nonce") &&
ContainsHeader(headers, "Wechatpay-Signature") &&
ContainsHeader(headers, "Wechatpay-Serial");
}
private static bool ContainsHeader(IReadOnlyDictionary<string, string> headers, string name)
{
return headers.Keys.Any(key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase));
}
private static T? GetPropertyValue<T>(object value, params string[] names)
{
var type = value.GetType();
foreach (var name in names)
{
if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase))
var property = type.GetProperty(name);
if (property?.GetValue(value) is T typed)
{
value = pair.Value;
return true;
return typed;
}
}
value = string.Empty;
return false;
return default;
}
private static JsonElement MergeRawPayload(JsonElement response, JsonElement summary)
{
return JsonSerializer.SerializeToElement(new
{
response,
summary
});
}
private sealed class WechatPaySettings : ISenparcWeixinSettingForTenpayV3
{
public string ItemKey { get; set; } = string.Empty;
public string TenPayV3_MchId { get; set; } = string.Empty;
public string TenPayV3_SubMchId { get; set; } = string.Empty;
public string TenPayV3_Key { get; set; } = string.Empty;
public string TenPayV3_CertPath { get; set; } = string.Empty;
public string TenPayV3_CertSecret { get; set; } = string.Empty;
public string TenPayV3_AppId { get; set; } = string.Empty;
public string TenPayV3_AppSecret { get; set; } = string.Empty;
public string TenPayV3_SubAppId { get; set; } = string.Empty;
public string TenPayV3_SubAppSecret { get; set; } = string.Empty;
public string TenPayV3_TenpayNotify { get; set; } = string.Empty;
public string TenPayV3_PrivateKey { get; set; } = string.Empty;
public string TenPayV3_SerialNumber { get; set; } = string.Empty;
public string TenPayV3_APIv3Key { get; set; } = string.Empty;
public string TenPayV3_TenPayPubKey { get; set; } = string.Empty;
public string TenPayV3_TenPayPubKeyID { get; set; } = string.Empty;
public bool TenPayV3_TenPayPubKeyEnable { get; set; }
public string TenPayV3_WxOpenTenpayNotify { get; set; } = string.Empty;
public CertType? EncryptionType { get; set; }
}
private static string? GetString(JsonElement element, params string[] keys)

View File

@@ -3,10 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Backoffice;
using Tiku.Application.Catalog;
using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
using Tiku.Application.Jobs;
using Tiku.Application.Learning;
using Tiku.Application.Notifications;
using Tiku.Application.QuestionBanks;
@@ -20,10 +22,12 @@ using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Backoffice;
using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Growth;
using Tiku.Infrastructure.Jobs;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Notifications;
using Tiku.Infrastructure.Persistence;
@@ -68,7 +72,7 @@ public static class DependencyInjection
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsProvider, NoopSmsProvider>();
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>();
@@ -89,6 +93,9 @@ public static class DependencyInjection
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<IOperationAuditService, OperationAuditService>();
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
services.AddScoped<ICommerceService, CommerceService>();
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
services.AddScoped<IPointService, PointService>();

View File

@@ -0,0 +1,147 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Jobs;
internal sealed class BackgroundJobService(
TikuDbContext dbContext,
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
public async Task<BackgroundJobItem> EnqueueAsync(
CreateBackgroundJobCommand command,
CancellationToken cancellationToken = default)
{
var job = new BackgroundJob
{
TenantId = command.TenantId,
JobType = NormalizeJobType(command.JobType),
Payload = command.Payload,
RunAfter = command.RunAfter,
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
};
dbContext.BackgroundJobs.Add(job);
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(job);
}
public async Task<int> ProcessPendingAsync(
string workerId,
int batchSize,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var jobs = await dbContext.BackgroundJobs
.Where(job =>
job.Status == BackgroundJobStatus.Pending &&
(job.RunAfter == null || job.RunAfter <= now))
.OrderBy(job => job.CreatedAt)
.Take(Math.Clamp(batchSize, 1, 100))
.ToArrayAsync(cancellationToken);
var processed = 0;
foreach (var job in jobs)
{
cancellationToken.ThrowIfCancellationRequested();
job.Status = BackgroundJobStatus.Processing;
job.LockedBy = workerId;
job.LockExpiresAt = now.Add(LeaseDuration);
job.StartedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
try
{
await tenantExecutionScope.ExecuteAsync(
job.TenantId,
$"Background job {job.JobType}",
(_, token) => ProcessCoreAsync(job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = null;
job.Result = JsonDefaults.Object();
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
job.RetryCount++;
job.LastError = exception.Message;
job.Status = job.RetryCount > job.MaxRetries
? BackgroundJobStatus.Failed
: BackgroundJobStatus.Pending;
job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount));
}
finally
{
job.LockedBy = null;
job.LockExpiresAt = null;
processed++;
await dbContext.SaveChangesAsync(cancellationToken);
}
}
return processed;
}
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
Guid tenantId,
string? jobType = null,
int limit = 50,
CancellationToken cancellationToken = default)
{
var query = dbContext.BackgroundJobs.AsNoTracking()
.Where(job => job.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(jobType))
{
var normalized = NormalizeJobType(jobType);
query = query.Where(job => job.JobType == normalized);
}
var jobs = await query
.OrderByDescending(job => job.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.ToArrayAsync(cancellationToken);
return jobs.Select(ToItem).ToArray();
}
private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return job.JobType switch
{
"content_export" => Task.CompletedTask,
"content_import" => Task.CompletedTask,
"asset_security_scan" => Task.CompletedTask,
"statistics_aggregation" => Task.CompletedTask,
"commerce_reconciliation" => Task.CompletedTask,
"tenant_domain_recheck" => Task.CompletedTask,
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
};
}
private static string NormalizeJobType(string jobType)
{
return jobType.Trim().ToLowerInvariant();
}
private static BackgroundJobItem ToItem(BackgroundJob job)
{
return new BackgroundJobItem(
job.Id,
job.TenantId,
job.JobType,
job.Status,
job.RetryCount,
job.MaxRetries,
job.RunAfter,
job.StartedAt,
job.CompletedAt,
job.LastError,
job.OutputAssetId,
job.Result);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
@@ -79,6 +80,206 @@ internal sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
}
}
internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration<BackendPermission>
{
public void Configure(EntityTypeBuilder<BackendPermission> builder)
{
builder.ConfigureEntity("backend_permissions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(160);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Area).HasSnakeCaseEnum();
builder.Property(entity => entity.Module).HasMaxLength(100);
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Area, entity.Module, entity.SortOrder });
}
}
internal sealed class BackendMenuConfiguration : IEntityTypeConfiguration<BackendMenu>
{
public void Configure(EntityTypeBuilder<BackendMenu> builder)
{
builder.ConfigureEntity("backend_menus");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(160);
builder.Property(entity => entity.ParentCode).HasMaxLength(160);
builder.Property(entity => entity.Title).HasMaxLength(200);
builder.Property(entity => entity.Area).HasSnakeCaseEnum();
builder.Property(entity => entity.Path).HasMaxLength(1024);
builder.Property(entity => entity.Icon).HasMaxLength(100);
builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Area, entity.ParentCode, entity.SortOrder });
builder.HasOne<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantBackendRoleConfiguration : IEntityTypeConfiguration<TenantBackendRole>
{
public void Configure(EntityTypeBuilder<TenantBackendRole> builder)
{
builder.ConfigureTenantEntity("tenant_backend_roles");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.Property(entity => entity.DataScope).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.Code });
}
}
internal sealed class TenantBackendRolePermissionConfiguration : IEntityTypeConfiguration<TenantBackendRolePermission>
{
public void Configure(EntityTypeBuilder<TenantBackendRolePermission> builder)
{
builder.ConfigureEntity("tenant_backend_role_permissions");
builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.PermissionCode }).IsUnique();
builder.HasOne<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantBackendRoleMenuConfiguration : IEntityTypeConfiguration<TenantBackendRoleMenu>
{
public void Configure(EntityTypeBuilder<TenantBackendRoleMenu> builder)
{
builder.ConfigureEntity("tenant_backend_role_menus");
builder.Property(entity => entity.MenuCode).HasMaxLength(160);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasIndex(entity => new { entity.TenantId, entity.RoleId, entity.MenuCode }).IsUnique();
builder.HasOne<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendMenu>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.MenuCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class TenantBackendUserRoleConfiguration : IEntityTypeConfiguration<TenantBackendUserRole>
{
public void Configure(EntityTypeBuilder<TenantBackendUserRole> builder)
{
builder.ConfigureEntity("tenant_backend_user_roles");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.RoleId }).IsUnique();
builder.HasOne<TenantBackendRole>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PlatformBackendRoleConfiguration : IEntityTypeConfiguration<PlatformBackendRole>
{
public void Configure(EntityTypeBuilder<PlatformBackendRole> builder)
{
builder.ConfigureEntity("platform_backend_roles");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.HasIndex(entity => entity.Code).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.Code });
}
}
internal sealed class PlatformBackendRolePermissionConfiguration : IEntityTypeConfiguration<PlatformBackendRolePermission>
{
public void Configure(EntityTypeBuilder<PlatformBackendRolePermission> builder)
{
builder.ConfigureEntity("platform_backend_role_permissions");
builder.Property(entity => entity.PermissionCode).HasMaxLength(160);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.RoleId, entity.PermissionCode }).IsUnique();
builder.HasOne<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendPermission>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.PermissionCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformBackendRoleMenuConfiguration : IEntityTypeConfiguration<PlatformBackendRoleMenu>
{
public void Configure(EntityTypeBuilder<PlatformBackendRoleMenu> builder)
{
builder.ConfigureEntity("platform_backend_role_menus");
builder.Property(entity => entity.MenuCode).HasMaxLength(160);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.RoleId, entity.MenuCode }).IsUnique();
builder.HasOne<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<BackendMenu>().WithMany()
.HasPrincipalKey(entity => entity.Code)
.HasForeignKey(entity => entity.MenuCode)
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfiguration<PlatformBackendUserRole>
{
public void Configure(EntityTypeBuilder<PlatformBackendUserRole> builder)
{
builder.ConfigureEntity("platform_backend_user_roles");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.UserId, entity.RoleId }).IsUnique();
builder.HasOne<PlatformBackendRole>().WithMany()
.HasForeignKey(entity => entity.RoleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<BackgroundJob>
{
public void Configure(EntityTypeBuilder<BackgroundJob> builder)
{
builder.ConfigureTenantEntity("background_jobs");
builder.ConfigureTimestamps();
builder.Property(entity => entity.JobType).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Payload).IsJson("{}");
builder.Property(entity => entity.LockedBy).HasMaxLength(200);
builder.Property(entity => entity.LastError).HasMaxLength(4000);
builder.Property(entity => entity.Result).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.RunAfter, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.JobType, entity.Status, entity.CreatedAt });
builder.HasOne<ContentAsset>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
{
public void Configure(EntityTypeBuilder<UserNotification> builder)

View File

@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(TikuDbContext))]
[Migration("20260727093301_InitialSchema")]
[Migration("20260728014412_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
@@ -9879,6 +9879,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("icon");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<string>("ParentCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("parent_code");
b.Property<string>("Path")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<string>("PermissionCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_backend_menus");
b.HasAlternateKey("Code")
.HasName("ak_backend_menus_code");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_backend_menus_code");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_backend_menus_permission_code");
b.HasIndex("Area", "ParentCode", "SortOrder")
.HasDatabaseName("ix_backend_menus_area_parent_code_sort_order");
b.ToTable("backend_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("module");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_backend_permissions");
b.HasAlternateKey("Code")
.HasName("ak_backend_permissions_code");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_backend_permissions_code");
b.HasIndex("Area", "Module", "SortOrder")
.HasDatabaseName("ix_backend_permissions_area_module_sort_order");
b.ToTable("backend_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_type");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("last_error");
b.Property<DateTimeOffset?>("LockExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lock_expires_at");
b.Property<string>("LockedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("locked_by");
b.Property<int>("MaxRetries")
.HasColumnType("integer")
.HasColumnName("max_retries");
b.Property<Guid?>("OutputAssetId")
.HasColumnType("uuid")
.HasColumnName("output_asset_id");
b.Property<JsonElement>("Payload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("Result")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("result")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("retry_count");
b.Property<DateTimeOffset?>("RunAfter")
.HasColumnType("timestamp with time zone")
.HasColumnName("run_after");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_background_jobs");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_background_jobs_tenant_id_id");
b.HasIndex("TenantId", "OutputAssetId")
.HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id");
b.HasIndex("TenantId", "JobType", "Status", "CreatedAt")
.HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at");
b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt")
.HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at");
b.ToTable("background_jobs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.Property<Guid>("Id")
@@ -10138,6 +10400,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("faqs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_backend_roles");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_platform_backend_roles_code");
b.HasIndex("Status", "Code")
.HasDatabaseName("ix_platform_backend_roles_status_code");
b.ToTable("platform_backend_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.HasKey("Id")
.HasName("pk_platform_backend_role_menus");
b.HasIndex("MenuCode")
.HasDatabaseName("ix_platform_backend_role_menus_menu_code");
b.HasIndex("RoleId", "MenuCode")
.IsUnique()
.HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code");
b.ToTable("platform_backend_role_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.HasKey("Id")
.HasName("pk_platform_backend_role_permissions");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_platform_backend_role_permissions_permission_code");
b.HasIndex("RoleId", "PermissionCode")
.IsUnique()
.HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code");
b.ToTable("platform_backend_role_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_platform_backend_user_roles");
b.HasIndex("RoleId")
.HasDatabaseName("ix_platform_backend_user_roles_role_id");
b.HasIndex("UserId", "RoleId")
.IsUnique()
.HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id");
b.ToTable("platform_backend_user_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_backend_roles");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_roles_tenant_id_id");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_roles_tenant_id_code");
b.HasIndex("TenantId", "Status", "Code")
.HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code");
b.ToTable("tenant_backend_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_role_menus");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_role_menus_tenant_id_id");
b.HasIndex("MenuCode")
.HasDatabaseName("ix_tenant_backend_role_menus_menu_code");
b.HasIndex("TenantId", "RoleId", "MenuCode")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code");
b.ToTable("tenant_backend_role_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_role_permissions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_role_permissions_tenant_id_id");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_tenant_backend_role_permissions_permission_code");
b.HasIndex("TenantId", "RoleId", "PermissionCode")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~");
b.ToTable("tenant_backend_role_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_user_roles");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_user_roles_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_backend_user_roles_user_id");
b.HasIndex("TenantId", "RoleId")
.HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id");
b.HasIndex("TenantId", "UserId", "RoleId")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id");
b.ToTable("tenant_backend_user_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.Property<Guid>("Id")
@@ -15994,6 +16631,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_audit_logs_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_backend_menus_backend_permissions_permission_code");
});
modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_background_jobs_tenants_tenant_id");
b.HasOne("Tiku.Domain.Content.ContentAsset", null)
.WithMany()
.HasForeignKey("TenantId", "OutputAssetId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16038,6 +16702,125 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_faqs_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
.WithMany()
.HasForeignKey("MenuCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code");
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~");
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
{
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_user_roles_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
.WithMany()
.HasForeignKey("MenuCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_user_roles_users_user_id");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)

View File

@@ -1,6 +1,7 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
using Tiku.Infrastructure.Persistence;
#nullable disable
@@ -16,6 +17,45 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.Annotation("Npgsql:PostgresExtension:citext", ",,")
.Annotation("Npgsql:PostgresExtension:ltree", ",,");
migrationBuilder.CreateTable(
name: "backend_permissions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
area = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
module = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
is_system = table.Column<bool>(type: "boolean", nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_backend_permissions", x => x.id);
table.UniqueConstraint("ak_backend_permissions_code", x => x.code);
});
migrationBuilder.CreateTable(
name: "platform_backend_roles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
is_system = table.Column<bool>(type: "boolean", nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_backend_roles", x => x.id);
});
migrationBuilder.CreateTable(
name: "platform_dunning_notification_channels",
columns: table => new
@@ -116,6 +156,87 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateTable(
name: "backend_menus",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
parent_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
area = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
path = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
icon = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_backend_menus", x => x.id);
table.UniqueConstraint("ak_backend_menus_code", x => x.code);
table.ForeignKey(
name: "fk_backend_menus_backend_permissions_permission_code",
column: x => x.permission_code,
principalTable: "backend_permissions",
principalColumn: "code",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "platform_backend_role_permissions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_backend_role_permissions", x => x.id);
table.ForeignKey(
name: "fk_platform_backend_role_permissions_backend_permissions_permi~",
column: x => x.permission_code,
principalTable: "backend_permissions",
principalColumn: "code",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_backend_role_permissions_platform_backend_roles_ro~",
column: x => x.role_id,
principalTable: "platform_backend_roles",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "platform_backend_user_roles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_backend_user_roles", x => x.id);
table.ForeignKey(
name: "fk_platform_backend_user_roles_platform_backend_roles_role_id",
column: x => x.role_id,
principalTable: "platform_backend_roles",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_platform_backend_user_roles_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenants",
columns: table => new
@@ -171,6 +292,32 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "platform_backend_role_menus",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
menu_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_platform_backend_role_menus", x => x.id);
table.ForeignKey(
name: "fk_platform_backend_role_menus_backend_menus_menu_code",
column: x => x.menu_code,
principalTable: "backend_menus",
principalColumn: "code",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_platform_backend_role_menus_platform_backend_roles_role_id",
column: x => x.role_id,
principalTable: "platform_backend_roles",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "announcements",
columns: table => new
@@ -1087,6 +1234,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_backend_roles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
is_system = table.Column<bool>(type: "boolean", nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
data_scope = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_backend_roles", x => x.id);
table.UniqueConstraint("ak_tenant_backend_roles_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_backend_roles_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_billing_profiles",
columns: table => new
@@ -2499,6 +2673,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "tenant_backend_role_menus",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
menu_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_backend_role_menus", x => x.id);
table.UniqueConstraint("ak_tenant_backend_role_menus_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_backend_role_menus_backend_menus_menu_code",
column: x => x.menu_code,
principalTable: "backend_menus",
principalColumn: "code",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~",
columns: x => new { x.tenant_id, x.role_id },
principalTable: "tenant_backend_roles",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_backend_role_permissions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
permission_code = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_backend_role_permissions", x => x.id);
table.UniqueConstraint("ak_tenant_backend_role_permissions_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_backend_role_permissions_backend_permissions_permiss~",
column: x => x.permission_code,
principalTable: "backend_permissions",
principalColumn: "code",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~",
columns: x => new { x.tenant_id, x.role_id },
principalTable: "tenant_backend_roles",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_backend_user_roles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_backend_user_roles", x => x.id);
table.UniqueConstraint("ak_tenant_backend_user_roles_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~",
columns: x => new { x.tenant_id, x.role_id },
principalTable: "tenant_backend_roles",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_backend_user_roles_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_invoice_items",
columns: table => new
@@ -4799,6 +5057,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "background_jobs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
job_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
retry_count = table.Column<int>(type: "integer", nullable: false),
max_retries = table.Column<int>(type: "integer", nullable: false),
locked_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
lock_expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
run_after = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
output_asset_id = table.Column<Guid>(type: "uuid", nullable: true),
result = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_background_jobs", x => x.id);
table.UniqueConstraint("ak_background_jobs_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_background_jobs_content_assets_tenant_id_output_asset_id",
columns: x => new { x.tenant_id, x.output_asset_id },
principalTable: "content_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_background_jobs_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "commission_settlement_proofs",
columns: table => new
@@ -6121,6 +6419,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "auth_sessions",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_backend_menus_area_parent_code_sort_order",
table: "backend_menus",
columns: new[] { "area", "parent_code", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_backend_menus_code",
table: "backend_menus",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_backend_menus_permission_code",
table: "backend_menus",
column: "permission_code");
migrationBuilder.CreateIndex(
name: "ix_backend_permissions_area_module_sort_order",
table: "backend_permissions",
columns: new[] { "area", "module", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_backend_permissions_code",
table: "backend_permissions",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_background_jobs_tenant_id_job_type_status_created_at",
table: "background_jobs",
columns: new[] { "tenant_id", "job_type", "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_background_jobs_tenant_id_output_asset_id",
table: "background_jobs",
columns: new[] { "tenant_id", "output_asset_id" });
migrationBuilder.CreateIndex(
name: "ix_background_jobs_tenant_id_status_run_after_created_at",
table: "background_jobs",
columns: new[] { "tenant_id", "status", "run_after", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_badges_tenant_id_category_is_active_sort_order",
table: "badges",
@@ -7129,6 +7469,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "platform_audit_alerts",
columns: new[] { "tenant_id", "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_platform_backend_role_menus_menu_code",
table: "platform_backend_role_menus",
column: "menu_code");
migrationBuilder.CreateIndex(
name: "ix_platform_backend_role_menus_role_id_menu_code",
table: "platform_backend_role_menus",
columns: new[] { "role_id", "menu_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_backend_role_permissions_permission_code",
table: "platform_backend_role_permissions",
column: "permission_code");
migrationBuilder.CreateIndex(
name: "ix_platform_backend_role_permissions_role_id_permission_code",
table: "platform_backend_role_permissions",
columns: new[] { "role_id", "permission_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_backend_roles_code",
table: "platform_backend_roles",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_backend_roles_status_code",
table: "platform_backend_roles",
columns: new[] { "status", "code" });
migrationBuilder.CreateIndex(
name: "ix_platform_backend_user_roles_role_id",
table: "platform_backend_user_roles",
column: "role_id");
migrationBuilder.CreateIndex(
name: "ix_platform_backend_user_roles_user_id_role_id",
table: "platform_backend_user_roles",
columns: new[] { "user_id", "role_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_platform_dunning_notification_channels_channel_code",
table: "platform_dunning_notification_channels",
@@ -7973,6 +8357,55 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: new[] { "tenant_id", "code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_role_menus_menu_code",
table: "tenant_backend_role_menus",
column: "menu_code");
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_role_menus_tenant_id_role_id_menu_code",
table: "tenant_backend_role_menus",
columns: new[] { "tenant_id", "role_id", "menu_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_role_permissions_permission_code",
table: "tenant_backend_role_permissions",
column: "permission_code");
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~",
table: "tenant_backend_role_permissions",
columns: new[] { "tenant_id", "role_id", "permission_code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_roles_tenant_id_code",
table: "tenant_backend_roles",
columns: new[] { "tenant_id", "code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_roles_tenant_id_status_code",
table: "tenant_backend_roles",
columns: new[] { "tenant_id", "status", "code" });
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_user_roles_tenant_id_role_id",
table: "tenant_backend_user_roles",
columns: new[] { "tenant_id", "role_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_user_roles_tenant_id_user_id_role_id",
table: "tenant_backend_user_roles",
columns: new[] { "tenant_id", "user_id", "role_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_backend_user_roles_user_id",
table: "tenant_backend_user_roles",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_tenant_class_members_tenant_id_class_id_status_member_type",
table: "tenant_class_members",
@@ -8686,64 +9119,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
migrationBuilder.Sql("""
create or replace function validate_tenant_question_reference()
returns trigger language plpgsql as $$
declare owner_mode text;
begin
select mode into owner_mode from tenants where id = new.question_owner_tenant_id;
if new.question_owner_tenant_id = new.tenant_id then
if new.source <> 'tenant' then raise exception 'A tenant-owned question reference must use tenant source'; end if;
elsif owner_mode = 'platform_owned' then
if new.source <> 'platform' then raise exception 'A platform-owned question reference must use platform source'; end if;
else
raise exception 'A tenant cannot reference another tenant private question';
end if;
return new;
end $$;
create trigger trg_validate_tenant_question_reference before insert or update on tenant_question_references
for each row execute function validate_tenant_question_reference();
create or replace function validate_taxonomy_owner_boundary()
returns trigger language plpgsql as $$
declare parent_mode text;
begin
if new.parent_owner_tenant_id is null or new.parent_owner_tenant_id = new.tenant_id then return new; end if;
select mode into parent_mode from tenants where id = new.parent_owner_tenant_id;
if parent_mode <> 'platform_owned' then
raise exception 'A taxonomy node can only extend platform or current tenant taxonomy';
end if;
return new;
end $$;
create trigger trg_validate_taxonomy_owner_boundary before insert or update on taxonomy_nodes
for each row execute function validate_taxonomy_owner_boundary();
create or replace function validate_question_bank_preference()
returns trigger language plpgsql as $$
declare owner_mode text;
begin
select mode into owner_mode from tenants where id = new.question_bank_owner_tenant_id;
if owner_mode <> 'platform_owned' then
raise exception 'Question bank preferences can only target platform-owned banks';
end if;
return new;
end $$;
create trigger trg_validate_question_bank_preference before insert or update on tenant_question_bank_preferences
for each row execute function validate_question_bank_preference();
""");
migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTenantQuestionReferenceGuard);
migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTaxonomyParentGuard);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
drop trigger if exists trg_validate_question_bank_preference on tenant_question_bank_preferences;
drop function if exists validate_question_bank_preference();
drop trigger if exists trg_validate_taxonomy_owner_boundary on taxonomy_nodes;
drop function if exists validate_taxonomy_owner_boundary();
drop trigger if exists trg_validate_tenant_question_reference on tenant_question_references;
drop function if exists validate_tenant_question_reference();
""");
migrationBuilder.Sql(PostgreSqlTenantConstraintSql.DropTenantGuards);
migrationBuilder.DropForeignKey(
name: "fk_content_entries_regions_tenant_id_region_id",
@@ -8970,6 +9353,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "auth_sessions");
migrationBuilder.DropTable(
name: "background_jobs");
migrationBuilder.DropTable(
name: "banners");
@@ -9042,6 +9428,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "platform_audit_alerts");
migrationBuilder.DropTable(
name: "platform_backend_role_menus");
migrationBuilder.DropTable(
name: "platform_backend_role_permissions");
migrationBuilder.DropTable(
name: "platform_backend_user_roles");
migrationBuilder.DropTable(
name: "platform_dunning_notification_events");
@@ -9114,6 +9509,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "subject_shares");
migrationBuilder.DropTable(
name: "tenant_backend_role_menus");
migrationBuilder.DropTable(
name: "tenant_backend_role_permissions");
migrationBuilder.DropTable(
name: "tenant_backend_user_roles");
migrationBuilder.DropTable(
name: "tenant_billing_profiles");
@@ -9225,6 +9629,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "platform_audit_alert_rules");
migrationBuilder.DropTable(
name: "platform_backend_roles");
migrationBuilder.DropTable(
name: "platform_dunning_notification_channels");
@@ -9249,6 +9656,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "reports");
migrationBuilder.DropTable(
name: "backend_menus");
migrationBuilder.DropTable(
name: "tenant_backend_roles");
migrationBuilder.DropTable(
name: "tenant_role_templates");
@@ -9285,6 +9698,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "practice_sessions");
migrationBuilder.DropTable(
name: "backend_permissions");
migrationBuilder.DropTable(
name: "vocabulary_units");

View File

@@ -9876,6 +9876,268 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("icon");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<string>("ParentCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("parent_code");
b.Property<string>("Path")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<string>("PermissionCode")
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_backend_menus");
b.HasAlternateKey("Code")
.HasName("ak_backend_menus_code");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_backend_menus_code");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_backend_menus_permission_code");
b.HasIndex("Area", "ParentCode", "SortOrder")
.HasDatabaseName("ix_backend_menus_area_parent_code_sort_order");
b.ToTable("backend_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("area");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("module");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_backend_permissions");
b.HasAlternateKey("Code")
.HasName("ak_backend_permissions_code");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_backend_permissions_code");
b.HasIndex("Area", "Module", "SortOrder")
.HasDatabaseName("ix_backend_permissions_area_module_sort_order");
b.ToTable("backend_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_type");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("last_error");
b.Property<DateTimeOffset?>("LockExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lock_expires_at");
b.Property<string>("LockedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("locked_by");
b.Property<int>("MaxRetries")
.HasColumnType("integer")
.HasColumnName("max_retries");
b.Property<Guid?>("OutputAssetId")
.HasColumnType("uuid")
.HasColumnName("output_asset_id");
b.Property<JsonElement>("Payload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("Result")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("result")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("retry_count");
b.Property<DateTimeOffset?>("RunAfter")
.HasColumnType("timestamp with time zone")
.HasColumnName("run_after");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_background_jobs");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_background_jobs_tenant_id_id");
b.HasIndex("TenantId", "OutputAssetId")
.HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id");
b.HasIndex("TenantId", "JobType", "Status", "CreatedAt")
.HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at");
b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt")
.HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at");
b.ToTable("background_jobs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.Property<Guid>("Id")
@@ -10135,6 +10397,381 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("faqs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_platform_backend_roles");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_platform_backend_roles_code");
b.HasIndex("Status", "Code")
.HasDatabaseName("ix_platform_backend_roles_status_code");
b.ToTable("platform_backend_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.HasKey("Id")
.HasName("pk_platform_backend_role_menus");
b.HasIndex("MenuCode")
.HasDatabaseName("ix_platform_backend_role_menus_menu_code");
b.HasIndex("RoleId", "MenuCode")
.IsUnique()
.HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code");
b.ToTable("platform_backend_role_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.HasKey("Id")
.HasName("pk_platform_backend_role_permissions");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_platform_backend_role_permissions_permission_code");
b.HasIndex("RoleId", "PermissionCode")
.IsUnique()
.HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code");
b.ToTable("platform_backend_role_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_platform_backend_user_roles");
b.HasIndex("RoleId")
.HasDatabaseName("ix_platform_backend_user_roles_role_id");
b.HasIndex("UserId", "RoleId")
.IsUnique()
.HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id");
b.ToTable("platform_backend_user_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_backend_roles");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_roles_tenant_id_id");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_roles_tenant_id_code");
b.HasIndex("TenantId", "Status", "Code")
.HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code");
b.ToTable("tenant_backend_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("MenuCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("menu_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_role_menus");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_role_menus_tenant_id_id");
b.HasIndex("MenuCode")
.HasDatabaseName("ix_tenant_backend_role_menus_menu_code");
b.HasIndex("TenantId", "RoleId", "MenuCode")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code");
b.ToTable("tenant_backend_role_menus", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)")
.HasColumnName("permission_code");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_role_permissions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_role_permissions_tenant_id_id");
b.HasIndex("PermissionCode")
.HasDatabaseName("ix_tenant_backend_role_permissions_permission_code");
b.HasIndex("TenantId", "RoleId", "PermissionCode")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~");
b.ToTable("tenant_backend_role_permissions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("RoleId")
.HasColumnType("uuid")
.HasColumnName("role_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tenant_backend_user_roles");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_backend_user_roles_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_backend_user_roles_user_id");
b.HasIndex("TenantId", "RoleId")
.HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id");
b.HasIndex("TenantId", "UserId", "RoleId")
.IsUnique()
.HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id");
b.ToTable("tenant_backend_user_roles", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.Property<Guid>("Id")
@@ -15991,6 +16628,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_audit_logs_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_backend_menus_backend_permissions_permission_code");
});
modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_background_jobs_tenants_tenant_id");
b.HasOne("Tiku.Domain.Content.ContentAsset", null)
.WithMany()
.HasForeignKey("TenantId", "OutputAssetId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.Badge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16035,6 +16699,125 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_faqs_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
.WithMany()
.HasForeignKey("MenuCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code");
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~");
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b =>
{
b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_platform_backend_user_roles_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendMenu", null)
.WithMany()
.HasForeignKey("MenuCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
.WithMany()
.HasForeignKey("PermissionCode")
.HasPrincipalKey("Code")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_user_roles_users_user_id");
b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null)
.WithMany()
.HasForeignKey("TenantId", "RoleId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~");
});
modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)

View File

@@ -0,0 +1,89 @@
namespace Tiku.Infrastructure.Persistence;
internal static class PostgreSqlTenantConstraintSql
{
public const string CreateTenantQuestionReferenceGuard = """
create or replace function tiku_guard_tenant_question_reference()
returns trigger
language plpgsql
as $$
declare
owner_mode text;
begin
select mode into owner_mode
from tenants
where id = new.question_owner_tenant_id;
if owner_mode = 'platform_owned' then
if new.source <> 'platform' then
raise exception 'tenant question reference to platform question must use platform source'
using errcode = '23514',
constraint = 'ck_tenant_question_references_platform_or_self_owner';
end if;
return new;
end if;
if new.question_owner_tenant_id = new.tenant_id then
if new.source <> 'tenant' then
raise exception 'tenant question reference to tenant-owned question must use tenant source'
using errcode = '23514',
constraint = 'ck_tenant_question_references_platform_or_self_owner';
end if;
return new;
end if;
raise exception 'tenant question reference cannot point to another tenant private question'
using errcode = '23514',
constraint = 'ck_tenant_question_references_platform_or_self_owner';
end;
$$;
create trigger trg_tenant_question_references_platform_or_self_owner
before insert or update of tenant_id, question_owner_tenant_id, question_id, source
on tenant_question_references
for each row
execute function tiku_guard_tenant_question_reference();
""";
public const string CreateTaxonomyParentGuard = """
create or replace function tiku_guard_taxonomy_parent_owner()
returns trigger
language plpgsql
as $$
declare
parent_owner_mode text;
begin
if new.parent_owner_tenant_id is null and new.parent_id is null then
return new;
end if;
select mode into parent_owner_mode
from tenants
where id = new.parent_owner_tenant_id;
if parent_owner_mode = 'platform_owned' or new.parent_owner_tenant_id = new.tenant_id then
return new;
end if;
raise exception 'taxonomy parent must belong to platform or current tenant'
using errcode = '23514',
constraint = 'ck_taxonomy_nodes_parent_platform_or_self_owner';
end;
$$;
create trigger trg_taxonomy_nodes_parent_platform_or_self_owner
before insert or update of tenant_id, parent_owner_tenant_id, parent_id
on taxonomy_nodes
for each row
execute function tiku_guard_taxonomy_parent_owner();
""";
public const string DropTenantGuards = """
drop trigger if exists trg_tenant_question_references_platform_or_self_owner on tenant_question_references;
drop function if exists tiku_guard_tenant_question_reference();
drop trigger if exists trg_taxonomy_nodes_parent_platform_or_self_owner on taxonomy_nodes;
drop function if exists tiku_guard_taxonomy_parent_owner();
""";
}

View File

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

View File

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