feat: 完善手动创建租户功能,添加账单明细和支付记录

1. 手动创建租户时自动生成账单明细(LineItemsJson)
2. 账单状态为已支付时自动创建支付记录
3. 租户列表接口返回联系人信息和认证状态
4. 账单详情接口返回支付记录和解析后的账单明细
5. 管理员账号自动复用租户联系人信息

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-02 17:32:05 +08:00
parent b4a597fe08
commit 59bc3005af
15 changed files with 1513 additions and 6 deletions

View File

@@ -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<PagedResult<TenantListItemDto>>.Ok(result);
}
/// <summary>
/// 后台手动新增租户(直接入驻:创建租户 + 认证 + 订阅 + 管理员账号)。
/// </summary>
/// <param name="command">新增租户命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户详情。</returns>
[HttpPost("manual")]
[PermissionAuthorize("tenant:create")]
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<ApiResponse<TenantDetailDto>> CreateManually(
[FromBody] CreateTenantManuallyCommand command,
CancellationToken cancellationToken = default)
{
// 1. 执行命令
var result = await mediator.Send(command, cancellationToken);
// 2. 返回租户详情
return ApiResponse<TenantDetailDto>.Ok(result);
}
/// <summary>
/// 获取租户详情(包含认证、订阅、套餐信息)。
/// </summary>
@@ -76,6 +99,60 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
return Ok(ApiResponse<TenantDetailDto>.Ok(result));
}
/// <summary>
/// 更新租户基本信息。
/// </summary>
/// <param name="id">租户 ID雪花算法。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>无内容。</returns>
[HttpPut("{id:long}")]
[PermissionAuthorize("tenant:update")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> 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<object>.Ok(null, "更新成功");
}
/// <summary>
/// 更新租户认证信息。
/// </summary>
/// <param name="id">租户 ID雪花算法。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>无内容。</returns>
[HttpPut("{id:long}/verification")]
[PermissionAuthorize("tenant:update")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> 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<object>.Ok(null, "更新成功");
}
/// <summary>
/// 获取租户配额使用情况。
/// </summary>

View File

@@ -86,6 +86,11 @@ public sealed record BillingDetailDto
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 账单明细列表(从 LineItemsJson 解析)。
/// </summary>
public IReadOnlyList<BillingLineItemDto>? LineItems { get; init; }
/// <summary>
/// 备注。
/// </summary>
@@ -195,3 +200,34 @@ public sealed record PaymentRecordDto
/// </summary>
public DateTime CreatedAt { get; init; }
}
/// <summary>
/// 账单明细项 DTO。
/// </summary>
public sealed record BillingLineItemDto
{
/// <summary>
/// 项目名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -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<BillingLineItemDto>? lineItems = null;
if (!string.IsNullOrEmpty(detail.LineItemsJson))
{
try
{
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(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
};
}
}

View File

@@ -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;
/// <summary>
/// 后台手动新增租户命令(直接入驻:创建租户 + 认证 + 订阅 + 管理员账号)。
/// </summary>
public sealed record CreateTenantManuallyCommand : IRequest<TenantDetailDto>
{
// 1. 租户基本信息public.tenants
/// <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>
/// 所属行业。
/// </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>
/// 租户状态。
/// </summary>
public TenantStatus TenantStatus { get; init; } = TenantStatus.Active;
// 2. 订阅信息public.tenant_subscriptions
/// <summary>
/// 套餐 ID雪花算法字符串传输
/// </summary>
public required string TenantPackageId { get; init; }
/// <summary>
/// 订阅时长(月)。
/// </summary>
public int DurationMonths { get; init; } = 12;
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 订阅生效时间。
/// </summary>
public DateTime? SubscriptionEffectiveFrom { get; init; }
/// <summary>
/// 下次计费日期。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 订阅状态0=待激活, 1=生效中, 2=宽限期, 3=已取消, 4=已暂停)。
/// </summary>
public SubscriptionStatus SubscriptionStatus { get; init; } = SubscriptionStatus.Active;
/// <summary>
/// 预约变更的套餐 ID。
/// </summary>
public string? ScheduledPackageId { get; init; }
/// <summary>
/// 订阅备注。
/// </summary>
public string? SubscriptionNotes { get; init; }
// 3. 认证信息public.tenant_verification_profiles
/// <summary>
/// 认证状态。
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Approved;
/// <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; }
/// <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>
/// 审核人姓名。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
// 4. 管理员账号identity.identity_users
/// <summary>
/// 管理员登录账号。
/// </summary>
public required string AdminAccount { get; init; }
/// <summary>
/// 管理员显示名称。
/// </summary>
public required string AdminDisplayName { get; init; }
/// <summary>
/// 管理员密码(明文,后端哈希)。
/// </summary>
public required string AdminPassword { get; init; }
/// <summary>
/// 管理员头像。
/// </summary>
public string? AdminAvatar { get; init; }
/// <summary>
/// 管理员所属商户 ID。
/// </summary>
public string? AdminMerchantId { get; init; }
// 5. 账单信息public.tenant_billing_statements
/// <summary>
/// 是否创建账单。
/// </summary>
public bool CreateBilling { get; init; } = true;
/// <summary>
/// 账单金额(不填则根据套餐价格×月数计算)。
/// </summary>
public decimal? BillingAmount { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal? BillingDiscountAmount { get; init; }
/// <summary>
/// 账单状态(默认已支付)。
/// </summary>
public TenantBillingStatus BillingStatus { get; init; } = TenantBillingStatus.Paid;
/// <summary>
/// 账单备注。
/// </summary>
public string? BillingNotes { get; init; }
}

View File

@@ -0,0 +1,94 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 更新租户基本信息命令。
/// </summary>
public sealed record UpdateTenantCommand : IRequest
{
/// <summary>
/// 租户 ID雪花算法字符串传输
/// </summary>
public required string TenantId { 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>
/// 所属行业。
/// </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; }
}

View File

@@ -0,0 +1,75 @@
using MediatR;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 更新租户认证信息命令。
/// </summary>
public sealed record UpdateTenantVerificationCommand : IRequest
{
/// <summary>
/// 租户 ID雪花算法字符串传输
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// 认证状态。
/// </summary>
public TenantVerificationStatus? VerificationStatus { get; init; }
/// <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; }
/// <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>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
}

View File

@@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户列表项 DTO(用于下拉选择器)
/// 租户列表项 DTO。
/// </summary>
public sealed class TenantListItemDto
{
@@ -30,8 +30,28 @@ public sealed class TenantListItemDto
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 联系人姓名。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系人电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 租户状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 认证状态。
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; }
/// <summary>
/// 服务到期时间。
/// </summary>
public DateTime? EffectiveTo { get; init; }
}

View File

@@ -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;
/// <summary>
/// 后台手动新增租户命令处理器。
/// </summary>
/// <remarks>
/// 该操作涉及跨数据库App + Identity写入采用 Saga 补偿模式保证数据一致性:
/// 1. 先在 Identity 库创建管理员账号
/// 2. 再在 App 库创建租户、订阅、认证资料
/// 3. 如果步骤 2 失败,回滚步骤 1删除管理员账号
/// </remarks>
public sealed class CreateTenantManuallyCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository tenantPackageRepository,
IIdentityUserRepository identityUserRepository,
IPasswordHasher<IdentityUser> passwordHasher,
ICurrentUserAccessor currentUserAccessor,
IIdGenerator idGenerator,
IMediator mediator,
ILogger<CreateTenantManuallyCommandHandler> logger)
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
{
/// <inheritdoc />
public async Task<TenantDetailDto> 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, "创建租户成功但查询详情失败");
}
/// <summary>
/// 根据套餐配额创建配额使用记录列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="package">套餐实体。</param>
/// <returns>配额使用记录列表。</returns>
private List<TenantQuotaUsage> CreateQuotaUsagesFromPackage(long tenantId, TenantPackage package)
{
var quotaUsages = new List<TenantQuotaUsage>();
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;
}
/// <summary>
/// 根据订阅信息创建账单实体。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="package">套餐实体。</param>
/// <param name="quotaUsages">配额使用记录列表。</param>
/// <param name="periodStart">账单周期开始时间。</param>
/// <param name="periodEnd">账单周期结束时间。</param>
/// <param name="durationMonths">订阅时长(月)。</param>
/// <param name="billingAmount">指定账单金额(可选)。</param>
/// <param name="discountAmount">折扣金额。</param>
/// <param name="status">账单状态。</param>
/// <param name="notes">账单备注。</param>
/// <returns>账单实体。</returns>
private TenantBillingStatement CreateBillingFromSubscription(
long tenantId,
long subscriptionId,
TenantPackage package,
IReadOnlyList<TenantQuotaUsage> quotaUsages,
DateTime periodStart,
DateTime periodEnd,
int durationMonths,
decimal? billingAmount,
decimal discountAmount,
TenantBillingStatus status,
string? notes)
{
// 1. 构建账单明细列表
var lineItems = new List<BillingLineItem>();
// 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
};
}
/// <summary>
/// 根据账单创建支付记录。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="billing">账单实体。</param>
/// <returns>支付记录实体。</returns>
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
};
}
}
/// <summary>
/// 账单明细项。
/// </summary>
internal sealed class BillingLineItem
{
/// <summary>
/// 项目名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 数量。
/// </summary>
public int Quantity { get; init; }
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -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. 返回分页结果

View File

@@ -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;
/// <summary>
/// 更新租户基本信息命令处理器。
/// </summary>
public sealed class UpdateTenantCommandHandler(ITenantRepository tenantRepository)
: IRequestHandler<UpdateTenantCommand>
{
/// <inheritdoc />
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);
}
}

View File

@@ -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;
/// <summary>
/// 更新租户认证信息命令处理器。
/// </summary>
public sealed class UpdateTenantVerificationCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<UpdateTenantVerificationCommand>
{
/// <inheritdoc />
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);
}
}

View File

@@ -78,6 +78,14 @@ public interface IBillingRepository
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default);
/// <summary>
/// 获取账单的支付记录列表。
/// </summary>
/// <param name="billingStatementId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录列表。</returns>
Task<IReadOnlyList<TenantPayment>> GetPaymentsByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default);
}
/// <summary>

View File

@@ -4,7 +4,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// <summary>
/// 租户只读仓储AdminApi 使用)。
/// 租户仓储AdminApi 使用)。
/// </summary>
public interface ITenantRepository
{
@@ -24,6 +24,15 @@ public interface ITenantRepository
/// <returns>租户列表(仅返回找到的租户)。</returns>
Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default);
/// <summary>
/// 根据编码查询租户是否存在。
/// </summary>
/// <param name="code">租户编码。</param>
/// <param name="excludeTenantId">排除的租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
Task<bool> ExistsByCodeAsync(string code, long? excludeTenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// 获取所有租户列表(用于下拉选择器)。
/// </summary>
@@ -61,6 +70,73 @@ public interface ITenantRepository
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 在事务中批量创建租户及相关数据。
/// </summary>
/// <param name="tenant">租户实体。</param>
/// <param name="subscription">订阅实体。</param>
/// <param name="verification">认证资料实体。</param>
/// <param name="quotaUsages">配额使用记录列表。</param>
/// <param name="billing">账单实体(可选)。</param>
/// <param name="payment">支付记录实体(可选)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task CreateTenantWithRelatedDataAsync(
Tenant tenant,
TenantSubscription subscription,
TenantVerificationProfile verification,
IReadOnlyList<TenantQuotaUsage> quotaUsages,
TenantBillingStatement? billing,
TenantPayment? payment,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取租户(用于更新,带跟踪)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户实体,不存在则返回 null。</returns>
Task<Tenant?> GetByIdForUpdateAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据租户 ID 获取认证资料(用于更新,带跟踪)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>认证资料实体,不存在则返回 null。</returns>
Task<TenantVerificationProfile?> GetVerificationForUpdateAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增租户。
/// </summary>
/// <param name="tenant">租户实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default);
/// <summary>
/// 新增租户订阅。
/// </summary>
/// <param name="subscription">订阅实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
/// <summary>
/// 新增租户认证资料。
/// </summary>
/// <param name="verification">认证资料实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddVerificationAsync(TenantVerificationProfile verification, CancellationToken cancellationToken = default);
/// <summary>
/// 保存仓储变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>

View File

@@ -238,4 +238,15 @@ public sealed class EfBillingRepository(TakeoutAdminDbContext context) : IBillin
// 1. 添加支付记录
await context.TenantPayments.AddAsync(payment, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> 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);
}
}

View File

@@ -7,7 +7,7 @@ using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户只读仓储实现AdminApi 使用)。
/// 租户仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantRepository
{
@@ -36,6 +36,24 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<bool> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Tenant>> GetAllAsync(string? keyword, CancellationToken cancellationToken = default)
{
@@ -123,4 +141,99 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR
// 4. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task CreateTenantWithRelatedDataAsync(
Tenant tenant,
TenantSubscription subscription,
TenantVerificationProfile verification,
IReadOnlyList<TenantQuotaUsage> 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);
});
}
/// <inheritdoc />
public Task<Tenant?> GetByIdForUpdateAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 带跟踪查询租户(用于更新)
return context.Tenants
.Where(x => x.Id == tenantId && x.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantVerificationProfile?> GetVerificationForUpdateAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 带跟踪查询认证资料(取最新一条,用于更新)
return context.TenantVerificationProfiles
.Where(v => v.TenantId == tenantId && v.DeletedAt == null)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
// 1. 新增租户
await context.Tenants.AddAsync(tenant, cancellationToken);
}
/// <inheritdoc />
public async Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
// 1. 新增订阅
await context.TenantSubscriptions.AddAsync(subscription, cancellationToken);
}
/// <inheritdoc />
public async Task AddVerificationAsync(TenantVerificationProfile verification, CancellationToken cancellationToken = default)
{
// 1. 新增认证资料
await context.TenantVerificationProfiles.AddAsync(verification, cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
return context.SaveChangesAsync(cancellationToken);
}
}