feat: 完善手动创建租户功能,添加账单明细和支付记录
1. 手动创建租户时自动生成账单明细(LineItemsJson) 2. 账单状态为已支付时自动创建支付记录 3. 租户列表接口返回联系人信息和认证状态 4. 账单详情接口返回支付记录和解析后的账单明细 5. 管理员账号自动复用租户联系人信息 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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. 返回分页结果
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user