fix: 创建租户时自动创建默认角色并分配给管理员

- 在 CreateTenantManuallyCommandHandler 中添加创建 tenant-admin 角色逻辑
- 自动将默认角色分配给租户管理员用户
- 更新 Saga 补偿逻辑,失败时回滚角色和用户角色关系
- 修复 TenantsController 中 4 处 CS8625 可空引用类型警告

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-03 12:51:59 +08:00
parent e92b333076
commit ef7aec1b60
2 changed files with 55 additions and 22 deletions

View File

@@ -127,7 +127,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
await mediator.Send(updatedCommand, cancellationToken);
// 3. 返回成功
return ApiResponse<object>.Ok(null, "更新成功");
return ApiResponse.Success("更新成功");
}
/// <summary>
@@ -154,7 +154,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
await mediator.Send(updatedCommand, cancellationToken);
// 3. 返回成功
return ApiResponse<object>.Ok(null, "更新成功");
return ApiResponse.Success("更新成功");
}
/// <summary>
@@ -236,7 +236,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
await mediator.Send(updatedCommand, cancellationToken);
// 3. 返回成功
return ApiResponse<object>.Ok(null, command.Approve ? "审核通过" : "审核驳回");
return ApiResponse.Success(command.Approve ? "审核通过" : "审核驳回");
}
/// <summary>
@@ -333,7 +333,7 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
await mediator.Send(command, cancellationToken);
// 3. 返回成功
return ApiResponse<object>.Ok(null, "已释放审核");
return ApiResponse.Success("已释放审核");
}
/// <summary>

View File

@@ -24,14 +24,16 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// </summary>
/// <remarks>
/// 该操作涉及跨数据库App + Identity写入采用 Saga 补偿模式保证数据一致性:
/// 1. 先在 Identity 库创建管理员账号
/// 1. 先在 Identity 库创建管理员账号、默认角色tenant-admin并分配角色
/// 2. 再在 App 库创建租户、订阅、认证资料
/// 3. 如果步骤 2 失败,回滚步骤 1删除管理员账号
/// 3. 如果步骤 2 失败,回滚步骤 1删除用户角色关系、角色、管理员账号)
/// </remarks>
public sealed class CreateTenantManuallyCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository tenantPackageRepository,
IIdentityUserRepository identityUserRepository,
IRoleRepository roleRepository,
IUserRoleRepository userRoleRepository,
IPasswordHasher<IdentityUser> passwordHasher,
ICurrentUserAccessor currentUserAccessor,
IIdGenerator idGenerator,
@@ -107,13 +109,32 @@ public sealed class CreateTenantManuallyCommandHandler(
await identityUserRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 管理员账号 {AdminUserId} 创建成功", tenantId, adminUser.Id);
// 10. 创建租户默认角色tenant-admin
var defaultRole = new Role
{
Id = idGenerator.NextId(),
Portal = PortalType.Tenant,
TenantId = tenantId,
Code = "tenant-admin",
Name = "租户管理员",
Description = "租户默认管理员角色,拥有租户端全部权限"
};
await roleRepository.AddAsync(defaultRole, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 默认角色 {RoleId} 创建成功", tenantId, defaultRole.Id);
// 11. 为管理员用户分配默认角色
await userRoleRepository.ReplaceUserRolesAsync(PortalType.Tenant, tenantId, adminUser.Id, [defaultRole.Id], cancellationToken);
await userRoleRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("租户 {TenantId} 管理员 {AdminUserId} 已分配角色 {RoleId}", tenantId, adminUser.Id, defaultRole.Id);
try
{
// 10. 根据是否跳过审核确定租户状态和认证状态
// 12. 根据是否跳过审核确定租户状态和认证状态
var tenantStatus = request.IsSkipApproval ? TenantStatus.Active : TenantStatus.PendingReview;
var verificationStatus = request.IsSkipApproval ? TenantVerificationStatus.Approved : TenantVerificationStatus.Pending;
// 11. 创建租户实体
// 13. 创建租户实体
var tenant = new Tenant
{
Id = tenantId,
@@ -138,7 +159,7 @@ public sealed class CreateTenantManuallyCommandHandler(
PrimaryOwnerUserId = adminUser.Id
};
// 12. 创建订阅实体
// 14. 创建订阅实体
var subscription = new TenantSubscription
{
Id = idGenerator.NextId(),
@@ -152,7 +173,7 @@ public sealed class CreateTenantManuallyCommandHandler(
Notes = request.SubscriptionNotes?.Trim()
};
// 13. 创建认证资料实体
// 15. 创建认证资料实体
var verification = new TenantVerificationProfile
{
Id = idGenerator.NextId(),
@@ -172,10 +193,10 @@ public sealed class CreateTenantManuallyCommandHandler(
ReviewedBy = request.IsSkipApproval ? currentUserAccessor.UserId : null
};
// 14. 根据套餐配额创建配额使用记录
// 16. 根据套餐配额创建配额使用记录
var quotaUsages = CreateQuotaUsagesFromPackage(tenantId, package);
// 15. 创建账单记录和支付记录(可选)
// 17. 创建账单记录和支付记录(可选)
TenantBillingStatement? billing = null;
TenantPayment? payment = null;
if (request.CreateBilling)
@@ -193,46 +214,58 @@ public sealed class CreateTenantManuallyCommandHandler(
request.BillingStatus,
request.BillingNotes);
// 16. 如果账单状态为已支付,创建支付记录
// 18. 如果账单状态为已支付,创建支付记录
if (request.BillingStatus == TenantBillingStatus.Paid && billing.AmountDue > 0)
{
payment = CreatePaymentFromBilling(tenantId, billing);
}
}
// 17. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务)
// 19. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务)
await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken);
logger.LogInformation("租户 {TenantId} 及相关数据创建成功,跳过审核:{IsSkipApproval}", tenantId, request.IsSkipApproval);
}
catch (Exception ex)
{
// 18. 【Saga 补偿】App 库操作失败,回滚 Identity 库的管理员账号
// 18. 【Saga 补偿】App 库操作失败,回滚 Identity 库的管理员账号、角色和用户角色关系
// 记录完整异常信息(包括内部异常)
var fullErrorMessage = ex.InnerException?.Message ?? ex.Message;
logger.LogError(ex, "租户 {TenantId} 创建失败,错误详情:{ErrorDetail},开始回滚管理员账号 {AdminUserId}",
tenantId, fullErrorMessage, adminUser.Id);
logger.LogError(ex, "租户 {TenantId} 创建失败,错误详情:{ErrorDetail},开始回滚 Identity 数据",
tenantId, fullErrorMessage);
try
{
// 19. 回滚用户角色关系(清空用户的角色)
await userRoleRepository.ReplaceUserRolesAsync(PortalType.Tenant, tenantId, adminUser.Id, [], cancellationToken);
await userRoleRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("用户角色关系回滚成功,用户 {AdminUserId}", adminUser.Id);
// 20. 回滚默认角色
await roleRepository.DeleteAsync(PortalType.Tenant, tenantId, defaultRole.Id, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("默认角色 {RoleId} 回滚成功", defaultRole.Id);
// 21. 回滚管理员账号
await identityUserRepository.RemoveAsync(adminUser, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("管理员账号 {AdminUserId} 回滚成功", adminUser.Id);
}
catch (Exception rollbackEx)
{
// 19. 补偿失败,记录严重错误(需要人工介入)
// 22. 补偿失败,记录严重错误(需要人工介入)
logger.LogCritical(
rollbackEx,
"严重:租户 {TenantId} 创建失败且管理员账号 {AdminUserId} 回滚失败,需要人工介入清理",
"严重:租户 {TenantId} 创建失败且 Identity 数据回滚失败,需要人工介入清理(用户 {AdminUserId},角色 {RoleId}",
tenantId,
adminUser.Id);
adminUser.Id,
defaultRole.Id);
}
// 20. 重新抛出业务异常(包含详细错误信息)
// 23. 重新抛出业务异常(包含详细错误信息)
throw new BusinessException(ErrorCodes.InternalServerError, $"创建租户失败:{fullErrorMessage}");
}
// 21. 查询并返回租户详情
// 24. 查询并返回租户详情
var detail = await mediator.Send(new GetTenantDetailQuery { TenantId = tenantId }, cancellationToken);
return detail ?? throw new BusinessException(ErrorCodes.InternalServerError, "创建租户成功但查询详情失败");
}