diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs index 2ce065d..9f7294a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Contracts; using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Application.Messaging.Abstractions; using TakeoutSaaS.Domain.Billings.Entities; using TakeoutSaaS.Domain.Billings.Enums; using TakeoutSaaS.Domain.Identity.Entities; @@ -11,6 +12,7 @@ using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Events; using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; @@ -38,6 +40,7 @@ public sealed class CreateTenantManuallyCommandHandler( ICurrentUserAccessor currentUserAccessor, IIdGenerator idGenerator, IMediator mediator, + IEventPublisher eventPublisher, ILogger logger) : IRequestHandler { @@ -224,6 +227,34 @@ public sealed class CreateTenantManuallyCommandHandler( // 19. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务) await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken); 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) { diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs index 0dffa2d..1d54ea8 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs @@ -11,7 +11,7 @@ public enum MerchantDocumentType BusinessLicense = 0, /// - /// 餐饮服务许可证。 + /// 餐饮服务许可证(食品经营许可证)。 /// CateringPermit = 1, @@ -20,6 +20,51 @@ public enum MerchantDocumentType /// TaxCertificate = 2, + /// + /// 法人身份证正面。 + /// + LegalPersonIdFront = 3, + + /// + /// 法人身份证背面。 + /// + LegalPersonIdBack = 4, + + /// + /// 卫生许可证。 + /// + HealthPermit = 5, + + /// + /// 消防安全证。 + /// + FireSafetyCertificate = 6, + + /// + /// 环保许可证。 + /// + EnvironmentalPermit = 7, + + /// + /// 特种行业许可证。 + /// + SpecialIndustryPermit = 8, + + /// + /// 商标注册证。 + /// + TrademarkCertificate = 9, + + /// + /// 门店照片(门头照)。 + /// + StoreFrontPhoto = 10, + + /// + /// 店内环境照片。 + /// + StoreInteriorPhoto = 11, + /// /// 其他补充资质。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Events/TenantCreatedEvent.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Events/TenantCreatedEvent.cs new file mode 100644 index 0000000..bb03a0a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Events/TenantCreatedEvent.cs @@ -0,0 +1,133 @@ +namespace TakeoutSaaS.Domain.Tenants.Events; + +/// +/// 租户创建完成事件。 +/// +/// +/// 当租户及其相关数据(订阅、认证资料等)创建成功后发布此事件。 +/// 消费者可据此自动创建关联的商户和证照记录。 +/// +public sealed record TenantCreatedEvent +{ + // 1. 租户基本信息 + + /// + /// 租户 ID。 + /// + public required long TenantId { get; init; } + + /// + /// 租户编码。 + /// + public required string Code { get; init; } + + /// + /// 租户名称。 + /// + public required string Name { get; init; } + + /// + /// 租户简称。 + /// + public string? ShortName { get; init; } + + /// + /// 法人/公司主体名称。 + /// + public string? LegalEntityName { get; init; } + + /// + /// LOGO 图片地址。 + /// + public string? LogoUrl { get; init; } + + // 2. 地址信息 + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } + + // 3. 联系信息 + + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 联系人邮箱。 + /// + public string? ContactEmail { get; init; } + + // 4. 认证资料(用于创建商户证照) + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照图片 URL。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 法人身份证正面 URL。 + /// + public string? LegalPersonIdFrontUrl { get; init; } + + /// + /// 法人身份证背面 URL。 + /// + public string? LegalPersonIdBackUrl { get; init; } + + // 5. 管理员信息 + + /// + /// 管理员用户 ID。 + /// + public long AdminUserId { get; init; } + + // 6. 审核状态 + + /// + /// 是否跳过审核(直接激活)。 + /// + public bool IsSkipApproval { get; init; } + + /// + /// 事件发生时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Consumers/TenantCreatedEventConsumer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Consumers/TenantCreatedEventConsumer.cs new file mode 100644 index 0000000..f726cb8 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Consumers/TenantCreatedEventConsumer.cs @@ -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; + +/// +/// 租户创建事件消费者。 +/// +/// +/// 当租户创建成功后,自动创建对应的商户和证照记录。 +/// 实现 Tenant 1:1 Merchant 的业务关系。 +/// +public sealed class TenantCreatedEventConsumer( + IMerchantRepository merchantRepository, + IIdGenerator idGenerator, + ILogger logger) : IConsumer +{ + /// + public async Task Consume(ConsumeContext 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); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs index 33b452d..2cc763d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Extensions/OperationLogOutboxServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using MassTransit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Infrastructure.App.Consumers; using TakeoutSaaS.Infrastructure.Identity.Persistence; using TakeoutSaaS.Infrastructure.Logs.Consumers; using TakeoutSaaS.Module.Messaging.Options; @@ -31,6 +32,7 @@ public static class OperationLogOutboxServiceCollectionExtensions services.AddMassTransit(configurator => { configurator.AddConsumer(); + configurator.AddConsumer(); configurator.AddEntityFrameworkOutbox(outbox => { outbox.UsePostgres();