feat: 完成账单管理模块后端功能开发及API优化

核心功能:
- 账单CRUD操作(创建、查询、详情、更新状态、删除)
- 支付记录管理(创建支付、审核支付)
- 批量操作支持(批量更新账单状态)
- 统计分析功能(账单统计、逾期账单查询)
- 导出功能(Excel/PDF/CSV)

API端点 (16个):
- GET /api/admin/v1/billings - 账单列表(分页、筛选、排序)
- POST /api/admin/v1/billings - 创建账单
- GET /api/admin/v1/billings/{id} - 账单详情
- DELETE /api/admin/v1/billings/{id} - 删除账单
- PUT /api/admin/v1/billings/{id}/status - 更新状态
- POST /api/admin/v1/billings/batch/status - 批量更新
- GET /api/admin/v1/billings/{id}/payments - 支付记录
- POST /api/admin/v1/billings/{id}/payments - 创建支付
- PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付
- GET /api/admin/v1/billings/statistics - 统计数据
- GET /api/admin/v1/billings/overdue - 逾期账单
- POST /api/admin/v1/billings/export - 导出账单

架构优化:
- 采用CQRS模式分离读写(MediatR + Dapper + EF Core)
- 完整的领域模型设计(TenantBillingStatement, TenantPayment等)
- FluentValidation请求验证
- 状态机管理账单和支付状态流转

API设计优化 (三项改进):
1. 导出API响应Content-Type改为application/octet-stream
2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝
3. 移除TenantBillings API中重复的TenantId参数

数据库变更:
- 新增账单相关表及关系
- 支持Snowflake ID主键
- 完整的审计字段支持

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 11:24:44 +08:00
parent 98f49ea7ad
commit 4b53862ded
73 changed files with 12688 additions and 305 deletions

View File

@@ -0,0 +1,84 @@
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 账单明细项(值对象)。
/// 用于记录账单中的单项费用明细,如套餐费用、配额包费用等。
/// </summary>
public sealed class BillingLineItem
{
/// <summary>
/// 明细项类型(如:套餐费、配额包、其他费用)。
/// </summary>
public string ItemType { get; set; } = string.Empty;
/// <summary>
/// 明细项描述。
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 数量。
/// </summary>
public decimal Quantity { get; set; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; set; }
/// <summary>
/// 金额(数量 × 单价)。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 折扣率0-1 之间,如 0.1 表示 10% 折扣)。
/// </summary>
public decimal DiscountRate { get; set; }
/// <summary>
/// 创建账单明细项。
/// </summary>
/// <param name="itemType">明细项类型。</param>
/// <param name="description">描述。</param>
/// <param name="quantity">数量。</param>
/// <param name="unitPrice">单价。</param>
/// <param name="discountRate">折扣率。</param>
/// <returns>账单明细项实例。</returns>
public static BillingLineItem Create(
string itemType,
string description,
decimal quantity,
decimal unitPrice,
decimal discountRate = 0)
{
var amount = quantity * unitPrice * (1 - discountRate);
return new BillingLineItem
{
ItemType = itemType,
Description = description,
Quantity = quantity,
UnitPrice = unitPrice,
Amount = amount,
DiscountRate = discountRate
};
}
/// <summary>
/// 计算折扣后的金额。
/// </summary>
/// <returns>折扣后金额。</returns>
public decimal CalculateDiscountedAmount()
{
return Quantity * UnitPrice * (1 - DiscountRate);
}
/// <summary>
/// 获取折扣金额。
/// </summary>
/// <returns>折扣金额。</returns>
public decimal GetDiscountAmount()
{
return Quantity * UnitPrice * DiscountRate;
}
}

View File

@@ -13,6 +13,16 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
/// </summary>
public string StatementNo { get; set; } = string.Empty;
/// <summary>
/// 账单类型(订阅账单/配额包账单/手动账单/续费账单)。
/// </summary>
public BillingType BillingType { get; set; } = BillingType.Subscription;
/// <summary>
/// 关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。
/// </summary>
public long? SubscriptionId { get; set; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
@@ -24,15 +34,30 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
public DateTime PeriodEnd { get; set; }
/// <summary>
/// 应付金额。
/// 应付金额(原始金额)
/// </summary>
public decimal AmountDue { get; set; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; set; }
/// <summary>
/// 实付金额。
/// </summary>
public decimal AmountPaid { get; set; }
/// <summary>
/// 货币类型(默认 CNY
/// </summary>
public string Currency { get; set; } = "CNY";
/// <summary>
/// 当前付款状态。
/// </summary>
@@ -43,8 +68,133 @@ public sealed class TenantBillingStatement : MultiTenantEntityBase
/// </summary>
public DateTime DueDate { get; set; }
/// <summary>
/// 提醒发送时间(续费提醒、逾期提醒等)。
/// </summary>
public DateTime? ReminderSentAt { get; set; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; set; }
/// <summary>
/// 账单明细 JSON记录各项费用。
/// </summary>
public string? LineItemsJson { get; set; }
/// <summary>
/// 备注信息(如:人工备注、取消原因等)。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 计算总金额(应付金额 - 折扣 + 税费)。
/// </summary>
/// <returns>总金额。</returns>
public decimal CalculateTotalAmount()
{
return AmountDue - DiscountAmount + TaxAmount;
}
/// <summary>
/// 标记为已支付(直接结清)。
/// </summary>
public void MarkAsPaid()
{
// 1. 计算剩余应付金额
var remainingAmount = CalculateTotalAmount() - AmountPaid;
// 2. 若已结清则直接返回
if (remainingAmount <= 0)
{
Status = TenantBillingStatus.Paid;
return;
}
// 3. 补足剩余金额并标记为已支付
MarkAsPaid(remainingAmount, string.Empty);
}
/// <summary>
/// 标记为已支付。
/// </summary>
/// <param name="amount">支付金额。</param>
/// <param name="transactionNo">交易号。</param>
public void MarkAsPaid(decimal amount, string transactionNo)
{
if (Status == TenantBillingStatus.Paid)
{
throw new InvalidOperationException("账单已经处于已支付状态,不能重复标记。");
}
if (Status == TenantBillingStatus.Cancelled)
{
throw new InvalidOperationException("已取消的账单不能标记为已支付。");
}
// 1. 累加支付金额
AmountPaid += amount;
// 2. 如果实付金额大于等于应付总额,则标记为已支付
if (AmountPaid >= CalculateTotalAmount())
{
Status = TenantBillingStatus.Paid;
}
}
/// <summary>
/// 标记为逾期。
/// </summary>
public void MarkAsOverdue()
{
// 1. 仅待支付账单允许标记逾期
if (Status != TenantBillingStatus.Pending)
{
return;
}
// 2. 未超过到期日则不处理
if (DateTime.UtcNow <= DueDate)
{
return;
}
// 3. 标记为逾期(通知时间由外部流程在发送通知时写入)
Status = TenantBillingStatus.Overdue;
}
/// <summary>
/// 取消账单。
/// </summary>
public void Cancel()
{
Cancel(null);
}
/// <summary>
/// 取消账单。
/// </summary>
/// <param name="reason">取消原因。</param>
public void Cancel(string? reason)
{
if (Status == TenantBillingStatus.Paid)
{
throw new InvalidOperationException("已支付的账单不能取消。");
}
if (Status == TenantBillingStatus.Cancelled)
{
throw new InvalidOperationException("账单已经处于取消状态。");
}
// 1. 变更状态
Status = TenantBillingStatus.Cancelled;
// 2. 记录取消原因(可选)
if (!string.IsNullOrWhiteSpace(reason))
{
Notes = string.IsNullOrWhiteSpace(Notes) ? $"[取消原因] {reason}" : $"{Notes}\n[取消原因] {reason}";
}
}
}

View File

@@ -21,12 +21,12 @@ public sealed class TenantPayment : MultiTenantEntityBase
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod Method { get; set; }
public TenantPaymentMethod Method { get; set; }
/// <summary>
/// 支付状态。
/// </summary>
public PaymentStatus Status { get; set; }
public TenantPaymentStatus Status { get; set; }
/// <summary>
/// 交易号。
@@ -43,8 +43,116 @@ public sealed class TenantPayment : MultiTenantEntityBase
/// </summary>
public DateTime? PaidAt { get; set; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; set; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; set; }
/// <summary>
/// 审核人 ID管理员
/// </summary>
public long? VerifiedBy { get; set; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; set; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 审核支付记录(确认支付有效性)。
/// </summary>
public void Verify()
{
if (Status != TenantPaymentStatus.Pending)
{
throw new InvalidOperationException("只有待审核的支付记录才能被审核。");
}
if (VerifiedAt.HasValue)
{
throw new InvalidOperationException("该支付记录已经被审核过。");
}
// 1. 标记为支付成功
Status = TenantPaymentStatus.Success;
// 2. 写入审核时间与支付时间
VerifiedAt = DateTime.UtcNow;
PaidAt ??= DateTime.UtcNow;
}
/// <summary>
/// 审核支付记录(确认支付有效性)。
/// </summary>
/// <param name="verifierId">审核人 ID。</param>
public void Verify(long verifierId)
{
Verify();
VerifiedBy = verifierId;
}
/// <summary>
/// 退款。
/// </summary>
public void Refund()
{
if (string.IsNullOrWhiteSpace(RefundReason))
{
throw new InvalidOperationException("退款原因不能为空。");
}
Refund(RefundReason);
}
/// <summary>
/// 退款。
/// </summary>
/// <param name="reason">退款原因。</param>
public void Refund(string reason)
{
if (Status == TenantPaymentStatus.Refunded)
{
throw new InvalidOperationException("该支付记录已经处于退款状态。");
}
if (Status != TenantPaymentStatus.Success)
{
throw new InvalidOperationException("只有支付成功的记录才能退款。");
}
// 1. 标记退款状态
Status = TenantPaymentStatus.Refunded;
// 2. 写入退款原因与退款时间
RefundReason = reason;
RefundedAt = DateTime.UtcNow;
}
/// <summary>
/// 拒绝支付(审核不通过)。
/// </summary>
/// <param name="verifierId">审核人 ID。</param>
/// <param name="reason">拒绝原因。</param>
public void Reject(long verifierId, string reason)
{
if (Status != TenantPaymentStatus.Pending)
{
throw new InvalidOperationException("只有待审核的支付记录才能被拒绝。");
}
Status = TenantPaymentStatus.Failed;
VerifiedBy = verifierId;
VerifiedAt = DateTime.UtcNow;
Notes = $"拒绝原因: {reason}";
}
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 账单导出格式。
/// </summary>
public enum BillingExportFormat
{
/// <summary>
/// Excel 格式(.xlsx
/// </summary>
Excel = 0,
/// <summary>
/// PDF 格式(.pdf
/// </summary>
Pdf = 1,
/// <summary>
/// CSV 格式(.csv
/// </summary>
Csv = 2
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 账单类型。
/// </summary>
public enum BillingType
{
/// <summary>
/// 订阅账单(周期性订阅费用)。
/// </summary>
Subscription = 0,
/// <summary>
/// 配额包购买(一次性配额包购买)。
/// </summary>
QuotaPurchase = 1,
/// <summary>
/// 手动创建(管理员手动生成的账单)。
/// </summary>
Manual = 2,
/// <summary>
/// 续费账单(自动续费生成的账单)。
/// </summary>
Renewal = 3
}

View File

@@ -1,9 +1,9 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 支付方式。
/// 租户支付方式。
/// </summary>
public enum PaymentMethod
public enum TenantPaymentMethod
{
/// <summary>
/// 线上支付。

View File

@@ -1,9 +1,9 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 支付状态。
/// 租户支付状态。
/// </summary>
public enum PaymentStatus
public enum TenantPaymentStatus
{
/// <summary>
/// 待支付。

View File

@@ -42,6 +42,14 @@ public interface ITenantBillingRepository
/// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default);
/// <summary>
/// 按账单编号获取账单(不限租户,管理员端使用)。
/// </summary>
/// <param name="statementNo">账单编号。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default);
/// <summary>
/// 判断是否已存在指定周期开始时间的未取消账单(用于自动续费幂等)。
/// </summary>
@@ -54,6 +62,39 @@ public interface ITenantBillingRepository
DateTime periodStart,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取逾期账单列表(已过到期日且未支付)。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>逾期账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取即将到期的账单列表(未来 N 天内到期且未支付)。
/// </summary>
/// <param name="daysAhead">提前天数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>即将到期的账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default);
/// <summary>
/// 按租户 ID 获取账单列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单集合。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按 ID 列表批量获取账单(管理员端/批量操作场景)。
/// </summary>
/// <param name="billingIds">账单 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体列表。</returns>
Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(
IReadOnlyCollection<long> billingIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增账单。
/// </summary>
@@ -84,6 +125,8 @@ public interface ITenantBillingRepository
/// <param name="status">账单状态筛选(可选)。</param>
/// <param name="from">开始时间UTC可选。</param>
/// <param name="to">结束时间UTC可选。</param>
/// <param name="minAmount">最小应付金额筛选(包含,可选)。</param>
/// <param name="maxAmount">最大应付金额筛选(包含,可选)。</param>
/// <param name="keyword">关键词搜索(账单号或租户名)。</param>
/// <param name="pageNumber">页码(从 1 开始)。</param>
/// <param name="pageSize">页大小。</param>
@@ -94,11 +137,29 @@ public interface ITenantBillingRepository
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取账单统计数据(用于报表与仪表盘)。
/// </summary>
/// <param name="tenantId">租户 ID可选管理员可查询所有租户。</param>
/// <param name="startDate">统计开始时间UTC。</param>
/// <param name="endDate">统计结束时间UTC。</param>
/// <param name="groupBy">分组方式Day/Week/Month。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计结果。</returns>
Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default);
/// <summary>
/// 按 ID 获取账单(不限租户,管理员端使用)。
/// </summary>
@@ -107,3 +168,80 @@ public interface ITenantBillingRepository
/// <returns>账单实体或 null。</returns>
Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default);
}
/// <summary>
/// 账单统计结果。
/// </summary>
public sealed record TenantBillingStatistics
{
/// <summary>
/// 总账单金额(统计区间内)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额(统计区间内)。
/// </summary>
public decimal PaidAmount { get; init; }
/// <summary>
/// 未支付金额(统计区间内)。
/// </summary>
public decimal UnpaidAmount { get; init; }
/// <summary>
/// 逾期金额(统计区间内)。
/// </summary>
public decimal OverdueAmount { get; init; }
/// <summary>
/// 总账单数量(统计区间内)。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 已支付账单数量(统计区间内)。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 未支付账单数量(统计区间内)。
/// </summary>
public int UnpaidCount { get; init; }
/// <summary>
/// 逾期账单数量(统计区间内)。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 趋势数据(按 groupBy 聚合)。
/// </summary>
public IReadOnlyList<TenantBillingTrendDataPoint> TrendData { get; init; } = [];
}
/// <summary>
/// 账单趋势统计点。
/// </summary>
public sealed record TenantBillingTrendDataPoint
{
/// <summary>
/// 分组时间点Day/Week/Month 的代表日期UTC
/// </summary>
public DateTime Period { get; init; }
/// <summary>
/// 账单数量。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal PaidAmount { get; init; }
}

View File

@@ -15,6 +15,14 @@ public interface ITenantPaymentRepository
/// <returns>支付记录集合。</returns>
Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default);
/// <summary>
/// 计算指定账单的累计已支付金额。
/// </summary>
/// <param name="billingStatementId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>累计已支付金额。</returns>
Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default);
/// <summary>
/// 按 ID 获取支付记录。
/// </summary>
@@ -23,6 +31,14 @@ public interface ITenantPaymentRepository
/// <returns>支付记录实体或 null。</returns>
Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default);
/// <summary>
/// 按交易号获取支付记录。
/// </summary>
/// <param name="transactionNo">交易号。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录实体或 null。</returns>
Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default);
/// <summary>
/// 新增支付记录。
/// </summary>

View File

@@ -0,0 +1,64 @@
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Services;
/// <summary>
/// 账单领域服务接口。
/// 负责处理账单生成、账单编号生成、逾期处理等跨实体的业务逻辑。
/// </summary>
public interface IBillingDomainService
{
/// <summary>
/// 根据订阅信息生成账单。
/// </summary>
/// <param name="subscription">租户订阅信息。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>生成的账单实体。</returns>
Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据配额包购买信息生成账单。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="quotaPackage">配额包信息。</param>
/// <param name="quantity">购买数量。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>生成的账单实体。</returns>
Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default);
/// <summary>
/// 生成唯一的账单编号。
/// 格式示例BIL-20251217-000001
/// </summary>
/// <returns>账单编号。</returns>
string GenerateStatementNo();
/// <summary>
/// 处理逾期账单(批量标记逾期状态)。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>处理的账单数量。</returns>
Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 计算账单总金额(含折扣和税费)。
/// </summary>
/// <param name="baseAmount">基础金额。</param>
/// <param name="discountAmount">折扣金额。</param>
/// <param name="taxAmount">税费金额。</param>
/// <returns>总金额。</returns>
decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount);
/// <summary>
/// 验证账单状态是否可以进行支付操作。
/// </summary>
/// <param name="billing">账单实体。</param>
/// <returns>是否可以支付。</returns>
bool CanProcessPayment(TenantBillingStatement billing);
}

View File

@@ -0,0 +1,33 @@
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Services;
/// <summary>
/// 账单导出服务接口。
/// </summary>
public interface IBillingExportService
{
/// <summary>
/// 导出为 ExcelXLSX
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
/// <summary>
/// 导出为 PDF。
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
/// <summary>
/// 导出为 CSV。
/// </summary>
/// <param name="billings">账单数据。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>文件字节数组。</returns>
Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default);
}