From 0d1db9a11a0c6858d8381d91c24a7fbaf0d7931b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 27 Dec 2025 09:33:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=94=B9=E9=80=A0=E4=B8=BAOutbox=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=97=A5=E5=BF=97=E5=BA=93=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 5 + src/Api/TakeoutSaaS.AdminApi/Program.cs | 2 + .../appsettings.Development.json | 4 +- .../appsettings.Production.json | 4 +- .../appsettings.Development.json | 4 +- .../appsettings.Production.json | 4 +- .../appsettings.Development.json | 4 +- .../appsettings.Production.json | 4 +- .../IIdentityOperationLogPublisher.cs | 17 + .../Events/IdentityUserOperationLogMessage.cs | 47 + ...atchIdentityUserOperationCommandHandler.cs | 15 +- .../ChangeIdentityUserStatusCommandHandler.cs | 17 +- .../CreateIdentityUserCommandHandler.cs | 35 +- .../DeleteIdentityUserCommandHandler.cs | 20 +- ...ResetIdentityUserPasswordCommandHandler.cs | 16 +- .../RestoreIdentityUserCommandHandler.cs | 22 +- .../UpdateIdentityUserCommandHandler.cs | 44 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Identity/Persistence/IdentityDbContext.cs | 3 + .../IdentityUserOperationLogConsumer.cs | 72 ++ ...ionLogOutboxServiceCollectionExtensions.cs | 56 ++ .../Persistence/OperationLogInboxMessage.cs | 19 + .../Logs/Persistence/TakeoutLogsDbContext.cs | 15 + .../IdentityOperationLogPublisher.cs | 15 + ...251227004313_AddIdentityOutbox.Designer.cs | 847 ++++++++++++++++++ .../20251227004313_AddIdentityOutbox.cs | 101 +++ .../IdentityDbContextModelSnapshot.cs | 121 +++ ...7_AddOperationLogInboxMessages.Designer.cs | 358 ++++++++ ...1227004337_AddOperationLogInboxMessages.cs | 43 + .../TakeoutLogsDbContextModelSnapshot.cs | 23 + 30 files changed, 1840 insertions(+), 99 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IIdentityOperationLogPublisher.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Events/IdentityUserOperationLogMessage.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Consumers/IdentityUserOperationLogConsumer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/OperationLogInboxMessage.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Publishers/IdentityOperationLogPublisher.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.Designer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.Designer.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.cs diff --git a/Directory.Build.props b/Directory.Build.props index 3bba554..da983d4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,4 +12,9 @@ + + + + + diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 211c70d..5d8837b 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -13,6 +13,7 @@ using TakeoutSaaS.Application.Sms.Extensions; using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Infrastructure.App.Extensions; using TakeoutSaaS.Infrastructure.Identity.Extensions; +using TakeoutSaaS.Infrastructure.Logs.Extensions; using TakeoutSaaS.Module.Authorization.Extensions; using TakeoutSaaS.Module.Dictionary.Extensions; using TakeoutSaaS.Module.Messaging.Extensions; @@ -76,6 +77,7 @@ builder.Services.AddSmsModule(builder.Configuration); builder.Services.AddSmsApplication(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); +builder.Services.AddOperationLogOutbox(builder.Configuration); builder.Services.AddSchedulerModule(builder.Configuration); builder.Services.AddHealthChecks(); builder.Services.AddRateLimiter(options => diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index ad8b9b8..247eaa4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -32,9 +32,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index a9ccc27..e6b7183 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -32,9 +32,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json index 6fa3d7f..7fd933a 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json index 6fa3d7f..7fd933a 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json index 8e4e77e..0fe565e 100644 --- a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json index 8e4e77e..0fe565e 100644 --- a/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IIdentityOperationLogPublisher.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IIdentityOperationLogPublisher.cs new file mode 100644 index 0000000..2b0add6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IIdentityOperationLogPublisher.cs @@ -0,0 +1,17 @@ +using TakeoutSaaS.Application.Identity.Events; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 身份模块操作日志发布器。 +/// +public interface IIdentityOperationLogPublisher +{ + /// + /// 发布身份模块操作日志消息。 + /// + /// 操作日志消息。 + /// 取消标记。 + /// 异步任务。 + Task PublishAsync(IdentityUserOperationLogMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Events/IdentityUserOperationLogMessage.cs b/src/Application/TakeoutSaaS.Application/Identity/Events/IdentityUserOperationLogMessage.cs new file mode 100644 index 0000000..611a3b9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Events/IdentityUserOperationLogMessage.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.Identity.Events; + +/// +/// 身份用户操作日志消息。 +/// +public sealed record IdentityUserOperationLogMessage +{ + /// + /// 操作类型。 + /// + public string OperationType { get; init; } = string.Empty; + + /// + /// 目标类型。 + /// + public string TargetType { get; init; } = string.Empty; + + /// + /// 目标 ID 列表(JSON)。 + /// + public string? TargetIds { get; init; } + + /// + /// 操作人 ID。 + /// + public string? OperatorId { get; init; } + + /// + /// 操作人名称。 + /// + public string? OperatorName { get; init; } + + /// + /// 操作参数(JSON)。 + /// + public string? Parameters { get; init; } + + /// + /// 操作结果(JSON)。 + /// + public string? Result { get; init; } + + /// + /// 是否成功。 + /// + public bool Success { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs index 11dae75..31f4ddc 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs @@ -3,12 +3,11 @@ using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Application.Identity.Models; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -26,7 +25,7 @@ public sealed class BatchIdentityUserOperationCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository) + IIdentityOperationLogPublisher operationLogPublisher) : IRequestHandler { /// @@ -208,7 +207,7 @@ public sealed class BatchIdentityUserOperationCommandHandler( })); } - // 7. (空行后) 写入操作日志 + // 7. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -217,7 +216,7 @@ public sealed class BatchIdentityUserOperationCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:batch", TargetType = "identity_user", @@ -228,8 +227,10 @@ public sealed class BatchIdentityUserOperationCommandHandler( Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }), Success = failures.Count == 0 }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 8. (空行后) 写入 Outbox 并保存变更 + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); return new BatchIdentityUserOperationResult { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs index be0534d..11da3a9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs @@ -2,10 +2,9 @@ using MediatR; using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -23,7 +22,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository) + IIdentityOperationLogPublisher operationLogPublisher) : IRequestHandler { /// @@ -81,9 +80,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler( throw new BusinessException(ErrorCodes.BadRequest, "无效的用户状态"); } - await identityUserRepository.SaveChangesAsync(cancellationToken); - - // 6. (空行后) 写入操作日志 + // 6. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -92,7 +89,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:status-change", TargetType = "identity_user", @@ -109,8 +106,10 @@ public sealed class ChangeIdentityUserStatusCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 7. (空行后) 写入 Outbox 并保存变更 + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); return true; } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs index 68a1064..edc5d3f 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs @@ -4,14 +4,14 @@ using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Application.Identity.Queries; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; @@ -28,7 +28,8 @@ public sealed class CreateIdentityUserCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository, + IIdentityOperationLogPublisher operationLogPublisher, + IIdGenerator idGenerator, IMediator mediator) : IRequestHandler { @@ -85,6 +86,7 @@ public sealed class CreateIdentityUserCommandHandler( // 6. (空行后) 创建用户实体 var user = new IdentityUser { + Id = idGenerator.NextId(), TenantId = tenantId, Account = account, DisplayName = displayName, @@ -100,17 +102,7 @@ public sealed class CreateIdentityUserCommandHandler( }; user.PasswordHash = passwordHasher.HashPassword(user, request.Password); - // 7. (空行后) 持久化用户 - await identityUserRepository.AddAsync(user, cancellationToken); - await identityUserRepository.SaveChangesAsync(cancellationToken); - - // 8. (空行后) 绑定角色 - if (roleIds.Length > 0) - { - await userRoleRepository.ReplaceUserRolesAsync(tenantId, user.Id, roleIds, cancellationToken); - } - - // 9. (空行后) 写入操作日志 + // 7. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -119,7 +111,7 @@ public sealed class CreateIdentityUserCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:create", TargetType = "identity_user", @@ -138,8 +130,17 @@ public sealed class CreateIdentityUserCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 8. (空行后) 持久化用户并写入 Outbox + await identityUserRepository.AddAsync(user, cancellationToken); + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); + + // 9. (空行后) 绑定角色 + if (roleIds.Length > 0) + { + await userRoleRepository.ReplaceUserRolesAsync(tenantId, user.Id, roleIds, cancellationToken); + } // 10. (空行后) 返回用户详情 var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs index 728aab8..74d3eba 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs @@ -2,10 +2,9 @@ using MediatR; using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -23,7 +22,7 @@ public sealed class DeleteIdentityUserCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository) + IIdentityOperationLogPublisher operationLogPublisher) : IRequestHandler { /// @@ -60,11 +59,7 @@ public sealed class DeleteIdentityUserCommandHandler( await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken); } - // 5. (空行后) 软删除用户 - await identityUserRepository.RemoveAsync(user, cancellationToken); - await identityUserRepository.SaveChangesAsync(cancellationToken); - - // 6. (空行后) 写入操作日志 + // 5. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -73,7 +68,7 @@ public sealed class DeleteIdentityUserCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:delete", TargetType = "identity_user", @@ -84,8 +79,11 @@ public sealed class DeleteIdentityUserCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 6. (空行后) 软删除用户并写入 Outbox + await identityUserRepository.RemoveAsync(user, cancellationToken); + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); return true; } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs index 1e010e2..2cb7ac6 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs @@ -3,10 +3,9 @@ using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -23,7 +22,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository) + IIdentityOperationLogPublisher operationLogPublisher) : IRequestHandler { /// @@ -66,9 +65,8 @@ public sealed class ResetIdentityUserPasswordCommandHandler( { user.Status = IdentityUserStatus.Active; } - await identityUserRepository.SaveChangesAsync(cancellationToken); - // 6. (空行后) 写入操作日志 + // 6. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -77,7 +75,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:password-reset", TargetType = "identity_user", @@ -88,8 +86,10 @@ public sealed class ResetIdentityUserPasswordCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id, expiresAt }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 7. (空行后) 写入 Outbox 并保存变更 + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); return new ResetIdentityUserPasswordResult { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs index eeb8da2..67ebe55 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs @@ -2,9 +2,8 @@ using MediatR; using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -20,7 +19,7 @@ public sealed class RestoreIdentityUserCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository) + IIdentityOperationLogPublisher operationLogPublisher) : IRequestHandler { /// @@ -54,12 +53,7 @@ public sealed class RestoreIdentityUserCommandHandler( return false; } - // 4. (空行后) 恢复软删除状态 - user.DeletedAt = null; - user.DeletedBy = null; - await identityUserRepository.SaveChangesAsync(cancellationToken); - - // 5. (空行后) 写入操作日志 + // 4. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -68,7 +62,7 @@ public sealed class RestoreIdentityUserCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:restore", TargetType = "identity_user", @@ -79,8 +73,12 @@ public sealed class RestoreIdentityUserCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 5. (空行后) 恢复软删除状态并写入 Outbox + user.DeletedAt = null; + user.DeletedBy = null; + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); return true; } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs index 30d2827..2f771e7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs @@ -3,11 +3,10 @@ using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Events; using TakeoutSaaS.Application.Identity.Queries; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; -using TakeoutSaaS.Domain.Tenants.Entities; -using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -25,7 +24,7 @@ public sealed class UpdateIdentityUserCommandHandler( ITenantProvider tenantProvider, ICurrentUserAccessor currentUserAccessor, IAdminAuthService adminAuthService, - IOperationLogRepository operationLogRepository, + IIdentityOperationLogPublisher operationLogPublisher, IMediator mediator) : IRequestHandler { @@ -93,23 +92,7 @@ public sealed class UpdateIdentityUserCommandHandler( user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim(); user.RowVersion = request.RowVersion; - // 6. (空行后) 持久化用户更新 - try - { - await identityUserRepository.SaveChangesAsync(cancellationToken); - } - catch (Exception ex) when (IsConcurrencyException(ex)) - { - throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试"); - } - - // 7. (空行后) 覆盖角色绑定(仅当显式传入时) - if (roleIds != null) - { - await userRoleRepository.ReplaceUserRolesAsync(user.TenantId, user.Id, roleIds, cancellationToken); - } - - // 8. (空行后) 写入操作日志 + // 6. (空行后) 构建操作日志消息 var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) ? operatorProfile.Account : operatorProfile.DisplayName; @@ -118,7 +101,7 @@ public sealed class UpdateIdentityUserCommandHandler( operatorName = $"user:{currentUserAccessor.UserId}"; } - var log = new OperationLog + var logMessage = new IdentityUserOperationLogMessage { OperationType = "identity-user:update", TargetType = "identity_user", @@ -136,8 +119,23 @@ public sealed class UpdateIdentityUserCommandHandler( Result = JsonSerializer.Serialize(new { userId = user.Id }), Success = true }; - await operationLogRepository.AddAsync(log, cancellationToken); - await operationLogRepository.SaveChangesAsync(cancellationToken); + + // 7. (空行后) 持久化用户更新并写入 Outbox + try + { + await operationLogPublisher.PublishAsync(logMessage, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) when (IsConcurrencyException(ex)) + { + throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试"); + } + + // 8. (空行后) 覆盖角色绑定(仅当显式传入时) + if (roleIds != null) + { + await userRoleRepository.ReplaceUserRolesAsync(user.TenantId, user.Id, roleIds, cancellationToken); + } // 9. (空行后) 返回用户详情 return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index 266b011..217f916 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Persistence; using TakeoutSaaS.Infrastructure.Identity.Repositories; using TakeoutSaaS.Infrastructure.Identity.Services; +using TakeoutSaaS.Infrastructure.Logs.Publishers; using TakeoutSaaS.Shared.Abstractions.Constants; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; @@ -60,6 +61,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped, PasswordHasher>(); + services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection("Identity:Jwt")) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 792fc14..9d0467b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -1,3 +1,4 @@ +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using TakeoutSaaS.Domain.Identity.Entities; @@ -79,6 +80,8 @@ public sealed class IdentityDbContext( ConfigureUserRole(modelBuilder.Entity()); ConfigureRolePermission(modelBuilder.Entity()); ConfigureMenuDefinition(modelBuilder.Entity()); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); ApplyTenantQueryFilters(modelBuilder); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Consumers/IdentityUserOperationLogConsumer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Consumers/IdentityUserOperationLogConsumer.cs new file mode 100644 index 0000000..12dcdea --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Consumers/IdentityUserOperationLogConsumer.cs @@ -0,0 +1,72 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using TakeoutSaaS.Application.Identity.Events; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Infrastructure.Logs.Persistence; + +namespace TakeoutSaaS.Infrastructure.Logs.Consumers; + +/// +/// 身份用户操作日志消费者。 +/// +public sealed class IdentityUserOperationLogConsumer(TakeoutLogsDbContext logsContext) : IConsumer +{ + /// + public async Task Consume(ConsumeContext context) + { + // 1. 校验消息标识并进行幂等检查 + var messageId = context.MessageId; + if (!messageId.HasValue) + { + throw new InvalidOperationException("缺少 MessageId,无法进行日志幂等处理。"); + } + + var exists = await logsContext.OperationLogInboxMessages + .AsNoTracking() + .AnyAsync(x => x.MessageId == messageId.Value, context.CancellationToken); + if (exists) + { + return; + } + + // 2. (空行后) 构建日志实体与去重记录 + var message = context.Message; + var log = new OperationLog + { + OperationType = message.OperationType, + TargetType = message.TargetType, + TargetIds = message.TargetIds, + OperatorId = message.OperatorId, + OperatorName = message.OperatorName, + Parameters = message.Parameters, + Result = message.Result, + Success = message.Success + }; + logsContext.OperationLogInboxMessages.Add(new OperationLogInboxMessage + { + MessageId = messageId.Value, + ConsumedAt = DateTime.UtcNow + }); + logsContext.OperationLogs.Add(log); + + // 3. (空行后) 保存并处理并发去重冲突 + try + { + await logsContext.SaveChangesAsync(context.CancellationToken); + } + catch (DbUpdateException ex) when (IsDuplicateMessage(ex)) + { + return; + } + } + + private static bool IsDuplicateMessage(DbUpdateException exception) + { + if (exception.InnerException is PostgresException postgresException) + { + return postgresException.SqlState == PostgresErrorCodes.UniqueViolation; + } + return false; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs new file mode 100644 index 0000000..c4a0029 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Infrastructure.Identity.Persistence; +using TakeoutSaaS.Infrastructure.Logs.Consumers; +using TakeoutSaaS.Module.Messaging.Options; + +namespace TakeoutSaaS.Infrastructure.Logs.Extensions; + +/// +/// 操作日志 Outbox 注册扩展。 +/// +public static class OperationLogOutboxServiceCollectionExtensions +{ + /// + /// 注册操作日志 Outbox 与消费者。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddOperationLogOutbox(this IServiceCollection services, IConfiguration configuration) + { + // 1. 读取 RabbitMQ 配置 + var options = configuration.GetSection("RabbitMQ").Get(); + if (options == null) + { + throw new InvalidOperationException("缺少 RabbitMQ 配置。"); + } + + // 2. (空行后) 注册 MassTransit 与 Outbox + services.AddMassTransit(configurator => + { + configurator.AddConsumer(); + configurator.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + }); + configurator.UsingRabbitMq((context, cfg) => + { + var virtualHost = string.IsNullOrWhiteSpace(options.VirtualHost) ? "/" : options.VirtualHost.Trim(); + var virtualHostPath = virtualHost == "/" ? "/" : $"/{virtualHost.TrimStart('/')}"; + var hostUri = new Uri($"rabbitmq://{options.Host}:{options.Port}{virtualHostPath}"); + cfg.Host(hostUri, host => + { + host.Username(options.Username); + host.Password(options.Password); + }); + cfg.PrefetchCount = options.PrefetchCount; + cfg.ConfigureEndpoints(context); + }); + }); + // 3. (空行后) 返回服务集合 + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/OperationLogInboxMessage.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/OperationLogInboxMessage.cs new file mode 100644 index 0000000..8246ced --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/OperationLogInboxMessage.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Infrastructure.Logs.Persistence; + +/// +/// 操作日志消息消费去重记录。 +/// +public sealed class OperationLogInboxMessage : EntityBase +{ + /// + /// 消息唯一标识。 + /// + public Guid MessageId { get; set; } + + /// + /// 消费时间(UTC)。 + /// + public DateTime ConsumedAt { get; set; } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs index 1743a1e..655e566 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs @@ -35,6 +35,11 @@ public sealed class TakeoutLogsDbContext( /// public DbSet OperationLogs => Set(); + /// + /// 操作日志消息去重集合。 + /// + public DbSet OperationLogInboxMessages => Set(); + /// /// 成长值日志集合。 /// @@ -50,6 +55,7 @@ public sealed class TakeoutLogsDbContext( ConfigureTenantAuditLog(modelBuilder.Entity()); ConfigureMerchantAuditLog(modelBuilder.Entity()); ConfigureOperationLog(modelBuilder.Entity()); + ConfigureOperationLogInboxMessage(modelBuilder.Entity()); ConfigureMemberGrowthLog(modelBuilder.Entity()); } @@ -91,6 +97,15 @@ public sealed class TakeoutLogsDbContext( builder.HasIndex(x => x.CreatedAt); } + private static void ConfigureOperationLogInboxMessage(EntityTypeBuilder builder) + { + builder.ToTable("operation_log_inbox_messages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MessageId).IsRequired(); + builder.Property(x => x.ConsumedAt).IsRequired(); + builder.HasIndex(x => x.MessageId).IsUnique(); + } + private static void ConfigureMemberGrowthLog(EntityTypeBuilder builder) { builder.ToTable("member_growth_logs"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Publishers/IdentityOperationLogPublisher.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Publishers/IdentityOperationLogPublisher.cs new file mode 100644 index 0000000..a88055d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Publishers/IdentityOperationLogPublisher.cs @@ -0,0 +1,15 @@ +using MassTransit; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Events; + +namespace TakeoutSaaS.Infrastructure.Logs.Publishers; + +/// +/// 身份模块操作日志发布器(基于 MassTransit Outbox)。 +/// +public sealed class IdentityOperationLogPublisher(IPublishEndpoint publishEndpoint) : IIdentityOperationLogPublisher +{ + /// + public Task PublishAsync(IdentityUserOperationLogMessage message, CancellationToken cancellationToken = default) + => publishEndpoint.Publish(message, cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.Designer.cs new file mode 100644 index 0000000..9435bd5 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.Designer.cs @@ -0,0 +1,847 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251227004313_AddIdentityOutbox")] + partial class AddIdentityOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasColumnType("text") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱(租户内唯一)。"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasComment("登录失败次数。"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近登录时间(UTC)。"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasComment("锁定截止时间(UTC)。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasComment("是否强制修改密码。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号(租户内唯一)。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("账号状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("TenantId", "Phone") + .IsUnique() + .HasFilter("\"Phone\" IS NOT NULL"); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthListJson") + .HasColumnType("text") + .HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。"); + + b.Property("Component") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("组件路径(不含 .vue)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("图标标识。"); + + b.Property("IsIframe") + .HasColumnType("boolean") + .HasComment("是否 iframe。"); + + b.Property("KeepAlive") + .HasColumnType("boolean") + .HasComment("是否缓存。"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("外链或 iframe 地址。"); + + b.Property("MetaPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.permissions(逗号分隔)。"); + + b.Property("MetaRoles") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.roles(逗号分隔)。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("菜单名称(前端路由 name)。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级菜单 ID,根节点为 0。"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("路由路径。"); + + b.Property("RequiredPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("访问该菜单所需的权限集合(逗号分隔)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("menu_definitions", null, t => + { + t.HasComment("管理端菜单定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasColumnType("text") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("权限名称。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级权限 ID,根节点为 0。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值,值越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("权限类型(group/leaf)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("permissions", null, t => + { + t.HasComment("权限定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasComment("角色定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionId") + .HasColumnType("bigint") + .HasComment("权限 ID。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasComment("角色-权限关系。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("模板描述。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("模板编码(唯一)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TemplateCode") + .IsUnique(); + + b.ToTable("role_templates", null, t => + { + t.HasComment("角色模板定义(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码。"); + + b.Property("RoleTemplateId") + .HasColumnType("bigint") + .HasComment("模板 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("RoleTemplateId", "PermissionCode") + .IsUnique(); + + b.ToTable("role_template_permissions", null, t => + { + t.HasComment("角色模板-权限关系(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasComment("用户-角色关系。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.cs new file mode 100644 index 0000000..2e903cf --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251227004313_AddIdentityOutbox.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + /// + public partial class AddIdentityOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OutboxMessage", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + table: "OutboxState", + column: "Created"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessage"); + + migrationBuilder.DropTable( + name: "OutboxState"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs index e74d9ce..07a3cad 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs @@ -22,6 +22,127 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => { b.Property("Id") diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.Designer.cs new file mode 100644 index 0000000..9e305a6 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.Designer.cs @@ -0,0 +1,358 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Logs.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb +{ + [DbContext(typeof(TakeoutLogsDbContext))] + [Migration("20251227004337_AddOperationLogInboxMessages")] + partial class AddOperationLogInboxMessages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberGrowthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeValue") + .HasColumnType("integer") + .HasComment("变动数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentValue") + .HasColumnType("integer") + .HasComment("当前成长值。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_growth_logs", null, t => + { + t.HasComment("成长值变动日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasComment("动作类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("详情描述。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("商户标识。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId"); + + b.ToTable("merchant_audit_logs", null, t => + { + t.HasComment("商户入驻审核日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.OperationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OperationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作类型:BatchExtend, BatchRemind, StatusChange 等。"); + + b.Property("OperatorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人ID。"); + + b.Property("OperatorName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("操作人名称。"); + + b.Property("Parameters") + .HasColumnType("text") + .HasComment("操作参数(JSON)。"); + + b.Property("Result") + .HasColumnType("text") + .HasComment("操作结果(JSON)。"); + + b.Property("Success") + .HasColumnType("boolean") + .HasComment("是否成功。"); + + b.Property("TargetIds") + .HasColumnType("text") + .HasComment("目标ID列表(JSON)。"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("目标类型:Subscription, Bill 等。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OperationType", "CreatedAt"); + + b.ToTable("operation_logs", null, t => + { + t.HasComment("运营操作日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentStatus") + .HasColumnType("integer") + .HasComment("新状态。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("详细描述。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人 ID。"); + + b.Property("OperatorName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("操作人名称。"); + + b.Property("PreviousStatus") + .HasColumnType("integer") + .HasComment("原状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("关联的租户标识。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("日志标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("tenant_audit_logs", null, t => + { + t.HasComment("租户运营审核日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Infrastructure.Logs.Persistence.OperationLogInboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("operation_log_inbox_messages", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.cs new file mode 100644 index 0000000..f6e1232 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/20251227004337_AddOperationLogInboxMessages.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb +{ + /// + public partial class AddOperationLogInboxMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "operation_log_inbox_messages", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_operation_log_inbox_messages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_operation_log_inbox_messages_MessageId", + table: "operation_log_inbox_messages", + column: "MessageId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "operation_log_inbox_messages"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/TakeoutLogsDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/TakeoutLogsDbContextModelSnapshot.cs index d35a5c9..7cfe69a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/TakeoutLogsDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/LogsDb/TakeoutLogsDbContextModelSnapshot.cs @@ -326,6 +326,29 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb t.HasComment("租户运营审核日志。"); }); }); + + modelBuilder.Entity("TakeoutSaaS.Infrastructure.Logs.Persistence.OperationLogInboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("operation_log_inbox_messages", (string)null); + }); #pragma warning restore 612, 618 } }