From a586407e60429c681291fac96847e63c9b2e32c2 Mon Sep 17 00:00:00 2001 From: MSuMshk <173331402+msumshk@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:59:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=8B=E5=8A=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E6=94=AF=E6=8C=81=E5=8F=AF=E9=80=89=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=AE=A1=E6=A0=B8=E9=80=9A?= =?UTF-8?q?=E8=BF=87/=E9=A9=B3=E5=9B=9E=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateTenantManuallyCommand 添加 IsSkipApproval 字段 - 根据 IsSkipApproval 自动设置租户状态和认证状态 - 新增 ApproveTenantCommand/Handler 审核通过逻辑 - 新增 RejectTenantCommand/Handler 审核驳回逻辑 - TenantsController 添加 PUT /approve 和 PUT /reject 接口 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/TenantsController.cs | 54 ++++++++++++++ .../Tenants/Commands/ApproveTenantCommand.cs | 24 ++++++ .../Commands/CreateTenantManuallyCommand.cs | 9 +++ .../Tenants/Commands/RejectTenantCommand.cs | 24 ++++++ .../Handlers/ApproveTenantCommandHandler.cs | 68 +++++++++++++++++ .../CreateTenantManuallyCommandHandler.cs | 28 ++++--- .../Handlers/RejectTenantCommandHandler.cs | 73 +++++++++++++++++++ 7 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ApproveTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RejectTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ApproveTenantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RejectTenantCommandHandler.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 60835e6..be3a541 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -207,4 +207,58 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController // 3. 返回账单分页列表 return ApiResponse>.Ok(result); } + + /// + /// 审核通过租户。 + /// + /// 租户 ID(雪花算法)。 + /// 审核通过命令。 + /// 取消标记。 + /// 无内容。 + [HttpPut("{id:long}/approve")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Approve( + long id, + [FromBody] ApproveTenantCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路径参数与请求体一致 + var updatedCommand = command with { TenantId = id.ToString() }; + + // 2. 执行命令 + await mediator.Send(updatedCommand, cancellationToken); + + // 3. 返回成功 + return ApiResponse.Ok(null, "审核通过"); + } + + /// + /// 审核驳回租户。 + /// + /// 租户 ID(雪花算法)。 + /// 审核驳回命令。 + /// 取消标记。 + /// 无内容。 + [HttpPut("{id:long}/reject")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Reject( + long id, + [FromBody] RejectTenantCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路径参数与请求体一致 + var updatedCommand = command with { TenantId = id.ToString() }; + + // 2. 执行命令 + await mediator.Send(updatedCommand, cancellationToken); + + // 3. 返回成功 + return ApiResponse.Ok(null, "审核驳回"); + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ApproveTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ApproveTenantCommand.cs new file mode 100644 index 0000000..3d41d4d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ApproveTenantCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 审核通过租户命令。 +/// +public sealed record ApproveTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法,字符串传输)。 + /// + public required string TenantId { get; init; } + + /// + /// 审核人姓名(可选,用于显示)。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs index 69b404e..35c2cf3 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs @@ -112,6 +112,15 @@ public sealed record CreateTenantManuallyCommand : IRequest /// public TenantStatus TenantStatus { get; init; } = TenantStatus.Active; + /// + /// 是否跳过审核(直接激活)。 + /// + /// + /// true:租户状态设为 Active,认证状态设为 Approved(默认)。 + /// false:租户状态设为 PendingReview,认证状态设为 Pending,需后续审核。 + /// + public bool IsSkipApproval { get; init; } = true; + // 2. 订阅信息(public.tenant_subscriptions) /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RejectTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RejectTenantCommand.cs new file mode 100644 index 0000000..11c5be7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RejectTenantCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 审核驳回租户命令。 +/// +public sealed record RejectTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法,字符串传输)。 + /// + public required string TenantId { get; init; } + + /// + /// 审核人姓名(可选,用于显示)。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 驳回原因(必填)。 + /// + public required string RejectReason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ApproveTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ApproveTenantCommandHandler.cs new file mode 100644 index 0000000..b393233 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ApproveTenantCommandHandler.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 审核通过租户命令处理器。 +/// +public sealed class ApproveTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ApproveTenantCommand 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 verification = await tenantRepository.GetVerificationForUpdateAsync(tenantId, cancellationToken); + if (verification is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户认证资料不存在"); + } + + // 5. 更新租户状态 + tenant.Status = TenantStatus.Active; + + // 6. 更新认证资料状态 + verification.Status = TenantVerificationStatus.Approved; + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId; + verification.ReviewedByName = request.ReviewedByName?.Trim(); + verification.ReviewRemarks = request.ReviewRemarks?.Trim(); + + // 7. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "租户 {TenantId} 审核通过,审核人:{ReviewedBy}", + tenantId, + currentUserAccessor.UserId); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs index 7ecf1f9..2ed0c7e 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs @@ -133,7 +133,11 @@ public sealed class CreateTenantManuallyCommandHandler( try { - // 12. 创建租户实体 + // 12. 根据是否跳过审核确定租户状态和认证状态 + var tenantStatus = request.IsSkipApproval ? TenantStatus.Active : TenantStatus.PendingReview; + var verificationStatus = request.IsSkipApproval ? TenantVerificationStatus.Approved : TenantVerificationStatus.Pending; + + // 13. 创建租户实体 var tenant = new Tenant { Id = tenantId, @@ -154,7 +158,7 @@ public sealed class CreateTenantManuallyCommandHandler( ContactEmail = request.ContactEmail?.Trim(), Tags = request.Tags?.Trim(), Remarks = request.Remarks?.Trim(), - Status = request.TenantStatus, + Status = tenantStatus, SuspendedAt = request.SuspendedAt, SuspensionReason = request.SuspensionReason?.Trim(), EffectiveFrom = effectiveFrom, @@ -162,7 +166,7 @@ public sealed class CreateTenantManuallyCommandHandler( PrimaryOwnerUserId = adminUser.Id }; - // 13. 创建订阅实体 + // 14. 创建订阅实体 var subscription = new TenantSubscription { Id = idGenerator.NextId(), @@ -177,12 +181,12 @@ public sealed class CreateTenantManuallyCommandHandler( Notes = request.SubscriptionNotes?.Trim() }; - // 14. 创建认证资料实体 + // 15. 创建认证资料实体 var verification = new TenantVerificationProfile { Id = idGenerator.NextId(), TenantId = tenantId, - Status = request.VerificationStatus, + Status = verificationStatus, BusinessLicenseNumber = request.BusinessLicenseNumber?.Trim(), BusinessLicenseUrl = request.BusinessLicenseUrl?.Trim(), LegalPersonName = request.LegalPersonName?.Trim(), @@ -194,16 +198,16 @@ public sealed class CreateTenantManuallyCommandHandler( BankName = request.BankName?.Trim(), AdditionalDataJson = request.AdditionalDataJson?.Trim(), SubmittedAt = DateTime.UtcNow, - ReviewedAt = request.VerificationStatus == TenantVerificationStatus.Approved ? DateTime.UtcNow : null, - ReviewedBy = request.VerificationStatus == TenantVerificationStatus.Approved ? currentUserAccessor.UserId : null, - ReviewedByName = request.ReviewedByName?.Trim(), - ReviewRemarks = request.ReviewRemarks?.Trim() + ReviewedAt = request.IsSkipApproval ? DateTime.UtcNow : null, + ReviewedBy = request.IsSkipApproval ? currentUserAccessor.UserId : null, + ReviewedByName = request.IsSkipApproval ? request.ReviewedByName?.Trim() : null, + ReviewRemarks = request.IsSkipApproval ? request.ReviewRemarks?.Trim() : null }; - // 15. 根据套餐配额创建配额使用记录 + // 16. 根据套餐配额创建配额使用记录 var quotaUsages = CreateQuotaUsagesFromPackage(tenantId, package); - // 16. 创建账单记录和支付记录(可选) + // 17. 创建账单记录和支付记录(可选) TenantBillingStatement? billing = null; TenantPayment? payment = null; if (request.CreateBilling) @@ -230,7 +234,7 @@ public sealed class CreateTenantManuallyCommandHandler( // 18. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务) await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken); - logger.LogInformation("租户 {TenantId} 及相关数据创建成功", tenantId); + logger.LogInformation("租户 {TenantId} 及相关数据创建成功,跳过审核:{IsSkipApproval}", tenantId, request.IsSkipApproval); } catch (Exception ex) { diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RejectTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RejectTenantCommandHandler.cs new file mode 100644 index 0000000..1038d32 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RejectTenantCommandHandler.cs @@ -0,0 +1,73 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 审核驳回租户命令处理器。 +/// +public sealed class RejectTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(RejectTenantCommand request, CancellationToken cancellationToken) + { + // 1. 解析租户 ID + if (!long.TryParse(request.TenantId, out var tenantId) || tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "租户 ID 无效"); + } + + // 2. 校验驳回原因 + var rejectReason = request.RejectReason?.Trim(); + if (string.IsNullOrWhiteSpace(rejectReason)) + { + throw new BusinessException(ErrorCodes.BadRequest, "驳回原因不能为空"); + } + + // 3. 获取租户(带跟踪) + var tenant = await tenantRepository.GetByIdForUpdateAsync(tenantId, cancellationToken); + if (tenant is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + } + + // 4. 校验租户状态(只有待审核状态才能驳回) + if (tenant.Status != TenantStatus.PendingReview) + { + throw new BusinessException(ErrorCodes.BadRequest, $"租户当前状态为 {tenant.Status},无法驳回"); + } + + // 5. 获取认证资料(带跟踪) + var verification = await tenantRepository.GetVerificationForUpdateAsync(tenantId, cancellationToken); + if (verification is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户认证资料不存在"); + } + + // 6. 更新认证资料状态(租户状态保持 PendingReview,等待重新提交) + verification.Status = TenantVerificationStatus.Rejected; + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId; + verification.ReviewedByName = request.ReviewedByName?.Trim(); + verification.ReviewRemarks = rejectReason; + + // 7. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "租户 {TenantId} 审核驳回,驳回原因:{RejectReason},审核人:{ReviewedBy}", + tenantId, + rejectReason, + currentUserAccessor.UserId); + } +}