From 2d09a629be4b776dc0b8d3b10cf381912f4561d7 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?feat:=20=E8=BA=AB=E4=BB=BD=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E6=97=A5=E5=BF=97=E6=94=B9=E9=80=A0=E4=B8=BAOutbox=E5=B9=B6?=
=?UTF-8?q?=E4=BF=AE=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