feat: 租户创建时自动创建商户和证照记录
- 扩展 MerchantDocumentType 枚举,新增法人身份证、卫生许可证等类型 - 新增 TenantCreatedEvent 领域事件 - 修改 CreateTenantManuallyCommandHandler 发布租户创建事件 - 新增 TenantCreatedEventConsumer 消费者,自动创建商户和证照 - 实现 Tenant 1:1 Merchant 的业务关系 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||||
using TakeoutSaaS.Application.App.Tenants.Contracts;
|
using TakeoutSaaS.Application.App.Tenants.Contracts;
|
||||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||||
|
using TakeoutSaaS.Application.Messaging.Abstractions;
|
||||||
using TakeoutSaaS.Domain.Billings.Entities;
|
using TakeoutSaaS.Domain.Billings.Entities;
|
||||||
using TakeoutSaaS.Domain.Billings.Enums;
|
using TakeoutSaaS.Domain.Billings.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
@@ -11,6 +12,7 @@ using TakeoutSaaS.Domain.Identity.Enums;
|
|||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Events;
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
@@ -38,6 +40,7 @@ public sealed class CreateTenantManuallyCommandHandler(
|
|||||||
ICurrentUserAccessor currentUserAccessor,
|
ICurrentUserAccessor currentUserAccessor,
|
||||||
IIdGenerator idGenerator,
|
IIdGenerator idGenerator,
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
IEventPublisher eventPublisher,
|
||||||
ILogger<CreateTenantManuallyCommandHandler> logger)
|
ILogger<CreateTenantManuallyCommandHandler> logger)
|
||||||
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
|
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
|
||||||
{
|
{
|
||||||
@@ -224,6 +227,34 @@ public sealed class CreateTenantManuallyCommandHandler(
|
|||||||
// 19. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务)
|
// 19. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务)
|
||||||
await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken);
|
await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken);
|
||||||
logger.LogInformation("租户 {TenantId} 及相关数据创建成功,跳过审核:{IsSkipApproval}", tenantId, request.IsSkipApproval);
|
logger.LogInformation("租户 {TenantId} 及相关数据创建成功,跳过审核:{IsSkipApproval}", tenantId, request.IsSkipApproval);
|
||||||
|
|
||||||
|
// 20. 发布租户创建完成事件(用于自动创建商户和证照)
|
||||||
|
var tenantCreatedEvent = new TenantCreatedEvent
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
ShortName = request.ShortName?.Trim(),
|
||||||
|
LegalEntityName = request.LegalEntityName?.Trim(),
|
||||||
|
LogoUrl = request.LogoUrl?.Trim(),
|
||||||
|
Province = request.Province?.Trim(),
|
||||||
|
City = request.City?.Trim(),
|
||||||
|
Address = request.Address?.Trim(),
|
||||||
|
ContactName = request.ContactName?.Trim(),
|
||||||
|
ContactPhone = request.ContactPhone?.Trim(),
|
||||||
|
ContactEmail = request.ContactEmail?.Trim(),
|
||||||
|
BusinessLicenseNumber = request.BusinessLicenseNumber?.Trim(),
|
||||||
|
BusinessLicenseUrl = request.BusinessLicenseUrl?.Trim(),
|
||||||
|
LegalPersonName = request.LegalPersonName?.Trim(),
|
||||||
|
LegalPersonIdNumber = request.LegalPersonIdNumber?.Trim(),
|
||||||
|
LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl?.Trim(),
|
||||||
|
LegalPersonIdBackUrl = request.LegalPersonIdBackUrl?.Trim(),
|
||||||
|
AdminUserId = adminUser.Id,
|
||||||
|
IsSkipApproval = request.IsSkipApproval,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await eventPublisher.PublishAsync("tenant.created", tenantCreatedEvent, cancellationToken);
|
||||||
|
logger.LogInformation("租户 {TenantId} 创建事件已发布", tenantId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public enum MerchantDocumentType
|
|||||||
BusinessLicense = 0,
|
BusinessLicense = 0,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 餐饮服务许可证。
|
/// 餐饮服务许可证(食品经营许可证)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
CateringPermit = 1,
|
CateringPermit = 1,
|
||||||
|
|
||||||
@@ -20,6 +20,51 @@ public enum MerchantDocumentType
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
TaxCertificate = 2,
|
TaxCertificate = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人身份证正面。
|
||||||
|
/// </summary>
|
||||||
|
LegalPersonIdFront = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人身份证背面。
|
||||||
|
/// </summary>
|
||||||
|
LegalPersonIdBack = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 卫生许可证。
|
||||||
|
/// </summary>
|
||||||
|
HealthPermit = 5,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消防安全证。
|
||||||
|
/// </summary>
|
||||||
|
FireSafetyCertificate = 6,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 环保许可证。
|
||||||
|
/// </summary>
|
||||||
|
EnvironmentalPermit = 7,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 特种行业许可证。
|
||||||
|
/// </summary>
|
||||||
|
SpecialIndustryPermit = 8,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商标注册证。
|
||||||
|
/// </summary>
|
||||||
|
TrademarkCertificate = 9,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店照片(门头照)。
|
||||||
|
/// </summary>
|
||||||
|
StoreFrontPhoto = 10,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 店内环境照片。
|
||||||
|
/// </summary>
|
||||||
|
StoreInteriorPhoto = 11,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 其他补充资质。
|
/// 其他补充资质。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Tenants.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户创建完成事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 当租户及其相关数据(订阅、认证资料等)创建成功后发布此事件。
|
||||||
|
/// 消费者可据此自动创建关联的商户和证照记录。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TenantCreatedEvent
|
||||||
|
{
|
||||||
|
// 1. 租户基本信息
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
public required long TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户编码。
|
||||||
|
/// </summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户名称。
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户简称。
|
||||||
|
/// </summary>
|
||||||
|
public string? ShortName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人/公司主体名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? LegalEntityName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LOGO 图片地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? LogoUrl { get; init; }
|
||||||
|
|
||||||
|
// 2. 地址信息
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 省份。
|
||||||
|
/// </summary>
|
||||||
|
public string? Province { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 城市。
|
||||||
|
/// </summary>
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 区县。
|
||||||
|
/// </summary>
|
||||||
|
public string? District { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 详细地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? Address { get; init; }
|
||||||
|
|
||||||
|
// 3. 联系信息
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系人姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系人电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系人邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; init; }
|
||||||
|
|
||||||
|
// 4. 认证资料(用于创建商户证照)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业执照号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BusinessLicenseNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业执照图片 URL。
|
||||||
|
/// </summary>
|
||||||
|
public string? BusinessLicenseUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string? LegalPersonName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人身份证号。
|
||||||
|
/// </summary>
|
||||||
|
public string? LegalPersonIdNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人身份证正面 URL。
|
||||||
|
/// </summary>
|
||||||
|
public string? LegalPersonIdFrontUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 法人身份证背面 URL。
|
||||||
|
/// </summary>
|
||||||
|
public string? LegalPersonIdBackUrl { get; init; }
|
||||||
|
|
||||||
|
// 5. 管理员信息
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理员用户 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long AdminUserId { get; init; }
|
||||||
|
|
||||||
|
// 6. 审核状态
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否跳过审核(直接激活)。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSkipApproval { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 事件发生时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Events;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户创建事件消费者。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 当租户创建成功后,自动创建对应的商户和证照记录。
|
||||||
|
/// 实现 Tenant 1:1 Merchant 的业务关系。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TenantCreatedEventConsumer(
|
||||||
|
IMerchantRepository merchantRepository,
|
||||||
|
IIdGenerator idGenerator,
|
||||||
|
ILogger<TenantCreatedEventConsumer> logger) : IConsumer<TenantCreatedEvent>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task Consume(ConsumeContext<TenantCreatedEvent> context)
|
||||||
|
{
|
||||||
|
var message = context.Message;
|
||||||
|
var tenantId = message.TenantId;
|
||||||
|
|
||||||
|
// 1. 幂等检查:是否已存在该租户的商户
|
||||||
|
var existingMerchant = await merchantRepository.FindByTenantIdAsync(tenantId, context.CancellationToken);
|
||||||
|
if (existingMerchant is not null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("租户 {TenantId} 的商户已存在(商户 ID:{MerchantId}),跳过创建", tenantId, existingMerchant.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建商户实体
|
||||||
|
var merchantId = idGenerator.NextId();
|
||||||
|
var merchantStatus = message.IsSkipApproval ? MerchantStatus.Approved : MerchantStatus.Pending;
|
||||||
|
var documentStatus = message.IsSkipApproval ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Pending;
|
||||||
|
|
||||||
|
var merchant = new Merchant
|
||||||
|
{
|
||||||
|
Id = merchantId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
BrandName = message.Name,
|
||||||
|
BrandAlias = message.ShortName,
|
||||||
|
LogoUrl = message.LogoUrl,
|
||||||
|
BusinessLicenseNumber = message.BusinessLicenseNumber,
|
||||||
|
BusinessLicenseImageUrl = message.BusinessLicenseUrl,
|
||||||
|
LegalPerson = message.LegalPersonName,
|
||||||
|
ContactPhone = message.ContactPhone ?? string.Empty,
|
||||||
|
ContactEmail = message.ContactEmail,
|
||||||
|
Province = message.Province,
|
||||||
|
City = message.City,
|
||||||
|
Address = message.Address,
|
||||||
|
Status = merchantStatus,
|
||||||
|
JoinedAt = message.IsSkipApproval ? DateTime.UtcNow : null,
|
||||||
|
ApprovedAt = message.IsSkipApproval ? DateTime.UtcNow : null,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await merchantRepository.AddMerchantAsync(merchant, context.CancellationToken);
|
||||||
|
logger.LogInformation("租户 {TenantId} 的商户 {MerchantId} 创建成功", tenantId, merchantId);
|
||||||
|
|
||||||
|
// 3. 创建证照记录(营业执照)
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.BusinessLicenseUrl))
|
||||||
|
{
|
||||||
|
var businessLicenseDoc = new MerchantDocument
|
||||||
|
{
|
||||||
|
Id = idGenerator.NextId(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
MerchantId = merchantId,
|
||||||
|
DocumentType = MerchantDocumentType.BusinessLicense,
|
||||||
|
Status = documentStatus,
|
||||||
|
FileUrl = message.BusinessLicenseUrl,
|
||||||
|
DocumentNumber = message.BusinessLicenseNumber,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await merchantRepository.AddDocumentAsync(businessLicenseDoc, context.CancellationToken);
|
||||||
|
logger.LogInformation("商户 {MerchantId} 营业执照证照记录创建成功", merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 创建证照记录(法人身份证正面)
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.LegalPersonIdFrontUrl))
|
||||||
|
{
|
||||||
|
var idFrontDoc = new MerchantDocument
|
||||||
|
{
|
||||||
|
Id = idGenerator.NextId(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
MerchantId = merchantId,
|
||||||
|
DocumentType = MerchantDocumentType.LegalPersonIdFront,
|
||||||
|
Status = documentStatus,
|
||||||
|
FileUrl = message.LegalPersonIdFrontUrl,
|
||||||
|
DocumentNumber = message.LegalPersonIdNumber,
|
||||||
|
Remarks = message.LegalPersonName,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await merchantRepository.AddDocumentAsync(idFrontDoc, context.CancellationToken);
|
||||||
|
logger.LogInformation("商户 {MerchantId} 法人身份证正面证照记录创建成功", merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建证照记录(法人身份证背面)
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.LegalPersonIdBackUrl))
|
||||||
|
{
|
||||||
|
var idBackDoc = new MerchantDocument
|
||||||
|
{
|
||||||
|
Id = idGenerator.NextId(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
MerchantId = merchantId,
|
||||||
|
DocumentType = MerchantDocumentType.LegalPersonIdBack,
|
||||||
|
Status = documentStatus,
|
||||||
|
FileUrl = message.LegalPersonIdBackUrl,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await merchantRepository.AddDocumentAsync(idBackDoc, context.CancellationToken);
|
||||||
|
logger.LogInformation("商户 {MerchantId} 法人身份证背面证照记录创建成功", merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 持久化所有变更
|
||||||
|
await merchantRepository.SaveChangesAsync(context.CancellationToken);
|
||||||
|
logger.LogInformation("租户 {TenantId} 的商户 {MerchantId} 及证照记录全部创建完成", tenantId, merchantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using MassTransit;
|
using MassTransit;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Consumers;
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||||
using TakeoutSaaS.Infrastructure.Logs.Consumers;
|
using TakeoutSaaS.Infrastructure.Logs.Consumers;
|
||||||
using TakeoutSaaS.Module.Messaging.Options;
|
using TakeoutSaaS.Module.Messaging.Options;
|
||||||
@@ -31,6 +32,7 @@ public static class OperationLogOutboxServiceCollectionExtensions
|
|||||||
services.AddMassTransit(configurator =>
|
services.AddMassTransit(configurator =>
|
||||||
{
|
{
|
||||||
configurator.AddConsumer<IdentityUserOperationLogConsumer>();
|
configurator.AddConsumer<IdentityUserOperationLogConsumer>();
|
||||||
|
configurator.AddConsumer<TenantCreatedEventConsumer>();
|
||||||
configurator.AddEntityFrameworkOutbox<IdentityDbContext>(outbox =>
|
configurator.AddEntityFrameworkOutbox<IdentityDbContext>(outbox =>
|
||||||
{
|
{
|
||||||
outbox.UsePostgres();
|
outbox.UsePostgres();
|
||||||
|
|||||||
Reference in New Issue
Block a user