diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 13c7a97..60835e6 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Contracts; using TakeoutSaaS.Application.App.Tenants.Queries; using TakeoutSaaS.Module.Authorization.Attributes; @@ -49,6 +50,28 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse>.Ok(result); } + /// + /// 后台手动新增租户(直接入驻:创建租户 + 认证 + 订阅 + 管理员账号)。 + /// + /// 新增租户命令。 + /// 取消标记。 + /// 租户详情。 + [HttpPost("manual")] + [PermissionAuthorize("tenant:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task> CreateManually( + [FromBody] CreateTenantManuallyCommand command, + CancellationToken cancellationToken = default) + { + // 1. 执行命令 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回租户详情 + return ApiResponse.Ok(result); + } + /// /// 获取租户详情(包含认证、订阅、套餐信息)。 /// @@ -76,6 +99,60 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return Ok(ApiResponse.Ok(result)); } + /// + /// 更新租户基本信息。 + /// + /// 租户 ID(雪花算法)。 + /// 更新命令。 + /// 取消标记。 + /// 无内容。 + [HttpPut("{id:long}")] + [PermissionAuthorize("tenant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update( + long id, + [FromBody] UpdateTenantCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路径参数与请求体一致 + var updatedCommand = command with { TenantId = id.ToString() }; + + // 2. 执行命令 + await mediator.Send(updatedCommand, cancellationToken); + + // 3. 返回成功 + return ApiResponse.Ok(null, "更新成功"); + } + + /// + /// 更新租户认证信息。 + /// + /// 租户 ID(雪花算法)。 + /// 更新命令。 + /// 取消标记。 + /// 无内容。 + [HttpPut("{id:long}/verification")] + [PermissionAuthorize("tenant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateVerification( + long id, + [FromBody] UpdateTenantVerificationCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路径参数与请求体一致 + var updatedCommand = command with { TenantId = id.ToString() }; + + // 2. 执行命令 + await mediator.Send(updatedCommand, cancellationToken); + + // 3. 返回成功 + return ApiResponse.Ok(null, "更新成功"); + } + /// /// 获取租户配额使用情况。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs index b823afd..c4f11eb 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs @@ -86,6 +86,11 @@ public sealed record BillingDetailDto /// public string? LineItemsJson { get; init; } + /// + /// 账单明细列表(从 LineItemsJson 解析)。 + /// + public IReadOnlyList? LineItems { get; init; } + /// /// 备注。 /// @@ -195,3 +200,34 @@ public sealed record PaymentRecordDto /// public DateTime CreatedAt { get; init; } } + +/// +/// 账单明细项 DTO。 +/// +public sealed record BillingLineItemDto +{ + /// + /// 项目名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs index 396bb1c..fab93ca 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using MediatR; using TakeoutSaaS.Application.App.Billings.Contracts; using TakeoutSaaS.Application.App.Billings.Queries; @@ -25,7 +26,43 @@ public sealed class GetBillingDetailQueryHandler(IBillingRepository billingRepos return null; } - // 3. 映射为 DTO 并返回(支付记录暂时返回空列表) + // 3. 查询支付记录 + var payments = await billingRepository.GetPaymentsByBillingIdAsync(request.BillingId, cancellationToken); + + // 4. 映射支付记录为 DTO + var paymentDtos = payments.Select(p => new PaymentRecordDto + { + Id = p.Id, + BillingStatementId = p.BillingStatementId, + Amount = p.Amount, + Method = (int)p.Method, + Status = (int)p.Status, + TransactionNo = p.TransactionNo, + ProofUrl = p.ProofUrl, + PaidAt = p.PaidAt, + Notes = p.Notes, + VerifiedBy = p.VerifiedBy, + VerifiedAt = p.VerifiedAt, + RefundReason = p.RefundReason, + RefundedAt = p.RefundedAt, + CreatedAt = p.CreatedAt + }).ToList(); + + // 5. 解析账单明细 JSON + List? lineItems = null; + if (!string.IsNullOrEmpty(detail.LineItemsJson)) + { + try + { + lineItems = JsonSerializer.Deserialize>(detail.LineItemsJson); + } + catch + { + // 解析失败时忽略 + } + } + + // 6. 映射为 DTO 并返回 return new BillingDetailDto { Id = detail.Id, @@ -43,12 +80,13 @@ public sealed class GetBillingDetailQueryHandler(IBillingRepository billingRepos Status = detail.Status, DueDate = detail.DueDate, LineItemsJson = detail.LineItemsJson, + LineItems = lineItems, Notes = detail.Notes, OverdueNotifiedAt = detail.OverdueNotifiedAt, ReminderSentAt = detail.ReminderSentAt, CreatedAt = detail.CreatedAt, UpdatedAt = detail.UpdatedAt, - Payments = [] + Payments = paymentDtos }; } } 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..69b404e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantManuallyCommand.cs @@ -0,0 +1,277 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 后台手动新增租户命令(直接入驻:创建租户 + 认证 + 订阅 + 管理员账号)。 +/// +public sealed record CreateTenantManuallyCommand : IRequest +{ + // 1. 租户基本信息(public.tenants) + + /// + /// 租户编码(唯一)。 + /// + public required string Code { get; init; } + + /// + /// 租户名称。 + /// + public required string Name { get; init; } + + /// + /// 租户简称。 + /// + 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; } + + /// + /// 租户状态。 + /// + public TenantStatus TenantStatus { get; init; } = TenantStatus.Active; + + // 2. 订阅信息(public.tenant_subscriptions) + + /// + /// 套餐 ID(雪花算法,字符串传输)。 + /// + public required string TenantPackageId { get; init; } + + /// + /// 订阅时长(月)。 + /// + public int DurationMonths { get; init; } = 12; + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } + + /// + /// 订阅生效时间。 + /// + public DateTime? SubscriptionEffectiveFrom { get; init; } + + /// + /// 下次计费日期。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 订阅状态(0=待激活, 1=生效中, 2=宽限期, 3=已取消, 4=已暂停)。 + /// + public SubscriptionStatus SubscriptionStatus { get; init; } = SubscriptionStatus.Active; + + /// + /// 预约变更的套餐 ID。 + /// + public string? ScheduledPackageId { get; init; } + + /// + /// 订阅备注。 + /// + public string? SubscriptionNotes { get; init; } + + // 3. 认证信息(public.tenant_verification_profiles) + + /// + /// 认证状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Approved; + + /// + /// 营业执照号。 + /// + 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; } + + /// + /// 银行账户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户银行。 + /// + public string? BankName { get; init; } + + /// + /// 附加数据 JSON。 + /// + public string? AdditionalDataJson { get; init; } + + /// + /// 审核人姓名。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } + + // 4. 管理员账号(identity.identity_users) + + /// + /// 管理员登录账号。 + /// + public required string AdminAccount { get; init; } + + /// + /// 管理员显示名称。 + /// + public required string AdminDisplayName { get; init; } + + /// + /// 管理员密码(明文,后端哈希)。 + /// + public required string AdminPassword { get; init; } + + /// + /// 管理员头像。 + /// + public string? AdminAvatar { get; init; } + + /// + /// 管理员所属商户 ID。 + /// + public string? AdminMerchantId { get; init; } + + // 5. 账单信息(public.tenant_billing_statements) + + /// + /// 是否创建账单。 + /// + public bool CreateBilling { get; init; } = true; + + /// + /// 账单金额(不填则根据套餐价格×月数计算)。 + /// + public decimal? BillingAmount { get; init; } + + /// + /// 折扣金额。 + /// + public decimal? BillingDiscountAmount { get; init; } + + /// + /// 账单状态(默认已支付)。 + /// + public TenantBillingStatus BillingStatus { get; init; } = TenantBillingStatus.Paid; + + /// + /// 账单备注。 + /// + public string? BillingNotes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantCommand.cs new file mode 100644 index 0000000..0d50ae8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantCommand.cs @@ -0,0 +1,94 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户基本信息命令。 +/// +public sealed record UpdateTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法,字符串传输)。 + /// + public required string TenantId { get; init; } + + /// + /// 租户名称。 + /// + public required string Name { get; init; } + + /// + /// 租户简称。 + /// + 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; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantVerificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantVerificationCommand.cs new file mode 100644 index 0000000..82b4252 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantVerificationCommand.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户认证信息命令。 +/// +public sealed record UpdateTenantVerificationCommand : IRequest +{ + /// + /// 租户 ID(雪花算法,字符串传输)。 + /// + public required string TenantId { get; init; } + + /// + /// 认证状态。 + /// + public TenantVerificationStatus? VerificationStatus { get; init; } + + /// + /// 营业执照号。 + /// + 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; } + + /// + /// 银行账户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户银行。 + /// + public string? BankName { get; init; } + + /// + /// 附加数据 JSON。 + /// + public string? AdditionalDataJson { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantListItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantListItemDto.cs index 7500979..791b8ab 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantListItemDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantListItemDto.cs @@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.App.Tenants.Contracts; /// -/// 租户列表项 DTO(用于下拉选择器)。 +/// 租户列表项 DTO。 /// public sealed class TenantListItemDto { @@ -30,8 +30,28 @@ public sealed class TenantListItemDto /// public string? ShortName { get; init; } + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + /// /// 租户状态。 /// public TenantStatus Status { get; init; } + + /// + /// 认证状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } + + /// + /// 服务到期时间。 + /// + public DateTime? EffectiveTo { 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..7ecf1f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs @@ -0,0 +1,562 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Contracts; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Billings.Entities; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Enums; +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; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 后台手动新增租户命令处理器。 +/// +/// +/// 该操作涉及跨数据库(App + Identity)写入,采用 Saga 补偿模式保证数据一致性: +/// 1. 先在 Identity 库创建管理员账号 +/// 2. 再在 App 库创建租户、订阅、认证资料 +/// 3. 如果步骤 2 失败,回滚步骤 1(删除管理员账号) +/// +public sealed class CreateTenantManuallyCommandHandler( + ITenantRepository tenantRepository, + ITenantPackageRepository tenantPackageRepository, + IIdentityUserRepository identityUserRepository, + IPasswordHasher passwordHasher, + ICurrentUserAccessor currentUserAccessor, + IIdGenerator idGenerator, + IMediator mediator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateTenantManuallyCommand request, CancellationToken cancellationToken) + { + // 1. 规范化输入 + var code = request.Code.Trim(); + var name = request.Name.Trim(); + var adminAccount = request.AdminAccount.Trim(); + var adminDisplayName = request.AdminDisplayName.Trim(); + + // 2. 解析套餐 ID + if (!long.TryParse(request.TenantPackageId, out var tenantPackageId) || tenantPackageId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐 ID 无效"); + } + + // 3. 解析可选的预约套餐 ID + long? scheduledPackageId = null; + if (!string.IsNullOrWhiteSpace(request.ScheduledPackageId)) + { + if (!long.TryParse(request.ScheduledPackageId, out var parsedScheduledId) || parsedScheduledId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "预约套餐 ID 无效"); + } + + scheduledPackageId = parsedScheduledId; + } + + // 4. 解析可选的管理员商户 ID + long? adminMerchantId = null; + if (!string.IsNullOrWhiteSpace(request.AdminMerchantId)) + { + if (!long.TryParse(request.AdminMerchantId, out var parsedMerchantId) || parsedMerchantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "管理员商户 ID 无效"); + } + + adminMerchantId = parsedMerchantId; + } + + // 5. 校验租户编码唯一性 + if (await tenantRepository.ExistsByCodeAsync(code, null, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, "租户编码已存在"); + } + + // 6. 校验套餐存在性 + var package = await tenantPackageRepository.GetByIdAsync(tenantPackageId, cancellationToken); + if (package is null) + { + throw new BusinessException(ErrorCodes.NotFound, "套餐不存在"); + } + + // 7. 生成租户 ID + var tenantId = idGenerator.NextId(); + + // 8. 校验管理员账号唯一性(租户内) + if (await identityUserRepository.ExistsByAccountAsync(PortalType.Tenant, tenantId, adminAccount, null, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, "管理员账号已存在"); + } + + // 9. 计算订阅时间 + var effectiveFrom = request.SubscriptionEffectiveFrom ?? DateTime.UtcNow; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + var nextBillingDate = request.NextBillingDate ?? effectiveTo; + + // 10. 创建管理员账号实体 + var adminUser = new IdentityUser + { + Id = idGenerator.NextId(), + Portal = PortalType.Tenant, + TenantId = tenantId, + Account = adminAccount, + DisplayName = adminDisplayName, + PasswordHash = string.Empty, + Phone = request.ContactPhone?.Trim(), + Email = request.ContactEmail?.Trim(), + Status = IdentityUserStatus.Active, + FailedLoginCount = 0, + LockedUntil = null, + LastLoginAt = null, + MustChangePassword = false, + MerchantId = adminMerchantId, + Avatar = request.AdminAvatar?.Trim() + }; + adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword); + + // 11. 【Saga 步骤 1】先在 Identity 库创建管理员账号 + await identityUserRepository.AddAsync(adminUser, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("租户 {TenantId} 管理员账号 {AdminUserId} 创建成功", tenantId, adminUser.Id); + + try + { + // 12. 创建租户实体 + var tenant = new Tenant + { + Id = tenantId, + Code = code, + Name = name, + ShortName = request.ShortName?.Trim(), + LegalEntityName = request.LegalEntityName?.Trim(), + Industry = request.Industry?.Trim(), + LogoUrl = request.LogoUrl?.Trim(), + CoverImageUrl = request.CoverImageUrl?.Trim(), + Website = request.Website?.Trim(), + Country = request.Country?.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(), + Tags = request.Tags?.Trim(), + Remarks = request.Remarks?.Trim(), + Status = request.TenantStatus, + SuspendedAt = request.SuspendedAt, + SuspensionReason = request.SuspensionReason?.Trim(), + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + PrimaryOwnerUserId = adminUser.Id + }; + + // 13. 创建订阅实体 + var subscription = new TenantSubscription + { + Id = idGenerator.NextId(), + TenantId = tenantId, + TenantPackageId = tenantPackageId, + Status = request.SubscriptionStatus, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = nextBillingDate, + AutoRenew = request.AutoRenew, + ScheduledPackageId = scheduledPackageId, + Notes = request.SubscriptionNotes?.Trim() + }; + + // 14. 创建认证资料实体 + var verification = new TenantVerificationProfile + { + Id = idGenerator.NextId(), + TenantId = tenantId, + Status = request.VerificationStatus, + BusinessLicenseNumber = request.BusinessLicenseNumber?.Trim(), + BusinessLicenseUrl = request.BusinessLicenseUrl?.Trim(), + LegalPersonName = request.LegalPersonName?.Trim(), + LegalPersonIdNumber = request.LegalPersonIdNumber?.Trim(), + LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl?.Trim(), + LegalPersonIdBackUrl = request.LegalPersonIdBackUrl?.Trim(), + BankAccountName = request.BankAccountName?.Trim(), + BankAccountNumber = request.BankAccountNumber?.Trim(), + BankName = request.BankName?.Trim(), + AdditionalDataJson = request.AdditionalDataJson?.Trim(), + SubmittedAt = DateTime.UtcNow, + ReviewedAt = request.VerificationStatus == TenantVerificationStatus.Approved ? DateTime.UtcNow : null, + ReviewedBy = request.VerificationStatus == TenantVerificationStatus.Approved ? currentUserAccessor.UserId : null, + ReviewedByName = request.ReviewedByName?.Trim(), + ReviewRemarks = request.ReviewRemarks?.Trim() + }; + + // 15. 根据套餐配额创建配额使用记录 + var quotaUsages = CreateQuotaUsagesFromPackage(tenantId, package); + + // 16. 创建账单记录和支付记录(可选) + TenantBillingStatement? billing = null; + TenantPayment? payment = null; + if (request.CreateBilling) + { + billing = CreateBillingFromSubscription( + tenantId, + subscription.Id, + package, + quotaUsages, + effectiveFrom, + effectiveTo, + request.DurationMonths, + request.BillingAmount, + request.BillingDiscountAmount ?? 0, + request.BillingStatus, + request.BillingNotes); + + // 17. 如果账单状态为已支付,创建支付记录 + if (request.BillingStatus == TenantBillingStatus.Paid && billing.AmountDue > 0) + { + payment = CreatePaymentFromBilling(tenantId, billing); + } + } + + // 18. 【Saga 步骤 2】在 App 库创建租户、订阅、认证资料、配额使用记录、账单、支付记录(使用事务) + await tenantRepository.CreateTenantWithRelatedDataAsync(tenant, subscription, verification, quotaUsages, billing, payment, cancellationToken); + logger.LogInformation("租户 {TenantId} 及相关数据创建成功", tenantId); + } + catch (Exception ex) + { + // 16. 【Saga 补偿】App 库操作失败,回滚 Identity 库的管理员账号 + // 记录完整异常信息(包括内部异常) + var fullErrorMessage = ex.InnerException?.Message ?? ex.Message; + logger.LogError(ex, "租户 {TenantId} 创建失败,错误详情:{ErrorDetail},开始回滚管理员账号 {AdminUserId}", + tenantId, fullErrorMessage, adminUser.Id); + + try + { + await identityUserRepository.RemoveAsync(adminUser, cancellationToken); + await identityUserRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("管理员账号 {AdminUserId} 回滚成功", adminUser.Id); + } + catch (Exception rollbackEx) + { + // 17. 补偿失败,记录严重错误(需要人工介入) + logger.LogCritical( + rollbackEx, + "严重:租户 {TenantId} 创建失败且管理员账号 {AdminUserId} 回滚失败,需要人工介入清理", + tenantId, + adminUser.Id); + } + + // 18. 重新抛出业务异常(包含详细错误信息) + throw new BusinessException(ErrorCodes.InternalServerError, $"创建租户失败:{fullErrorMessage}"); + } + + // 19. 查询并返回租户详情 + var detail = await mediator.Send(new GetTenantDetailQuery { TenantId = tenantId }, cancellationToken); + return detail ?? throw new BusinessException(ErrorCodes.InternalServerError, "创建租户成功但查询详情失败"); + } + + /// + /// 根据套餐配额创建配额使用记录列表。 + /// + /// 租户 ID。 + /// 套餐实体。 + /// 配额使用记录列表。 + private List CreateQuotaUsagesFromPackage(long tenantId, TenantPackage package) + { + var quotaUsages = new List(); + var now = DateTime.UtcNow; + + // 1. 门店数量配额 + if (package.MaxStoreCount.HasValue) + { + quotaUsages.Add(new TenantQuotaUsage + { + Id = idGenerator.NextId(), + TenantId = tenantId, + QuotaType = TenantQuotaType.Store, + LimitValue = package.MaxStoreCount.Value, + UsedValue = 0, + ResetCycle = null, + LastResetAt = now + }); + } + + // 2. 账号数量配额 + if (package.MaxAccountCount.HasValue) + { + quotaUsages.Add(new TenantQuotaUsage + { + Id = idGenerator.NextId(), + TenantId = tenantId, + QuotaType = TenantQuotaType.Account, + LimitValue = package.MaxAccountCount.Value, + UsedValue = 0, + ResetCycle = null, + LastResetAt = now + }); + } + + // 3. 存储空间配额 + if (package.MaxStorageGb.HasValue) + { + quotaUsages.Add(new TenantQuotaUsage + { + Id = idGenerator.NextId(), + TenantId = tenantId, + QuotaType = TenantQuotaType.StorageGb, + LimitValue = package.MaxStorageGb.Value, + UsedValue = 0, + ResetCycle = null, + LastResetAt = now + }); + } + + // 4. 短信额度配额(按月重置) + if (package.MaxSmsCredits.HasValue) + { + quotaUsages.Add(new TenantQuotaUsage + { + Id = idGenerator.NextId(), + TenantId = tenantId, + QuotaType = TenantQuotaType.SmsCredits, + LimitValue = package.MaxSmsCredits.Value, + UsedValue = 0, + ResetCycle = "monthly", + LastResetAt = now + }); + } + + // 5. 配送订单数配额(按月重置) + if (package.MaxDeliveryOrders.HasValue) + { + quotaUsages.Add(new TenantQuotaUsage + { + Id = idGenerator.NextId(), + TenantId = tenantId, + QuotaType = TenantQuotaType.DeliveryOrders, + LimitValue = package.MaxDeliveryOrders.Value, + UsedValue = 0, + ResetCycle = "monthly", + LastResetAt = now + }); + } + + return quotaUsages; + } + + /// + /// 根据订阅信息创建账单实体。 + /// + /// 租户 ID。 + /// 订阅 ID。 + /// 套餐实体。 + /// 配额使用记录列表。 + /// 账单周期开始时间。 + /// 账单周期结束时间。 + /// 订阅时长(月)。 + /// 指定账单金额(可选)。 + /// 折扣金额。 + /// 账单状态。 + /// 账单备注。 + /// 账单实体。 + private TenantBillingStatement CreateBillingFromSubscription( + long tenantId, + long subscriptionId, + TenantPackage package, + IReadOnlyList quotaUsages, + DateTime periodStart, + DateTime periodEnd, + int durationMonths, + decimal? billingAmount, + decimal discountAmount, + TenantBillingStatus status, + string? notes) + { + // 1. 构建账单明细列表 + var lineItems = new List(); + + // 2. 添加套餐费用明细 + decimal packageAmount = 0; + if (durationMonths >= 12 && package.YearlyPrice.HasValue) + { + var years = durationMonths / 12; + var remainingMonths = durationMonths % 12; + packageAmount = package.YearlyPrice.Value * years; + + lineItems.Add(new BillingLineItem + { + Name = $"{package.Name}(年付 × {years}年)", + UnitPrice = package.YearlyPrice.Value, + Quantity = years, + Amount = package.YearlyPrice.Value * years + }); + + if (remainingMonths > 0 && package.MonthlyPrice.HasValue) + { + var monthlyAmount = package.MonthlyPrice.Value * remainingMonths; + packageAmount += monthlyAmount; + lineItems.Add(new BillingLineItem + { + Name = $"{package.Name}(月付 × {remainingMonths}月)", + UnitPrice = package.MonthlyPrice.Value, + Quantity = remainingMonths, + Amount = monthlyAmount + }); + } + } + else if (package.MonthlyPrice.HasValue) + { + packageAmount = package.MonthlyPrice.Value * durationMonths; + lineItems.Add(new BillingLineItem + { + Name = $"{package.Name}(月付 × {durationMonths}月)", + UnitPrice = package.MonthlyPrice.Value, + Quantity = durationMonths, + Amount = packageAmount + }); + } + else + { + lineItems.Add(new BillingLineItem + { + Name = $"{package.Name}(免费套餐)", + UnitPrice = 0, + Quantity = durationMonths, + Amount = 0 + }); + } + + // 3. 添加配额包明细(免费赠送) + foreach (var quota in quotaUsages) + { + var quotaName = quota.QuotaType switch + { + TenantQuotaType.Store => $"门店配额({quota.LimitValue}个)", + TenantQuotaType.Account => $"账号配额({quota.LimitValue}个)", + TenantQuotaType.StorageGb => $"存储空间({quota.LimitValue}GB)", + TenantQuotaType.SmsCredits => $"短信额度({quota.LimitValue}条/月)", + TenantQuotaType.DeliveryOrders => $"配送订单({quota.LimitValue}单/月)", + _ => $"配额({quota.LimitValue})" + }; + + lineItems.Add(new BillingLineItem + { + Name = quotaName, + UnitPrice = 0, + Quantity = 1, + Amount = 0, + Remark = "套餐赠送" + }); + } + + // 4. 添加折扣明细 + if (discountAmount > 0) + { + lineItems.Add(new BillingLineItem + { + Name = "优惠折扣", + UnitPrice = -discountAmount, + Quantity = 1, + Amount = -discountAmount + }); + } + + // 5. 计算账单金额 + decimal amountDue = billingAmount ?? Math.Max(0, packageAmount - discountAmount); + + // 6. 生成账单编号:BILL-{YYYYMMDD}-{ID后8位} + var billingId = idGenerator.NextId(); + var datePart = DateTime.UtcNow.ToString("yyyyMMdd"); + var idSuffix = billingId.ToString().PadLeft(8, '0')[^8..]; + var statementNo = $"BILL-{datePart}-{idSuffix}"; + + // 7. 序列化账单明细为 JSON + var lineItemsJson = System.Text.Json.JsonSerializer.Serialize(lineItems); + + // 8. 创建账单实体 + return new TenantBillingStatement + { + Id = billingId, + TenantId = tenantId, + StatementNo = statementNo, + BillingType = TenantBillingType.Subscription, + PeriodStart = periodStart, + PeriodEnd = periodEnd, + AmountDue = amountDue, + AmountPaid = status == TenantBillingStatus.Paid ? amountDue : 0, + DiscountAmount = discountAmount, + TaxAmount = 0, + Currency = "CNY", + Status = status, + DueDate = periodEnd, + LineItemsJson = lineItemsJson, + Notes = notes?.Trim(), + SubscriptionId = subscriptionId, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// 根据账单创建支付记录。 + /// + /// 租户 ID。 + /// 账单实体。 + /// 支付记录实体。 + private TenantPayment CreatePaymentFromBilling(long tenantId, TenantBillingStatement billing) + { + return new TenantPayment + { + Id = idGenerator.NextId(), + TenantId = tenantId, + BillingStatementId = billing.Id, + Amount = billing.AmountDue, + Method = TenantPaymentMethod.Other, + Status = TenantPaymentStatus.Success, + TransactionNo = $"MANUAL-{billing.StatementNo}", + PaidAt = DateTime.UtcNow, + Notes = "手动创建租户时自动生成", + CreatedAt = DateTime.UtcNow + }; + } +} + +/// +/// 账单明细项。 +/// +internal sealed class BillingLineItem +{ + /// + /// 项目名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ListTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ListTenantsQueryHandler.cs index 672db5a..8db27b8 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ListTenantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ListTenantsQueryHandler.cs @@ -1,6 +1,7 @@ using MediatR; using TakeoutSaaS.Application.App.Tenants.Contracts; using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Shared.Abstractions.Results; @@ -36,7 +37,11 @@ public sealed class ListTenantsQueryHandler(ITenantRepository tenantRepository) Code = t.Code, Name = t.Name, ShortName = t.ShortName, - Status = t.Status + ContactName = t.ContactName, + ContactPhone = t.ContactPhone, + Status = t.Status, + VerificationStatus = TenantVerificationStatus.Approved, // TODO: 从认证表查询 + EffectiveTo = t.EffectiveTo }).ToArray(); // 5. 返回分页结果 diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantCommandHandler.cs new file mode 100644 index 0000000..157bdbb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新租户基本信息命令处理器。 +/// +public sealed class UpdateTenantCommandHandler(ITenantRepository tenantRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken) + { + // 1. 解析租户 ID + if (!long.TryParse(request.TenantId, out var tenantId) || tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "租户 ID 无效"); + } + + // 2. 获取租户(带跟踪) + var tenant = await tenantRepository.GetByIdForUpdateAsync(tenantId, cancellationToken); + if (tenant is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + } + + // 3. 更新租户信息 + tenant.Name = request.Name.Trim(); + tenant.ShortName = request.ShortName?.Trim(); + tenant.LegalEntityName = request.LegalEntityName?.Trim(); + tenant.Industry = request.Industry?.Trim(); + tenant.LogoUrl = request.LogoUrl?.Trim(); + tenant.CoverImageUrl = request.CoverImageUrl?.Trim(); + tenant.Website = request.Website?.Trim(); + tenant.Country = request.Country?.Trim(); + tenant.Province = request.Province?.Trim(); + tenant.City = request.City?.Trim(); + tenant.Address = request.Address?.Trim(); + tenant.ContactName = request.ContactName?.Trim(); + tenant.ContactPhone = request.ContactPhone?.Trim(); + tenant.ContactEmail = request.ContactEmail?.Trim(); + tenant.Tags = request.Tags?.Trim(); + tenant.Remarks = request.Remarks?.Trim(); + + // 4. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantVerificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantVerificationCommandHandler.cs new file mode 100644 index 0000000..545d777 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantVerificationCommandHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新租户认证信息命令处理器。 +/// +public sealed class UpdateTenantVerificationCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(UpdateTenantVerificationCommand request, CancellationToken cancellationToken) + { + // 1. 解析租户 ID + if (!long.TryParse(request.TenantId, out var tenantId) || tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "租户 ID 无效"); + } + + // 2. 获取认证资料(带跟踪) + var verification = await tenantRepository.GetVerificationForUpdateAsync(tenantId, cancellationToken); + if (verification is null) + { + throw new BusinessException(ErrorCodes.NotFound, "租户认证资料不存在"); + } + + // 3. 更新认证信息 + if (request.VerificationStatus.HasValue) + { + verification.Status = request.VerificationStatus.Value; + + // 4. 如果状态变更为已审核,记录审核信息 + if (request.VerificationStatus.Value == TenantVerificationStatus.Approved) + { + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId; + } + } + + verification.BusinessLicenseNumber = request.BusinessLicenseNumber?.Trim(); + verification.BusinessLicenseUrl = request.BusinessLicenseUrl?.Trim(); + verification.LegalPersonName = request.LegalPersonName?.Trim(); + verification.LegalPersonIdNumber = request.LegalPersonIdNumber?.Trim(); + verification.LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl?.Trim(); + verification.LegalPersonIdBackUrl = request.LegalPersonIdBackUrl?.Trim(); + verification.BankAccountName = request.BankAccountName?.Trim(); + verification.BankAccountNumber = request.BankAccountNumber?.Trim(); + verification.BankName = request.BankName?.Trim(); + verification.AdditionalDataJson = request.AdditionalDataJson?.Trim(); + verification.ReviewRemarks = request.ReviewRemarks?.Trim(); + + // 5. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs index 0a6f6cf..18bd217 100644 --- a/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs @@ -78,6 +78,14 @@ public interface IBillingRepository /// 取消标记。 /// 异步操作任务。 Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default); + + /// + /// 获取账单的支付记录列表。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 支付记录列表。 + Task> GetPaymentsByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default); } /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index dd86329..72c84b1 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -4,7 +4,7 @@ using TakeoutSaaS.Domain.Tenants.Entities; namespace TakeoutSaaS.Domain.Tenants.Repositories; /// -/// 租户只读仓储(AdminApi 使用)。 +/// 租户仓储(AdminApi 使用)。 /// public interface ITenantRepository { @@ -24,6 +24,15 @@ public interface ITenantRepository /// 租户列表(仅返回找到的租户)。 Task> FindByIdsAsync(IReadOnlyCollection tenantIds, CancellationToken cancellationToken = default); + /// + /// 根据编码查询租户是否存在。 + /// + /// 租户编码。 + /// 排除的租户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByCodeAsync(string code, long? excludeTenantId = null, CancellationToken cancellationToken = default); + /// /// 获取所有租户列表(用于下拉选择器)。 /// @@ -61,6 +70,73 @@ public interface ITenantRepository int page, int pageSize, CancellationToken cancellationToken = default); + + /// + /// 在事务中批量创建租户及相关数据。 + /// + /// 租户实体。 + /// 订阅实体。 + /// 认证资料实体。 + /// 配额使用记录列表。 + /// 账单实体(可选)。 + /// 支付记录实体(可选)。 + /// 取消标记。 + /// 异步操作任务。 + Task CreateTenantWithRelatedDataAsync( + Tenant tenant, + TenantSubscription subscription, + TenantVerificationProfile verification, + IReadOnlyList quotaUsages, + TenantBillingStatement? billing, + TenantPayment? payment, + CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取租户(用于更新,带跟踪)。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 租户实体,不存在则返回 null。 + Task GetByIdForUpdateAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据租户 ID 获取认证资料(用于更新,带跟踪)。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 认证资料实体,不存在则返回 null。 + Task GetVerificationForUpdateAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增租户。 + /// + /// 租户实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default); + + /// + /// 新增租户订阅。 + /// + /// 订阅实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// 新增租户认证资料。 + /// + /// 认证资料实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddVerificationAsync(TenantVerificationProfile verification, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs index b71f161..c0ffd45 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs @@ -238,4 +238,15 @@ public sealed class EfBillingRepository(TakeoutAdminDbContext context) : IBillin // 1. 添加支付记录 await context.TenantPayments.AddAsync(payment, cancellationToken); } + + /// + public async Task> GetPaymentsByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default) + { + // 1. 查询账单的支付记录(排除已删除,按创建时间排序) + return await context.TenantPayments + .AsNoTracking() + .Where(p => p.BillingStatementId == billingStatementId && p.DeletedAt == null) + .OrderBy(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index f67dee5..ddc0d86 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -7,7 +7,7 @@ using TakeoutSaaS.Infrastructure.App.Persistence; namespace TakeoutSaaS.Infrastructure.App.Repositories; /// -/// 租户只读仓储实现(AdminApi 使用)。 +/// 租户仓储实现(AdminApi 使用)。 /// public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantRepository { @@ -36,6 +36,24 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR .ToListAsync(cancellationToken); } + /// + public async Task ExistsByCodeAsync(string code, long? excludeTenantId = null, CancellationToken cancellationToken = default) + { + // 1. 构建查询 + var query = context.Tenants + .AsNoTracking() + .Where(x => x.Code == code && x.DeletedAt == null); + + // 2. 排除指定租户 + if (excludeTenantId.HasValue) + { + query = query.Where(x => x.Id != excludeTenantId.Value); + } + + // 3. 返回是否存在 + return await query.AnyAsync(cancellationToken); + } + /// public async Task> GetAllAsync(string? keyword, CancellationToken cancellationToken = default) { @@ -123,4 +141,99 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR // 4. 返回结果 return (items, totalCount); } + + /// + public async Task CreateTenantWithRelatedDataAsync( + Tenant tenant, + TenantSubscription subscription, + TenantVerificationProfile verification, + IReadOnlyList quotaUsages, + TenantBillingStatement? billing, + TenantPayment? payment, + CancellationToken cancellationToken = default) + { + // 1. 使用执行策略保证可靠性 + var strategy = context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + // 2. 开启事务 + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + // 3. 批量添加实体 + await context.Tenants.AddAsync(tenant, cancellationToken); + await context.TenantSubscriptions.AddAsync(subscription, cancellationToken); + await context.TenantVerificationProfiles.AddAsync(verification, cancellationToken); + + // 4. 添加配额使用记录 + if (quotaUsages.Count > 0) + { + await context.TenantQuotaUsages.AddRangeAsync(quotaUsages, cancellationToken); + } + + // 5. 添加账单记录 + if (billing is not null) + { + await context.TenantBillingStatements.AddAsync(billing, cancellationToken); + } + + // 6. 添加支付记录 + if (payment is not null) + { + await context.TenantPayments.AddAsync(payment, cancellationToken); + } + + // 7. 保存变更 + await context.SaveChangesAsync(cancellationToken); + + // 8. 提交事务 + await transaction.CommitAsync(cancellationToken); + }); + } + + /// + public Task GetByIdForUpdateAsync(long tenantId, CancellationToken cancellationToken = default) + { + // 1. 带跟踪查询租户(用于更新) + return context.Tenants + .Where(x => x.Id == tenantId && x.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetVerificationForUpdateAsync(long tenantId, CancellationToken cancellationToken = default) + { + // 1. 带跟踪查询认证资料(取最新一条,用于更新) + return context.TenantVerificationProfiles + .Where(v => v.TenantId == tenantId && v.DeletedAt == null) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default) + { + // 1. 新增租户 + await context.Tenants.AddAsync(tenant, cancellationToken); + } + + /// + public async Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default) + { + // 1. 新增订阅 + await context.TenantSubscriptions.AddAsync(subscription, cancellationToken); + } + + /// + public async Task AddVerificationAsync(TenantVerificationProfile verification, CancellationToken cancellationToken = default) + { + // 1. 新增认证资料 + await context.TenantVerificationProfiles.AddAsync(verification, cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + // 1. 保存变更 + return context.SaveChangesAsync(cancellationToken); + } }