feat: 新增租户审核领取和审核日志接口

- 新增 TenantReviewClaim 和 TenantAuditLog 实体
- 新增 TenantAuditAction 枚举
- 新增审核领取相关接口:GET/POST /review/claim, /review/force-claim, /review/release
- 新增审核日志接口:GET /audits
- 更新 ITenantRepository 和 EfTenantRepository
- 更新 TakeoutAppDbContext 添加 DbSet

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-02 20:22:03 +08:00
parent a586407e60
commit 6ffcc09c26
19 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 领取租户审核命令。
/// </summary>
public sealed record ClaimTenantReviewCommand(long TenantId) : IRequest<TenantReviewClaimDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 强制接管租户审核命令。
/// </summary>
public sealed record ForceClaimTenantReviewCommand(long TenantId) : IRequest<TenantReviewClaimDto>;

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 释放租户审核领取命令。
/// </summary>
public sealed record ReleaseTenantReviewClaimCommand(long TenantId) : IRequest;

View File

@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户审核日志 DTO。
/// </summary>
public sealed class TenantAuditLogDto
{
/// <summary>
/// 日志 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 操作类型。
/// </summary>
public TenantAuditAction Action { get; init; }
/// <summary>
/// 操作标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 操作描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 操作人名称。
/// </summary>
public string? OperatorName { get; init; }
/// <summary>
/// 操作前状态。
/// </summary>
public TenantStatus? PreviousStatus { get; init; }
/// <summary>
/// 操作后状态。
/// </summary>
public TenantStatus? CurrentStatus { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户审核领取信息 DTO。
/// </summary>
public sealed class TenantReviewClaimDto
{
/// <summary>
/// 记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 领取人 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long ClaimedBy { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string ClaimedByName { get; init; } = string.Empty;
/// <summary>
/// 领取时间。
/// </summary>
public DateTime ClaimedAt { get; init; }
}

View File

@@ -0,0 +1,103 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 领取租户审核命令处理器。
/// </summary>
public sealed class ClaimTenantReviewCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IIdGenerator idGenerator,
ILogger<ClaimTenantReviewCommandHandler> logger)
: IRequestHandler<ClaimTenantReviewCommand, TenantReviewClaimDto>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto> Handle(ClaimTenantReviewCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户是否存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
if (tenant is null)
{
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
}
// 2. 校验租户状态(只有待审核状态才能领取)
if (tenant.Status != TenantStatus.PendingReview)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户不在待审核状态,无法领取审核");
}
// 3. 检查是否已被领取
var existingClaim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (existingClaim is not null)
{
// 4. 如果是自己领取的,直接返回
if (existingClaim.ClaimedBy == currentUserAccessor.UserId)
{
return ToDto(existingClaim);
}
// 5. 已被他人领取
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {existingClaim.ClaimedByName} 领取");
}
// 6. 创建新的领取记录
var now = DateTime.UtcNow;
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
var claim = new TenantReviewClaim
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
ClaimedBy = currentUserAccessor.UserId,
ClaimedByName = actorName,
ClaimedAt = now
};
// 7. 保存领取记录
await tenantRepository.AddReviewClaimAsync(claim, cancellationToken);
// 8. 添加审核日志
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
Action = TenantAuditAction.ReviewClaimed,
Title = "领取审核",
Description = $"领取人:{actorName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = actorName,
CurrentStatus = tenant.Status
}, cancellationToken);
// 9. 保存变更
await tenantRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 审核已被 {UserId} 领取", request.TenantId, currentUserAccessor.UserId);
return ToDto(claim);
}
private static TenantReviewClaimDto ToDto(TenantReviewClaim claim)
=> new()
{
Id = claim.Id,
TenantId = claim.TenantId,
ClaimedBy = claim.ClaimedBy,
ClaimedByName = claim.ClaimedByName,
ClaimedAt = claim.ClaimedAt
};
}

View File

@@ -0,0 +1,94 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 强制接管租户审核命令处理器。
/// </summary>
public sealed class ForceClaimTenantReviewCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IIdGenerator idGenerator,
ILogger<ForceClaimTenantReviewCommandHandler> logger)
: IRequestHandler<ForceClaimTenantReviewCommand, TenantReviewClaimDto>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto> Handle(ForceClaimTenantReviewCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户是否存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
if (tenant is null)
{
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
}
// 2. 校验租户状态(只有待审核状态才能领取)
if (tenant.Status != TenantStatus.PendingReview)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户不在待审核状态,无法领取审核");
}
// 3. 检查是否已被领取,如果有则释放
var existingClaim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (existingClaim is not null)
{
existingClaim.ReleasedAt = DateTime.UtcNow;
}
// 4. 创建新的领取记录
var now = DateTime.UtcNow;
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
var claim = new TenantReviewClaim
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
ClaimedBy = currentUserAccessor.UserId,
ClaimedByName = actorName,
ClaimedAt = now
};
// 5. 保存领取记录
await tenantRepository.AddReviewClaimAsync(claim, cancellationToken);
// 6. 添加审核日志
var previousClaimInfo = existingClaim is not null ? $",原领取人:{existingClaim.ClaimedByName}" : "";
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
Action = TenantAuditAction.ReviewForceClaimed,
Title = "强制接管审核",
Description = $"接管人:{actorName}{previousClaimInfo}",
OperatorId = currentUserAccessor.UserId,
OperatorName = actorName,
CurrentStatus = tenant.Status
}, cancellationToken);
// 7. 保存变更
await tenantRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 审核已被 {UserId} 强制接管", request.TenantId, currentUserAccessor.UserId);
return new TenantReviewClaimDto
{
Id = claim.Id,
TenantId = claim.TenantId,
ClaimedBy = claim.ClaimedBy,
ClaimedByName = claim.ClaimedByName,
ClaimedAt = claim.ClaimedAt
};
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户审核日志查询处理器。
/// </summary>
public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantAuditLogsQuery, PagedResult<TenantAuditLogDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantAuditLogDto>> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken)
{
// 1. 查询审核日志
var (items, totalCount) = await tenantRepository.GetAuditLogsAsync(
request.TenantId,
request.Page,
request.PageSize,
cancellationToken);
// 2. 转换为 DTO
var dtos = items.Select(log => new TenantAuditLogDto
{
Id = log.Id,
TenantId = log.TenantId,
Action = log.Action,
Title = log.Title,
Description = log.Description,
OperatorName = log.OperatorName,
PreviousStatus = log.PreviousStatus,
CurrentStatus = log.CurrentStatus,
CreatedAt = log.CreatedAt
}).ToList();
// 3. 返回分页结果
return new PagedResult<TenantAuditLogDto>(dtos, totalCount, request.Page, request.PageSize);
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户审核领取信息查询处理器。
/// </summary>
public sealed class GetTenantReviewClaimQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantReviewClaimQuery, TenantReviewClaimDto?>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto?> Handle(GetTenantReviewClaimQuery request, CancellationToken cancellationToken)
{
// 1. 查询当前有效的审核领取记录
var claim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
// 2. 未找到返回 null
if (claim is null)
{
return null;
}
// 3. 转换为 DTO
return new TenantReviewClaimDto
{
Id = claim.Id,
TenantId = claim.TenantId,
ClaimedBy = claim.ClaimedBy,
ClaimedByName = claim.ClaimedByName,
ClaimedAt = claim.ClaimedAt
};
}
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 释放租户审核领取命令处理器。
/// </summary>
public sealed class ReleaseTenantReviewClaimCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IIdGenerator idGenerator,
ILogger<ReleaseTenantReviewClaimCommandHandler> logger)
: IRequestHandler<ReleaseTenantReviewClaimCommand>
{
/// <inheritdoc />
public async Task Handle(ReleaseTenantReviewClaimCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户是否存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
if (tenant is null)
{
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
}
// 2. 检查是否有领取记录
var existingClaim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (existingClaim is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "该租户审核未被领取");
}
// 3. 校验是否是自己领取的
if (existingClaim.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Forbidden, "只能释放自己领取的审核");
}
// 4. 释放领取
existingClaim.ReleasedAt = DateTime.UtcNow;
// 5. 添加审核日志
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
Action = TenantAuditAction.ReviewReleased,
Title = "释放审核",
Description = $"释放人:{actorName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = actorName,
CurrentStatus = tenant.Status
}, cancellationToken);
// 6. 保存变更
await tenantRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 审核已被 {UserId} 释放", request.TenantId, currentUserAccessor.UserId);
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 获取租户审核日志查询。
/// </summary>
public sealed record GetTenantAuditLogsQuery : IRequest<PagedResult<TenantAuditLogDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 获取租户审核领取信息查询。
/// </summary>
public sealed record GetTenantReviewClaimQuery(long TenantId) : IRequest<TenantReviewClaimDto?>;