Files
TakeoutSaaS.TenantApi/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs

270 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Entities;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 后台手动新增租户处理器。
/// </summary>
public sealed class CreateTenantManuallyCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository tenantPackageRepository,
IIdentityUserRepository identityUserRepository,
IRoleRepository roleRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IIdGenerator idGenerator,
IMediator mediator,
ITenantContextAccessor tenantContextAccessor,
ICurrentUserAccessor currentUserAccessor,
ILogger<CreateTenantManuallyCommandHandler> logger)
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
{
/// <inheritdoc />
public async Task<TenantDetailDto> Handle(CreateTenantManuallyCommand request, CancellationToken cancellationToken)
{
// 1. 校验订阅时长
if (request.DurationMonths <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "订阅时长必须大于 0");
}
// 2. 校验租户编码唯一性
var normalizedCode = request.Code.Trim();
if (await tenantRepository.ExistsByCodeAsync(normalizedCode, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {normalizedCode} 已存在");
}
// 3. (空行后) 校验联系人手机号唯一性(仅当填写时)
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
{
var normalizedPhone = request.ContactPhone.Trim();
if (await tenantRepository.ExistsByContactPhoneAsync(normalizedPhone, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
}
}
// 4. (空行后) 校验管理员账号唯一性
var normalizedAccount = request.AdminAccount.Trim();
if (await identityUserRepository.ExistsByAccountAsync(normalizedAccount, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"账号 {normalizedAccount} 已存在");
}
// 5. (空行后) 校验套餐存在且可用
var package = await tenantPackageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "套餐不存在");
if (!package.IsActive)
{
throw new BusinessException(ErrorCodes.BadRequest, "套餐未启用,无法绑定订阅");
}
// 6. (空行后) 计算订阅生效与到期时间UTC
var now = DateTime.UtcNow;
var subscriptionEffectiveFrom = request.SubscriptionEffectiveFrom ?? now;
var subscriptionEffectiveTo = subscriptionEffectiveFrom.AddMonths(request.DurationMonths);
// 7. (空行后) 构建租户与订阅
var tenantId = idGenerator.NextId();
var tenant = new Tenant
{
Id = tenantId,
Code = normalizedCode,
Name = request.Name.Trim(),
ShortName = request.ShortName,
LegalEntityName = request.LegalEntityName,
Industry = request.Industry,
LogoUrl = request.LogoUrl,
CoverImageUrl = request.CoverImageUrl,
Website = request.Website,
Country = request.Country,
Province = request.Province,
City = request.City,
Address = request.Address,
ContactName = request.ContactName,
ContactPhone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
ContactEmail = request.ContactEmail,
Status = request.TenantStatus,
EffectiveFrom = subscriptionEffectiveFrom,
EffectiveTo = subscriptionEffectiveTo,
SuspendedAt = request.SuspendedAt,
SuspensionReason = request.SuspensionReason,
Tags = request.Tags,
Remarks = request.Remarks
};
// 8. (空行后) 构建订阅实体
var subscription = new TenantSubscription
{
Id = idGenerator.NextId(),
TenantId = tenantId,
TenantPackageId = request.TenantPackageId,
EffectiveFrom = subscriptionEffectiveFrom,
EffectiveTo = subscriptionEffectiveTo,
NextBillingDate = request.NextBillingDate ?? subscriptionEffectiveTo,
Status = request.SubscriptionStatus,
AutoRenew = request.AutoRenew,
ScheduledPackageId = request.ScheduledPackageId,
Notes = request.SubscriptionNotes
};
// 9. (空行后) 构建认证资料(默认直接通过)
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
var verification = new TenantVerificationProfile
{
Id = idGenerator.NextId(),
TenantId = tenantId,
Status = request.VerificationStatus,
BusinessLicenseNumber = request.BusinessLicenseNumber,
BusinessLicenseUrl = request.BusinessLicenseUrl,
LegalPersonName = request.LegalPersonName,
LegalPersonIdNumber = request.LegalPersonIdNumber,
LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl,
LegalPersonIdBackUrl = request.LegalPersonIdBackUrl,
BankAccountName = request.BankAccountName,
BankAccountNumber = request.BankAccountNumber,
BankName = request.BankName,
AdditionalDataJson = request.AdditionalDataJson,
SubmittedAt = request.SubmittedAt ?? now,
ReviewedAt = request.ReviewedAt ?? now,
ReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
ReviewedByName = string.IsNullOrWhiteSpace(request.ReviewedByName) ? actorName : request.ReviewedByName,
ReviewRemarks = request.ReviewRemarks
};
// 10. (空行后) 写入审计日志与订阅历史
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.RegistrationSubmitted,
Title = "后台手动创建",
Description = $"绑定套餐 {request.TenantPackageId},订阅 {request.DurationMonths} 月",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.SubscriptionUpdated,
Title = "订阅初始化",
Description = $"生效:{subscription.EffectiveFrom:yyyy-MM-dd HH:mm:ss},到期:{subscription.EffectiveTo:yyyy-MM-dd HH:mm:ss}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.VerificationApproved,
Title = "认证已通过",
Description = request.ReviewRemarks,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory
{
TenantId = tenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = request.TenantPackageId,
ToPackageId = request.TenantPackageId,
ChangeType = SubscriptionChangeType.New,
EffectiveFrom = subscription.EffectiveFrom,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.SubscriptionNotes
}, cancellationToken);
// 11. (空行后) 持久化租户、订阅与认证资料
await tenantRepository.AddTenantAsync(tenant, cancellationToken);
await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken);
await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
// 12. (空行后) 临时切换租户上下文,保证身份与权限写入正确
var previousContext = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "manual-create");
try
{
// 13. 创建租户管理员账号
var adminUser = new IdentityUser
{
TenantId = tenant.Id,
Account = normalizedAccount,
DisplayName = request.AdminDisplayName.Trim(),
PasswordHash = string.Empty,
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
MerchantId = request.AdminMerchantId,
Avatar = request.AdminAvatar
};
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
await identityUserRepository.AddAsync(adminUser, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
// 14. (空行后) 初始化租户管理员角色模板并绑定角色
await mediator.Send(new InitializeRoleTemplatesCommand
{
TemplateCodes = new[] { "tenant-admin" }
}, cancellationToken);
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenant.Id, cancellationToken);
if (tenantAdminRole != null)
{
await mediator.Send(new AssignUserRolesCommand
{
UserId = adminUser.Id,
RoleIds = new[] { tenantAdminRole.Id }
}, cancellationToken);
}
// 15. (空行后) 回写租户所有者账号
tenant.PrimaryOwnerUserId = adminUser.Id;
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
}
finally
{
// 16. 恢复上下文
tenantContextAccessor.Current = previousContext;
}
// 17. (空行后) 返回创建结果
logger.LogInformation("已后台手动创建租户 {TenantCode}", tenant.Code);
return new TenantDetailDto
{
Tenant = TenantMapping.ToDto(tenant, subscription, verification),
Verification = verification.ToVerificationDto(),
Subscription = subscription.ToSubscriptionDto(),
Package = package.ToDto()
};
}
}