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,237 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateTenantBillingSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RefundReason",
table: "tenant_payments",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "退款原因。");
migrationBuilder.AddColumn<DateTime>(
name: "RefundedAt",
table: "tenant_payments",
type: "timestamp with time zone",
nullable: true,
comment: "退款时间。");
migrationBuilder.AddColumn<DateTime>(
name: "VerifiedAt",
table: "tenant_payments",
type: "timestamp with time zone",
nullable: true,
comment: "审核时间。");
migrationBuilder.AddColumn<long>(
name: "VerifiedBy",
table: "tenant_payments",
type: "bigint",
nullable: true,
comment: "审核人 ID管理员。");
migrationBuilder.AlterColumn<decimal>(
name: "AmountDue",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
comment: "应付金额(原始金额)。",
oldClrType: typeof(decimal),
oldType: "numeric(18,2)",
oldPrecision: 18,
oldScale: 2,
oldComment: "应付金额。");
migrationBuilder.AddColumn<int>(
name: "BillingType",
table: "tenant_billing_statements",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "账单类型(订阅账单/配额包账单/手动账单/续费账单)。");
migrationBuilder.AddColumn<string>(
name: "Currency",
table: "tenant_billing_statements",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "CNY",
comment: "货币类型(默认 CNY。");
migrationBuilder.AddColumn<decimal>(
name: "DiscountAmount",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "折扣金额。");
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "tenant_billing_statements",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "备注信息(如:人工备注、取消原因等)。");
migrationBuilder.AddColumn<DateTime>(
name: "OverdueNotifiedAt",
table: "tenant_billing_statements",
type: "timestamp with time zone",
nullable: true,
comment: "逾期通知时间。");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "tenant_billing_statements",
type: "timestamp with time zone",
nullable: true,
comment: "提醒发送时间(续费提醒、逾期提醒等)。");
migrationBuilder.AddColumn<long>(
name: "SubscriptionId",
table: "tenant_billing_statements",
type: "bigint",
nullable: true,
comment: "关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。");
migrationBuilder.AddColumn<decimal>(
name: "TaxAmount",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "税费金额。");
migrationBuilder.CreateIndex(
name: "idx_payment_billing_paidat",
table: "tenant_payments",
columns: new[] { "BillingStatementId", "PaidAt" });
migrationBuilder.CreateIndex(
name: "idx_payment_transaction_no",
table: "tenant_payments",
column: "TransactionNo",
filter: "\"TransactionNo\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "idx_billing_created_at",
table: "tenant_billing_statements",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "idx_billing_status_duedate",
table: "tenant_billing_statements",
columns: new[] { "Status", "DueDate" },
filter: "\"Status\" IN (0, 2)");
migrationBuilder.CreateIndex(
name: "idx_billing_tenant_status_duedate",
table: "tenant_billing_statements",
columns: new[] { "TenantId", "Status", "DueDate" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_payment_billing_paidat",
table: "tenant_payments");
migrationBuilder.DropIndex(
name: "idx_payment_transaction_no",
table: "tenant_payments");
migrationBuilder.DropIndex(
name: "idx_billing_created_at",
table: "tenant_billing_statements");
migrationBuilder.DropIndex(
name: "idx_billing_status_duedate",
table: "tenant_billing_statements");
migrationBuilder.DropIndex(
name: "idx_billing_tenant_status_duedate",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "RefundReason",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "RefundedAt",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "VerifiedAt",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "VerifiedBy",
table: "tenant_payments");
migrationBuilder.DropColumn(
name: "BillingType",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "Currency",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "DiscountAmount",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "Notes",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "OverdueNotifiedAt",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "SubscriptionId",
table: "tenant_billing_statements");
migrationBuilder.DropColumn(
name: "TaxAmount",
table: "tenant_billing_statements");
migrationBuilder.AlterColumn<decimal>(
name: "AmountDue",
table: "tenant_billing_statements",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
comment: "应付金额。",
oldClrType: typeof(decimal),
oldType: "numeric(18,2)",
oldPrecision: 18,
oldScale: 2,
oldComment: "应付金额(原始金额)。");
}
}
}

View File

@@ -6221,13 +6221,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.Property<decimal>("AmountDue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("应付金额。");
.HasComment("应付金额(原始金额)。");
b.Property<decimal>("AmountPaid")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("实付金额。");
b.Property<int>("BillingType")
.HasColumnType("integer")
.HasComment("账单类型(订阅账单/配额包账单/手动账单/续费账单)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
@@ -6236,6 +6240,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasDefaultValue("CNY")
.HasComment("货币类型(默认 CNY。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
@@ -6244,6 +6256,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal>("DiscountAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("折扣金额。");
b.Property<DateTime>("DueDate")
.HasColumnType("timestamp with time zone")
.HasComment("到期日。");
@@ -6252,6 +6269,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text")
.HasComment("账单明细 JSON记录各项费用。");
b.Property<string>("Notes")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("备注信息(如:人工备注、取消原因等)。");
b.Property<DateTime?>("OverdueNotifiedAt")
.HasColumnType("timestamp with time zone")
.HasComment("逾期通知时间。");
b.Property<DateTime>("PeriodEnd")
.HasColumnType("timestamp with time zone")
.HasComment("账单周期结束时间。");
@@ -6260,6 +6286,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("账单周期开始时间。");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("timestamp with time zone")
.HasComment("提醒发送时间(续费提醒、逾期提醒等)。");
b.Property<string>("StatementNo")
.IsRequired()
.HasMaxLength(64)
@@ -6270,6 +6300,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("integer")
.HasComment("当前付款状态。");
b.Property<long?>("SubscriptionId")
.HasColumnType("bigint")
.HasComment("关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。");
b.Property<decimal>("TaxAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("税费金额。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
@@ -6284,9 +6323,19 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("idx_billing_created_at");
b.HasIndex("Status", "DueDate")
.HasDatabaseName("idx_billing_status_duedate")
.HasFilter("\"Status\" IN (0, 2)");
b.HasIndex("TenantId", "StatementNo")
.IsUnique();
b.HasIndex("TenantId", "Status", "DueDate")
.HasDatabaseName("idx_billing_tenant_status_duedate");
b.ToTable("tenant_billing_statements", null, t =>
{
t.HasComment("租户账单,用于呈现周期性收费。");
@@ -6555,6 +6604,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(512)")
.HasComment("支付凭证 URL。");
b.Property<string>("RefundReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("退款原因。");
b.Property<DateTime?>("RefundedAt")
.HasColumnType("timestamp with time zone")
.HasComment("退款时间。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("支付状态。");
@@ -6576,8 +6634,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasComment("审核时间。");
b.Property<long?>("VerifiedBy")
.HasColumnType("bigint")
.HasComment("审核人 ID管理员。");
b.HasKey("Id");
b.HasIndex("TransactionNo")
.HasDatabaseName("idx_payment_transaction_no")
.HasFilter("\"TransactionNo\" IS NOT NULL");
b.HasIndex("BillingStatementId", "PaidAt")
.HasDatabaseName("idx_payment_billing_paidat");
b.HasIndex("TenantId", "BillingStatementId");
b.ToTable("tenant_payments", null, t =>