feat: 手动创建租户支持可选审核,新增审核通过/驳回接口
- CreateTenantManuallyCommand 添加 IsSkipApproval 字段 - 根据 IsSkipApproval 自动设置租户状态和认证状态 - 新增 ApproveTenantCommand/Handler 审核通过逻辑 - 新增 RejectTenantCommand/Handler 审核驳回逻辑 - TenantsController 添加 PUT /approve 和 PUT /reject 接口 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -207,4 +207,58 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
|
||||
// 3. 返回账单分页列表
|
||||
return ApiResponse<PagedResult<TenantBillingListDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过租户。
|
||||
/// </summary>
|
||||
/// <param name="id">租户 ID(雪花算法)。</param>
|
||||
/// <param name="command">审核通过命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>无内容。</returns>
|
||||
[HttpPut("{id:long}/approve")]
|
||||
[PermissionAuthorize("tenant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> 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<object>.Ok(null, "审核通过");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回租户。
|
||||
/// </summary>
|
||||
/// <param name="id">租户 ID(雪花算法)。</param>
|
||||
/// <param name="command">审核驳回命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>无内容。</returns>
|
||||
[HttpPut("{id:long}/reject")]
|
||||
[PermissionAuthorize("tenant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> 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<object>.Ok(null, "审核驳回");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过租户命令。
|
||||
/// </summary>
|
||||
public sealed record ApproveTenantCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,字符串传输)。
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人姓名(可选,用于显示)。
|
||||
/// </summary>
|
||||
public string? ReviewedByName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
public string? ReviewRemarks { get; init; }
|
||||
}
|
||||
@@ -112,6 +112,15 @@ public sealed record CreateTenantManuallyCommand : IRequest<TenantDetailDto>
|
||||
/// </summary>
|
||||
public TenantStatus TenantStatus { get; init; } = TenantStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 是否跳过审核(直接激活)。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// true:租户状态设为 Active,认证状态设为 Approved(默认)。
|
||||
/// false:租户状态设为 PendingReview,认证状态设为 Pending,需后续审核。
|
||||
/// </remarks>
|
||||
public bool IsSkipApproval { get; init; } = true;
|
||||
|
||||
// 2. 订阅信息(public.tenant_subscriptions)
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回租户命令。
|
||||
/// </summary>
|
||||
public sealed record RejectTenantCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,字符串传输)。
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核人姓名(可选,用于显示)。
|
||||
/// </summary>
|
||||
public string? ReviewedByName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 驳回原因(必填)。
|
||||
/// </summary>
|
||||
public required string RejectReason { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过租户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ApproveTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<ApproveTenantCommandHandler> logger)
|
||||
: IRequestHandler<ApproveTenantCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 审核驳回租户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class RejectTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<RejectTenantCommandHandler> logger)
|
||||
: IRequestHandler<RejectTenantCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user