270 lines
12 KiB
C#
270 lines
12 KiB
C#
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()
|
||
};
|
||
}
|
||
}
|