# Design: add-paid-amount-snapshot-to-package-usage

## Context

当前 `tb_package_usage` 表在套餐激活时快照了 `package_name`，但未快照购买实付金额。`paid_amount` 字段需要从 `tb_order.actual_paid_amount` 获取，目前只能通过 `order_id` JOIN 订单表实现。

**当前 PackageUsage 创建路径有两条，各含主套餐和加油包，共 4 个写入点**：
1. `internal/service/order/service.go` — `activateMainPackage()` / `activateAddonPackage()`（支付后激活）
2. `internal/task/auto_purchase.go` — `activateMainPackage()` / `activateAddonPackage()`（C 端充值自动购包）

**时序保证**：两条路径创建 `PackageUsage` 时，`order.ActualPaidAmount` 均已赋值：
- 钱包支付 / 自动购包：Order 创建时即写入 `actual_paid_amount`
- 微信 / 支付宝：支付回调 `HandlePaymentCallback` 先更新 `actual_paid_amount`，再调用 `activatePackage()`
- 线下支付：`actual_paid_amount = nil`（无实际收款，字段允许 null）

**约束**：禁止外键、禁止 GORM 关联（项目规范），不能依赖 JOIN 查询。

## Goals / Non-Goals

**Goals**
- 在 `tb_package_usage` 中存储购买时的实付金额快照
- 使资产套餐两个接口（current-package / packages）直接返回 `paid_amount`，无需 JOIN
- 存量数据通过迁移 SQL 回填（JOIN 仅在一次性迁移中使用）

**Non-Goals**
- 不修改订单表结构
- 不改变套餐购买业务流程
- 不修改 C 端 / H5 相关接口
- 不修改其他展示场景（如订单列表、分佣等）中的价格来源

## Decisions

### 决策 1：快照字段还是 JOIN 查询

**选择**：快照字段（在 `tb_package_usage` 新增 `paid_amount`）

**理由**：
- 项目规范禁止 GORM 关联，JOIN 需手动实现，读频繁时性能代价不可忽视
- 快照语义更清晰：记录的是"购买时的价格"，而非"当前订单的价格"
- `package_name` 已有先例，模式一致

**备选方案**：JOIN `tb_order` — 被否决，原因是高频读场景性能差、与快照设计原则不符。

### 决策 2：字段类型

**选择**：`BIGINT NULLABLE`，Go 侧 `*int64`

**理由**：
- 与项目所有金额字段统一（单位：分，bigint）
- Nullable 而非 `NOT NULL DEFAULT 0`：线下支付 `actual_paid_amount` 为 null，区分"0元"和"无实付"语义不同
- 存量 `order_id = 0` 的记录（如企业无订单直接分配套餐）回填为 null

**备选方案**：`NOT NULL DEFAULT 0` — 被否决，0 与"无实付"语义歧义。

### 决策 3：赋值来源

**选择**：直接读 `order.ActualPaidAmount`，不需要调用方额外传参

**理由**：
- 4 个写入点创建 `PackageUsage` 时均已持有完整 `order` 对象
- 避免调用链增加参数，改动最小

### 决策 4：存量数据回填策略

**选择**：迁移 SQL 中 `UPDATE ... FROM tb_order` 一次性回填

```sql
UPDATE tb_package_usage pu
SET paid_amount = o.actual_paid_amount
FROM tb_order o
WHERE pu.order_id = o.id
  AND pu.order_id != 0
  AND pu.paid_amount IS NULL;
```

**理由**：
- 历史数据量有限，一次性回填可接受
- `order_id = 0` 的记录（无订单分配）保持 null，语义正确

## Risks / Trade-offs

| 风险 | 缓解措施 |
|------|---------|
| 写入点遗漏：未来新增的 PackageUsage 创建路径忘记赋值 | 在 Model 层 `PackageUsage` 字段注释中标注"创建时必须从 order 赋值" |
| 存量回填失败（迁移中途中断） | 迁移 SQL 使用 `IF NOT EXISTS` + `UPDATE` 幂等写法，重跑安全 |
| 线下支付 null 值前端未处理 | DTO 字段使用 `*int64`（omitempty），前端已有 null 处理惯例 |

## Migration Plan

1. 执行迁移 `000129_add_paid_amount_to_package_usage.up.sql`：
   - `ALTER TABLE tb_package_usage ADD COLUMN IF NOT EXISTS paid_amount BIGINT`
   - `UPDATE ... FROM tb_order` 回填历史数据
2. 部署代码（Model / Service / DTO）
3. 新的套餐激活记录自动写入 `paid_amount`

**回滚**：执行 `.down.sql` DROP COLUMN，代码回滚到上一版本。

## Open Questions

无。时序、字段设计、回填策略均已确认。
