From 13821f2d197ea35cd1e8e24e094efa1b5654d814 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 15 Dec 2025 12:16:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E5=8F=B0=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=A7=9F=E6=88=B7=E5=B9=B6=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=A9=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/TenantsController.cs | 16 ++ .../Commands/CreateTenantManuallyCommand.cs | 264 +++++++++++++++++ .../CreateTenantManuallyCommandHandler.cs | 268 ++++++++++++++++++ 3 files changed, 548 insertions(+) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 096a3c9..556ade8 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -35,6 +35,22 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse.Ok(result); } + /// + /// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。 + /// + /// 新增后的租户详情。 + [HttpPost("manual")] + [PermissionAuthorize("tenant:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateManually([FromBody] CreateTenantManuallyCommand command, CancellationToken cancellationToken) + { + // 1. 后台手动新增租户(直接可用) + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + /// /// 分页查询租户。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs new file mode 100644 index 0000000..f7af18d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs @@ -0,0 +1,264 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。 +/// +public sealed record CreateTenantManuallyCommand : IRequest +{ + /// + /// 租户短编码,作为跨系统引用的唯一标识。 + /// + [Required] + [StringLength(64)] + public string Code { get; init; } = string.Empty; + + /// + /// 租户全称或品牌名称。 + /// + [Required] + [StringLength(128)] + public string Name { get; init; } = string.Empty; + + /// + /// 对外展示的简称。 + /// + public string? ShortName { get; init; } + + /// + /// 法人或公司主体名称。 + /// + public string? LegalEntityName { get; init; } + + /// + /// 所属行业,如餐饮、零售等。 + /// + public string? Industry { get; init; } + + /// + /// LOGO 图片地址。 + /// + public string? LogoUrl { get; init; } + + /// + /// 品牌海报或封面图。 + /// + public string? CoverImageUrl { get; init; } + + /// + /// 官网或主要宣传链接。 + /// + public string? Website { get; init; } + + /// + /// 所在国家/地区。 + /// + public string? Country { get; init; } + + /// + /// 所在省份或州。 + /// + public string? Province { get; init; } + + /// + /// 所在城市。 + /// + public string? City { get; init; } + + /// + /// 详细地址信息。 + /// + public string? Address { get; init; } + + /// + /// 主联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 主联系人电话(唯一)。 + /// + public string? ContactPhone { get; init; } + + /// + /// 主联系人邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 业务标签集合(逗号分隔)。 + /// + public string? Tags { get; init; } + + /// + /// 备注信息,用于运营记录特殊说明。 + /// + public string? Remarks { get; init; } + + /// + /// 最近一次暂停服务时间。 + /// + public DateTime? SuspendedAt { get; init; } + + /// + /// 暂停或终止的原因说明。 + /// + public string? SuspensionReason { get; init; } + + /// + /// 租户当前状态,默认 Active(直接入驻)。 + /// + public TenantStatus TenantStatus { get; init; } = TenantStatus.Active; + + /// + /// 购买套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 订阅时长(月)。 + /// + [Range(1, int.MaxValue)] + public int DurationMonths { get; init; } = 12; + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } = false; + + /// + /// 订阅生效时间(UTC),为空则立即生效。 + /// + public DateTime? SubscriptionEffectiveFrom { get; init; } + + /// + /// 下次计费时间(UTC),为空则默认等于到期时间。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 订阅状态,默认 Active。 + /// + public SubscriptionStatus SubscriptionStatus { get; init; } = SubscriptionStatus.Active; + + /// + /// 预定下次切换的套餐 ID。 + /// + public long? ScheduledPackageId { get; init; } + + /// + /// 订阅备注。 + /// + public string? SubscriptionNotes { get; init; } + + /// + /// 实名状态,默认 Approved(直接通过)。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Approved; + + /// + /// 营业执照编号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照扫描件地址。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 法人身份证人像面图片地址。 + /// + public string? LegalPersonIdFrontUrl { get; init; } + + /// + /// 法人身份证国徽面图片地址。 + /// + public string? LegalPersonIdBackUrl { get; init; } + + /// + /// 对公账户户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 对公银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户行名称。 + /// + public string? BankName { get; init; } + + /// + /// 其他补充资料 JSON。 + /// + public string? AdditionalDataJson { get; init; } + + /// + /// 提交时间(UTC),为空则默认当前时间。 + /// + public DateTime? SubmittedAt { get; init; } + + /// + /// 审核时间(UTC),为空则默认当前时间。 + /// + public DateTime? ReviewedAt { get; init; } + + /// + /// 审核人姓名(展示用),为空则默认当前用户。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } + + /// + /// 租户管理员账号。 + /// + [Required] + [StringLength(128)] + public string AdminAccount { get; init; } = string.Empty; + + /// + /// 租户管理员显示名。 + /// + [Required] + [StringLength(128)] + public string AdminDisplayName { get; init; } = string.Empty; + + /// + /// 管理员初始密码(明文,仅用于创建时生成哈希,不会被持久化回传)。 + /// + [Required] + [StringLength(128, MinimumLength = 6)] + public string AdminPassword { get; init; } = string.Empty; + + /// + /// 管理员头像。 + /// + public string? AdminAvatar { get; init; } + + /// + /// 关联商户 ID(若有)。 + /// + public long? AdminMerchantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs new file mode 100644 index 0000000..510f904 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs @@ -0,0 +1,268 @@ +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; + +/// +/// 后台手动新增租户处理器。 +/// +public sealed class CreateTenantManuallyCommandHandler( + ITenantRepository tenantRepository, + ITenantPackageRepository tenantPackageRepository, + IIdentityUserRepository identityUserRepository, + IRoleRepository roleRepository, + IPasswordHasher passwordHasher, + IIdGenerator idGenerator, + IMediator mediator, + ITenantContextAccessor tenantContextAccessor, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) + : IRequestHandler +{ + /// + public async Task 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, + 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() + }; + } +} +