feat: 后台手动新增租户并直接入驻接口

This commit is contained in:
2025-12-15 12:16:17 +08:00
parent 5e7f7144ed
commit 13821f2d19
3 changed files with 548 additions and 0 deletions

View File

@@ -35,6 +35,22 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
return ApiResponse<TenantDto>.Ok(result);
}
/// <summary>
/// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。
/// </summary>
/// <returns>新增后的租户详情。</returns>
[HttpPost("manual")]
[PermissionAuthorize("tenant:create")]
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantDetailDto>> CreateManually([FromBody] CreateTenantManuallyCommand command, CancellationToken cancellationToken)
{
// 1. 后台手动新增租户(直接可用)
var result = await mediator.Send(command, cancellationToken);
// 2. 返回创建结果
return ApiResponse<TenantDetailDto>.Ok(result);
}
/// <summary>
/// 分页查询租户。
/// </summary>

View File

@@ -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;
/// <summary>
/// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。
/// </summary>
public sealed record CreateTenantManuallyCommand : IRequest<TenantDetailDto>
{
/// <summary>
/// 租户短编码,作为跨系统引用的唯一标识。
/// </summary>
[Required]
[StringLength(64)]
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户全称或品牌名称。
/// </summary>
[Required]
[StringLength(128)]
public string Name { get; init; } = string.Empty;
/// <summary>
/// 对外展示的简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 法人或公司主体名称。
/// </summary>
public string? LegalEntityName { get; init; }
/// <summary>
/// 所属行业,如餐饮、零售等。
/// </summary>
public string? Industry { get; init; }
/// <summary>
/// LOGO 图片地址。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品牌海报或封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 官网或主要宣传链接。
/// </summary>
public string? Website { get; init; }
/// <summary>
/// 所在国家/地区。
/// </summary>
public string? Country { get; init; }
/// <summary>
/// 所在省份或州。
/// </summary>
public string? Province { get; init; }
/// <summary>
/// 所在城市。
/// </summary>
public string? City { get; init; }
/// <summary>
/// 详细地址信息。
/// </summary>
public string? Address { get; init; }
/// <summary>
/// 主联系人姓名。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 主联系人电话(唯一)。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 主联系人邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 业务标签集合(逗号分隔)。
/// </summary>
public string? Tags { get; init; }
/// <summary>
/// 备注信息,用于运营记录特殊说明。
/// </summary>
public string? Remarks { get; init; }
/// <summary>
/// 最近一次暂停服务时间。
/// </summary>
public DateTime? SuspendedAt { get; init; }
/// <summary>
/// 暂停或终止的原因说明。
/// </summary>
public string? SuspensionReason { get; init; }
/// <summary>
/// 租户当前状态,默认 Active直接入驻
/// </summary>
public TenantStatus TenantStatus { get; init; } = TenantStatus.Active;
/// <summary>
/// 购买套餐 ID。
/// </summary>
[Required]
public long TenantPackageId { get; init; }
/// <summary>
/// 订阅时长(月)。
/// </summary>
[Range(1, int.MaxValue)]
public int DurationMonths { get; init; } = 12;
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; } = false;
/// <summary>
/// 订阅生效时间UTC为空则立即生效。
/// </summary>
public DateTime? SubscriptionEffectiveFrom { get; init; }
/// <summary>
/// 下次计费时间UTC为空则默认等于到期时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 订阅状态,默认 Active。
/// </summary>
public SubscriptionStatus SubscriptionStatus { get; init; } = SubscriptionStatus.Active;
/// <summary>
/// 预定下次切换的套餐 ID。
/// </summary>
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 订阅备注。
/// </summary>
public string? SubscriptionNotes { get; init; }
/// <summary>
/// 实名状态,默认 Approved直接通过
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Approved;
/// <summary>
/// 营业执照编号。
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// 营业执照扫描件地址。
/// </summary>
public string? BusinessLicenseUrl { get; init; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; init; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; init; }
/// <summary>
/// 法人身份证人像面图片地址。
/// </summary>
public string? LegalPersonIdFrontUrl { get; init; }
/// <summary>
/// 法人身份证国徽面图片地址。
/// </summary>
public string? LegalPersonIdBackUrl { get; init; }
/// <summary>
/// 对公账户户名。
/// </summary>
public string? BankAccountName { get; init; }
/// <summary>
/// 对公银行账号。
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// 开户行名称。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 其他补充资料 JSON。
/// </summary>
public string? AdditionalDataJson { get; init; }
/// <summary>
/// 提交时间UTC为空则默认当前时间。
/// </summary>
public DateTime? SubmittedAt { get; init; }
/// <summary>
/// 审核时间UTC为空则默认当前时间。
/// </summary>
public DateTime? ReviewedAt { get; init; }
/// <summary>
/// 审核人姓名(展示用),为空则默认当前用户。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
/// <summary>
/// 租户管理员账号。
/// </summary>
[Required]
[StringLength(128)]
public string AdminAccount { get; init; } = string.Empty;
/// <summary>
/// 租户管理员显示名。
/// </summary>
[Required]
[StringLength(128)]
public string AdminDisplayName { get; init; } = string.Empty;
/// <summary>
/// 管理员初始密码(明文,仅用于创建时生成哈希,不会被持久化回传)。
/// </summary>
[Required]
[StringLength(128, MinimumLength = 6)]
public string AdminPassword { get; init; } = string.Empty;
/// <summary>
/// 管理员头像。
/// </summary>
public string? AdminAvatar { get; init; }
/// <summary>
/// 关联商户 ID若有
/// </summary>
public long? AdminMerchantId { get; init; }
}

View File

@@ -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;
/// <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,
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()
};
}
}