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,233 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 分页查询账单列表处理器。
/// </summary>
public sealed class GetBillingListQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
{
/// <summary>
/// 处理分页查询账单列表请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页账单列表 DTO。</returns>
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
var minAmount = request.MinAmount;
var maxAmount = request.MaxAmount;
var offset = (page - 1) * pageSize;
// 1.1 (空行后) 金额区间规范化(避免 min > max 导致结果为空)
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
{
(minAmount, maxAmount) = (maxAmount, minAmount);
}
// 2. (空行后) 排序白名单(防 SQL 注入)
var orderBy = request.SortBy?.Trim() switch
{
"DueDate" => "b.\"DueDate\"",
"AmountDue" => "b.\"AmountDue\"",
"PeriodStart" => "b.\"PeriodStart\"",
"PeriodEnd" => "b.\"PeriodEnd\"",
"CreatedAt" => "b.\"CreatedAt\"",
_ => "b.\"CreatedAt\""
};
// 3. (空行后) 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 3.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("tenantId", request.TenantId),
("status", request.Status.HasValue ? (int)request.Status.Value : null),
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),
("minAmount", minAmount),
("maxAmount", maxAmount),
("keyword", keyword)
],
token);
// 3.2 (空行后) 查询列表
var listSql = BuildListSql(orderBy, request.SortDesc);
await using var listCommand = CreateCommand(
connection,
listSql,
[
("tenantId", request.TenantId),
("status", request.Status.HasValue ? (int)request.Status.Value : null),
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),
("minAmount", minAmount),
("maxAmount", maxAmount),
("keyword", keyword),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
var now = DateTime.UtcNow;
var items = new List<BillingListDto>();
while (await reader.ReadAsync(token))
{
var dueDate = reader.GetDateTime(13);
var status = (TenantBillingStatus)reader.GetInt32(12);
var amountDue = reader.GetDecimal(8);
var discountAmount = reader.GetDecimal(9);
var taxAmount = reader.GetDecimal(10);
var totalAmount = amountDue - discountAmount + taxAmount;
// 3.2.1 (空行后) 逾期辅助字段
var isOverdue = status is TenantBillingStatus.Overdue
|| (status is TenantBillingStatus.Pending && dueDate < now);
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
items.Add(new BillingListDto
{
Id = reader.GetInt64(0),
TenantId = reader.GetInt64(1),
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
StatementNo = reader.GetString(4),
BillingType = (BillingType)reader.GetInt32(5),
PeriodStart = reader.GetDateTime(6),
PeriodEnd = reader.GetDateTime(7),
AmountDue = amountDue,
DiscountAmount = discountAmount,
TaxAmount = taxAmount,
TotalAmount = totalAmount,
AmountPaid = reader.GetDecimal(11),
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
Status = status,
DueDate = dueDate,
IsOverdue = isOverdue,
OverdueDays = overdueDays,
CreatedAt = reader.GetDateTime(15),
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
});
}
// 3.3 (空行后) 返回分页
return new PagedResult<BillingListDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and (@status::int is null or b."Status" = @status)
and (@billingType::int is null or b."BillingType" = @billingType)
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
and (
@keyword::text is null
or b."StatementNo" ilike ('%' || @keyword::text || '%')
or t."Name" ilike ('%' || @keyword::text || '%')
);
""";
}
private static string BuildListSql(string orderBy, bool sortDesc)
{
var direction = sortDesc ? "desc" : "asc";
return $"""
select
b."Id",
b."TenantId",
t."Name" as "TenantName",
b."SubscriptionId",
b."StatementNo",
b."BillingType",
b."PeriodStart",
b."PeriodEnd",
b."AmountDue",
b."DiscountAmount",
b."TaxAmount",
b."AmountPaid",
b."Status",
b."DueDate",
b."Currency",
b."CreatedAt",
b."UpdatedAt"
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and (@status::int is null or b."Status" = @status)
and (@billingType::int is null or b."BillingType" = @billingType)
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
and (
@keyword::text is null
or b."StatementNo" ilike ('%' || @keyword::text || '%')
or t."Name" ilike ('%' || @keyword::text || '%')
)
order by {orderBy} {direction}
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}