feat: 身份操作日志改造为Outbox并修正日志库连接

This commit is contained in:
2025-12-27 09:33:16 +08:00
parent 04444c6554
commit 2d09a629be
30 changed files with 1840 additions and 99 deletions

View File

@@ -0,0 +1,17 @@
using TakeoutSaaS.Application.Identity.Events;
namespace TakeoutSaaS.Application.Identity.Abstractions;
/// <summary>
/// 身份模块操作日志发布器。
/// </summary>
public interface IIdentityOperationLogPublisher
{
/// <summary>
/// 发布身份模块操作日志消息。
/// </summary>
/// <param name="message">操作日志消息。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task PublishAsync(IdentityUserOperationLogMessage message, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.Identity.Events;
/// <summary>
/// 身份用户操作日志消息。
/// </summary>
public sealed record IdentityUserOperationLogMessage
{
/// <summary>
/// 操作类型。
/// </summary>
public string OperationType { get; init; } = string.Empty;
/// <summary>
/// 目标类型。
/// </summary>
public string TargetType { get; init; } = string.Empty;
/// <summary>
/// 目标 ID 列表JSON
/// </summary>
public string? TargetIds { get; init; }
/// <summary>
/// 操作人 ID。
/// </summary>
public string? OperatorId { get; init; }
/// <summary>
/// 操作人名称。
/// </summary>
public string? OperatorName { get; init; }
/// <summary>
/// 操作参数JSON
/// </summary>
public string? Parameters { get; init; }
/// <summary>
/// 操作结果JSON
/// </summary>
public string? Result { get; init; }
/// <summary>
/// 是否成功。
/// </summary>
public bool Success { get; init; }
}

View File

@@ -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<BatchIdentityUserOperationCommand, BatchIdentityUserOperationResult>
{
/// <inheritdoc />
@@ -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
{

View File

@@ -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<ChangeIdentityUserStatusCommand, bool>
{
/// <inheritdoc />
@@ -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;
}

View File

@@ -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<CreateIdentityUserCommand, UserDetailDto>
{
@@ -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);

View File

@@ -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<DeleteIdentityUserCommand, bool>
{
/// <inheritdoc />
@@ -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;
}

View File

@@ -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<ResetIdentityUserPasswordCommand, ResetIdentityUserPasswordResult>
{
/// <inheritdoc />
@@ -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
{

View File

@@ -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<RestoreIdentityUserCommand, bool>
{
/// <inheritdoc />
@@ -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;
}

View File

@@ -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<UpdateIdentityUserCommand, UserDetailDto?>
{
@@ -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);