From a55ebad67570465cf57b88c3df9d3c80cd598953 Mon Sep 17 00:00:00 2001 From: MSuMshk <173331402+msumshk@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:01:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=9A=84=E7=A7=9F=E6=88=B7=E5=AE=A1=E6=A0=B8=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=20POST=20/review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ReviewTenantCommand 支持 approve/reason/renewMonths/operatingMode - 新增 ReviewTenantCommandHandler 统一处理通过/驳回逻辑 - 添加 TenantsController.Review 接口 - 审核完成后自动释放领取、记录审核日志 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/TenantsController.cs | 27 +++ .../Tenants/Commands/ReviewTenantCommand.cs | 35 ++++ .../Handlers/ReviewTenantCommandHandler.cs | 184 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 7afeb68..64576b6 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -266,6 +266,33 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse.Ok(null, "审核驳回"); } + /// + /// 审核租户(统一接口,支持通过/驳回)。 + /// + /// 租户 ID(雪花算法)。 + /// 审核命令。 + /// 取消标记。 + /// 无内容。 + [HttpPost("{id:long}/review")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Review( + long id, + [FromBody] ReviewTenantCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路径参数与请求体一致 + var updatedCommand = command with { TenantId = id.ToString() }; + + // 2. 执行命令 + await mediator.Send(updatedCommand, cancellationToken); + + // 3. 返回成功 + return ApiResponse.Ok(null, command.Approve ? "审核通过" : "审核驳回"); + } + /// /// 获取租户审核领取信息。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs new file mode 100644 index 0000000..5d1b31f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Domain.Common.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 审核租户命令(统一处理通过/驳回)。 +/// +public sealed record ReviewTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法,字符串传输)。 + /// + public required string TenantId { get; init; } + + /// + /// 是否通过审核。 + /// + public bool Approve { get; init; } + + /// + /// 驳回原因(仅当 Approve=false 时有效)。 + /// + public string? Reason { get; init; } + + /// + /// 续费时长(月)(仅当 Approve=true 时有效)。 + /// + public int? RenewMonths { get; init; } + + /// + /// 经营模式(仅当 Approve=true 时有效)。 + /// + public OperatingMode? OperatingMode { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs new file mode 100644 index 0000000..3a2a0ab --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs @@ -0,0 +1,184 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +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; + +/// +/// 审核租户命令处理器(统一处理通过/驳回)。 +/// +public sealed class ReviewTenantCommandHandler( + ITenantRepository tenantRepository, + IIdentityUserRepository identityUserRepository, + ICurrentUserAccessor currentUserAccessor, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReviewTenantCommand request, CancellationToken cancellationToken) + { + // 1. 解析租户 ID + if (!long.TryParse(request.TenantId, out var tenantId) || tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "租户 ID 无效"); + } + + // 2. 获取租户(带跟踪) + var tenant = await tenantRepository.GetByIdForUpdateAsync(tenantId, cancellationToken); + if (tenant is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + } + + // 3. 校验租户状态(只有待审核状态才能审核) + if (tenant.Status != TenantStatus.PendingReview) + { + throw new BusinessException(ErrorCodes.BadRequest, $"租户当前状态为 {tenant.Status},无法审核"); + } + + // 4. 校验是否已领取审核 + var existingClaim = await tenantRepository.GetActiveReviewClaimAsync(tenantId, cancellationToken); + if (existingClaim is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "请先领取审核"); + } + + if (existingClaim.ClaimedBy != currentUserAccessor.UserId) + { + throw new BusinessException(ErrorCodes.Forbidden, $"该审核已被 {existingClaim.ClaimedByName} 领取,无法操作"); + } + + // 5. 获取认证资料(带跟踪) + var verification = await tenantRepository.GetVerificationForUpdateAsync(tenantId, cancellationToken); + if (verification is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户认证资料不存在"); + } + + // 6. 查询当前用户的显示名称 + var actorName = "system"; + if (currentUserAccessor.IsAuthenticated && currentUserAccessor.UserId != 0) + { + var user = await identityUserRepository.FindByIdAsync(currentUserAccessor.UserId, cancellationToken); + actorName = user?.DisplayName ?? $"用户{currentUserAccessor.UserId}"; + } + + // 7. 根据审核结果处理 + if (request.Approve) + { + await HandleApprove(tenant, verification, existingClaim, actorName, request, cancellationToken); + } + else + { + await HandleReject(tenant, verification, existingClaim, actorName, request, cancellationToken); + } + + // 8. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "租户 {TenantId} 审核{Result},审核人:{ReviewedBy}", + tenantId, + request.Approve ? "通过" : "驳回", + currentUserAccessor.UserId); + } + + private async Task HandleApprove( + Domain.Tenants.Entities.Tenant tenant, + TenantVerificationProfile verification, + TenantReviewClaim claim, + string actorName, + ReviewTenantCommand request, + CancellationToken cancellationToken) + { + // 1. 更新租户状态 + tenant.Status = TenantStatus.Active; + + // 2. 更新经营模式 + if (request.OperatingMode.HasValue) + { + tenant.OperatingMode = request.OperatingMode.Value; + } + + // 3. 更新订阅有效期(如果指定了续费时长) + if (request.RenewMonths.HasValue && request.RenewMonths.Value > 0) + { + var now = DateTime.UtcNow; + var effectiveFrom = tenant.EffectiveFrom ?? now; + var effectiveTo = effectiveFrom.AddMonths(request.RenewMonths.Value); + tenant.EffectiveFrom = effectiveFrom; + tenant.EffectiveTo = effectiveTo; + } + + // 4. 更新认证资料状态 + verification.Status = TenantVerificationStatus.Approved; + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId; + verification.ReviewedByName = actorName; + + // 5. 释放领取 + claim.ReleasedAt = DateTime.UtcNow; + + // 6. 添加审核日志 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + Action = TenantAuditAction.Approved, + Title = "审核通过", + Description = $"审核人:{actorName},续费时长:{request.RenewMonths ?? 0}个月", + OperatorId = currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = TenantStatus.PendingReview, + CurrentStatus = TenantStatus.Active + }, cancellationToken); + } + + private async Task HandleReject( + Domain.Tenants.Entities.Tenant tenant, + TenantVerificationProfile verification, + TenantReviewClaim claim, + string actorName, + ReviewTenantCommand request, + CancellationToken cancellationToken) + { + // 1. 校验驳回原因 + var rejectReason = request.Reason?.Trim(); + if (string.IsNullOrWhiteSpace(rejectReason)) + { + throw new BusinessException(ErrorCodes.BadRequest, "驳回原因不能为空"); + } + + // 2. 更新认证资料状态(租户状态保持 PendingReview,等待重新提交) + verification.Status = TenantVerificationStatus.Rejected; + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId; + verification.ReviewedByName = actorName; + verification.ReviewRemarks = rejectReason; + + // 3. 释放领取 + claim.ReleasedAt = DateTime.UtcNow; + + // 4. 添加审核日志 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + Action = TenantAuditAction.Rejected, + Title = "审核驳回", + Description = $"审核人:{actorName},驳回原因:{rejectReason}", + OperatorId = currentUserAccessor.UserId, + OperatorName = actorName, + CurrentStatus = tenant.Status + }, cancellationToken); + } +}