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,34 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.AdminApi.Contracts.Requests;
/// <summary>
/// 租户账单分页查询请求QueryString 参数)。
/// </summary>
public sealed record SearchTenantBillsRequest
{
/// <summary>
/// 账单状态筛选。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 账单起始时间UTC筛选。
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 账单结束时间UTC筛选。
/// </summary>
public DateTime? To { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -1,6 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
@@ -16,7 +17,7 @@ namespace TakeoutSaaS.AdminApi.Controllers;
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/bills")]
[Route("api/admin/v{version:apiVersion}/billings")]
public sealed class BillingsController(IMediator mediator) : BaseApiController
{
/// <summary>
@@ -25,11 +26,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>账单分页结果。</returns>
[HttpGet]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillDto>>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillingListDto>>> GetList([FromQuery] GetBillingListQuery query, CancellationToken cancellationToken)
{
// 1. 查询账单列表
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<BillDto>>.Ok(result);
// 2. 返回分页结果
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
}
/// <summary>
@@ -40,15 +44,15 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>账单详情。</returns>
[HttpGet("{id:long}")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillingDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetBillDetailQuery { BillId = id }, cancellationToken);
// 1. 查询账单详情(若不存在则抛出业务异常,由全局异常处理转换为 404
var result = await mediator.Send(new GetBillingDetailQuery { BillingId = id }, cancellationToken);
return result is null
? ApiResponse<BillDetailDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
: ApiResponse<BillDetailDto>.Ok(result);
// 2. 返回详情
return ApiResponse<BillingDetailDto>.Ok(result);
}
/// <summary>
@@ -59,11 +63,14 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <returns>创建的账单信息。</returns>
[HttpPost]
[PermissionAuthorize("bill:create")]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BillDto>> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BillingDetailDto>> Create([FromBody, Required] CreateBillingCommand command, CancellationToken cancellationToken)
{
// 1. 创建账单
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<BillDto>.Ok(result);
// 2. 返回创建结果
return ApiResponse<BillingDetailDto>.Ok(result);
}
/// <summary>
@@ -72,50 +79,202 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController
/// <param name="id">账单 ID。</param>
/// <param name="command">更新状态命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的账单信息。</returns>
/// <returns>更新结果。</returns>
[HttpPut("{id:long}/status")]
[PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillDto>> UpdateStatus(long id, [FromBody, Required] UpdateBillStatusCommand command, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> UpdateStatus(long id, [FromBody, Required] UpdateBillingStatusCommand command, CancellationToken cancellationToken)
{
command = command with { BillId = id };
var result = await mediator.Send(command, cancellationToken);
// 1. 绑定账单标识
command = command with { BillingId = id };
return result is null
? ApiResponse<BillDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
: ApiResponse<BillDto>.Ok(result);
// 2. 更新账单状态(若不存在则抛出业务异常,由全局异常处理转换为 404
await mediator.Send(command, cancellationToken);
// 3. 返回成功结果
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 取消账单。
/// </summary>
/// <param name="id">账单 ID。</param>
/// <param name="reason">取消原因(可选)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>取消结果。</returns>
[HttpDelete("{id:long}")]
[PermissionAuthorize("bill:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Cancel(long id, [FromQuery] string? reason, CancellationToken cancellationToken)
{
// 1. 取消账单(取消原因支持可选)
await mediator.Send(new CancelBillingCommand { BillingId = id, Reason = reason ?? string.Empty }, cancellationToken);
// 2. 返回成功结果
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取账单支付记录。
/// </summary>
/// <param name="billId">账单 ID。</param>
/// <param name="id">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录列表。</returns>
[HttpGet("{billId:long}/payments")]
[HttpGet("{id:long}/payments")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<List<PaymentDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<PaymentDto>>> GetPayments(long billId, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<List<PaymentRecordDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<PaymentRecordDto>>> GetPayments(long id, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken);
return ApiResponse<List<PaymentDto>>.Ok(result);
// 1. 查询支付记录
var result = await mediator.Send(new GetBillingPaymentsQuery { BillingId = id }, cancellationToken);
// 2. 返回列表
return ApiResponse<List<PaymentRecordDto>>.Ok(result);
}
/// <summary>
/// 记录支付(线下支付确认)。
/// </summary>
/// <param name="billId">账单 ID。</param>
/// <param name="id">账单 ID。</param>
/// <param name="command">记录支付命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录信息。</returns>
[HttpPost("{billId:long}/payments")]
[HttpPost("{id:long}/payments")]
[PermissionAuthorize("bill:pay")]
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PaymentDto>> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentRecordDto>> RecordPayment(long id, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
{
command = command with { BillId = billId };
// 1. 绑定账单标识
command = command with { BillingId = id };
// 2. 记录支付
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<PaymentDto>.Ok(result);
// 3. 返回支付记录
return ApiResponse<PaymentRecordDto>.Ok(result);
}
/// <summary>
/// 审核支付记录。
/// </summary>
/// <param name="paymentId">支付记录 ID。</param>
/// <param name="command">审核参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>审核后的支付记录。</returns>
[HttpPut("payments/{paymentId:long}/verify")]
[PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentRecordDto>> VerifyPayment(long paymentId, [FromBody, Required] VerifyPaymentCommand command, CancellationToken cancellationToken)
{
// 1. 绑定支付记录标识
command = command with { PaymentId = paymentId };
// 2. (空行后) 审核支付记录
var result = await mediator.Send(command, cancellationToken);
// 3. (空行后) 返回审核结果
return ApiResponse<PaymentRecordDto>.Ok(result);
}
/// <summary>
/// 批量更新账单状态。
/// </summary>
/// <param name="command">批量更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新条数。</returns>
[HttpPost("batch/status")]
[PermissionAuthorize("bill:update")]
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
public async Task<ApiResponse<int>> BatchUpdateStatus([FromBody, Required] BatchUpdateStatusCommand command, CancellationToken cancellationToken)
{
// 1. 执行批量更新
var affected = await mediator.Send(command, cancellationToken);
// 2. 返回更新条数
return ApiResponse<int>.Ok(affected);
}
/// <summary>
/// 导出账单Excel/PDF/CSV
/// </summary>
/// <param name="query">导出请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>导出文件。</returns>
[HttpPost("export")]
[PermissionAuthorize("bill:read")]
[Produces("application/octet-stream")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> Export([FromBody, Required] ExportBillingsQuery query, CancellationToken cancellationToken)
{
// 1. 执行导出
var bytes = await mediator.Send(query, cancellationToken);
// 2. (空行后) 解析格式并生成文件名
var extension = ResolveExportFileExtension(query.Format);
var fileName = $"billings_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{extension}";
// 3. (空行后) 显式写入 Content-Disposition确保浏览器以附件形式下载
Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment")
{
FileName = fileName,
FileNameStar = fileName
}.ToString();
// 4. (空行后) 返回二进制流(统一 octet-stream避免被默认 JSON Produces 影响)
return File(bytes, "application/octet-stream");
}
/// <summary>
/// 获取账单统计数据。
/// </summary>
/// <param name="query">统计查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计结果。</returns>
[HttpGet("statistics")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<BillingStatisticsDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<BillingStatisticsDto>> Statistics([FromQuery] GetBillingStatisticsQuery query, CancellationToken cancellationToken)
{
// 1. 查询统计数据
var result = await mediator.Send(query, cancellationToken);
// 2. 返回统计结果
return ApiResponse<BillingStatisticsDto>.Ok(result);
}
/// <summary>
/// 获取逾期账单列表。
/// </summary>
/// <param name="query">逾期列表查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>逾期账单分页结果。</returns>
[HttpGet("overdue")]
[PermissionAuthorize("bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillingListDto>>> Overdue([FromQuery] GetOverdueBillingsQuery query, CancellationToken cancellationToken)
{
// 1. 查询逾期账单分页列表
var result = await mediator.Send(query, cancellationToken);
// 2. 返回分页结果
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
}
private static string ResolveExportFileExtension(string? format)
{
// 1. 归一化导出格式
var normalized = (format ?? string.Empty).Trim();
// 2. (空行后) 映射扩展名
return normalized.ToUpperInvariant() switch
{
"PDF" => "pdf",
"CSV" => "csv",
_ => "xlsx"
};
}
}

View File

@@ -2,6 +2,7 @@ using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.AdminApi.Contracts.Requests;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
@@ -26,10 +27,18 @@ public sealed class TenantBillingsController(IMediator mediator) : BaseApiContro
[HttpGet]
[PermissionAuthorize("tenant-bill:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantBillingDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantBillingDto>>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken)
public async Task<ApiResponse<PagedResult<TenantBillingDto>>> Search(long tenantId, [FromQuery] SearchTenantBillsRequest request, CancellationToken cancellationToken)
{
// 1. 绑定租户标识
query = query with { TenantId = tenantId };
// 1. 组装查询对象TenantId 仅来自路由,避免与 QueryString 重复)
var query = new SearchTenantBillsQuery
{
TenantId = tenantId,
Status = request.Status,
From = request.From,
To = request.To,
Page = request.Page,
PageSize = request.PageSize,
};
// 2. 查询账单列表
var result = await mediator.Send(query, cancellationToken);