# 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 无。时序、字段设计、回填策略均已确认。