feat: 新增套餐使用记录实付金额快照字段 paid_amount
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m19s

- 新增迁移 000129:tb_package_usage 添加 paid_amount BIGINT 字段,存量数据通过 JOIN tb_order 回填
- PackageUsage Model 新增 PaidAmount *int64 字段
- 4 个写入点(order service 主套餐/加油包、auto_purchase 主套餐/加油包)赋值 order.ActualPaidAmount
- AssetPackageResponse DTO 新增 paid_amount 字段
- GetCurrentPackage / GetPackages 填充 paid_amount,接口直接返回购买价格无需 JOIN 订单表
This commit is contained in:
2026-04-18 10:19:21 +08:00
parent 4b8784d5e7
commit 2b3a9cb33f
15 changed files with 391 additions and 5 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-18

View File

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

View File

@@ -0,0 +1,49 @@
## Why
`tb_package_usage` 在套餐激活时只快照了套餐名称(`package_name`),未快照购买时的实付金额。导致 `GET /api/admin/assets/{id}/current-package` 和 `GET /api/admin/assets/{id}/packages` 两个接口无法返回价格信息,也无法在不 JOIN 订单表的情况下展示历史购买价格。
## What Changes
- **新增数据库字段**:`tb_package_usage.paid_amount BIGINT`,单位分,nullable,用于快照购买时的实付金额
- **新增数据库迁移**:`000129_add_paid_amount_to_package_usage`,含存量数据回填(通过 `order_id` JOIN `tb_order`)
- **Model 更新**:`PackageUsage` 结构体新增 `PaidAmount *int64` 字段
- **写入点赋值(4处)**:
- `internal/service/order/service.go` `activateMainPackage()` — 主套餐激活
- `internal/service/order/service.go` `activateAddonPackage()` — 加油包激活
- `internal/task/auto_purchase.go` `activateMainPackage()` — 自动购包主套餐
- `internal/task/auto_purchase.go` `activateAddonPackage()` — 自动购包加油包
- **DTO 更新**:`AssetPackageResponse` 新增 `PaidAmount *int64` 字段
- **Service 查询更新**:Asset Service 的 `GetCurrentPackage` 和 `GetPackages` 填充 DTO `PaidAmount` 字段
## Capabilities
### New Capabilities
- `package-usage-paid-amount-snapshot`:在套餐使用记录中快照购买实付金额,使资产套餐接口能直接返回购买价格,无需 JOIN 订单表
### Modified Capabilities
- `asset-queries`:套餐列表和当前套餐接口响应新增 `paid_amount` 字段
## Impact
**数据库**
- `tb_package_usage` 新增字段 `paid_amount BIGINT`(nullable,存量回填)
**代码**
| 层级 | 文件 | 改动 |
|------|------|------|
| Model | `internal/model/package.go` | `PackageUsage` 新增 `PaidAmount *int64` |
| Service(写) | `internal/service/order/service.go` | 主套餐 + 加油包激活各加一行赋值 |
| Service(写) | `internal/task/auto_purchase.go` | 自动购包主套餐 + 加油包各加一行赋值 |
| DTO | `internal/model/dto/asset_dto.go` | `AssetPackageResponse` 新增 `PaidAmount *int64` |
| Service(读) | `internal/service/asset/service.go` | `GetCurrentPackage` + `GetPackages` 填充 DTO 字段 |
| 迁移 | `migrations/000129_add_paid_amount_to_package_usage.{up,down}.sql` | 加字段 + 回填 |
**API**
- `GET /api/admin/assets/{id}/current-package`:响应新增 `paid_amount`(分)
- `GET /api/admin/assets/{id}/packages`:响应 items 新增 `paid_amount`(分)
**特殊情况**
- 线下支付订单:`actual_paid_amount` 为 null,`paid_amount` 快照为 null(字段 nullable)
- 存量 `order_id = 0` 的记录(企业无订单分配):回填为 null,字段保持 nullable

View File

@@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: 套餐历史列表查询
系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。
**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages`
**排序**: 按 `created_at` 倒序(最新套餐在前)
**分页**: 不分页,全量返回
**范围**: 包含所有状态(含 status=4 已失效的历史套餐)
**按 asset_type 区分查询**:
- card:查询 `PackageUsage.iot_card_id = :id`
- device:查询 `PackageUsage.device_id = :id`
**每条记录响应字段**:
- `package_usage_id`: 套餐使用记录 ID
- `package_name`: 套餐名称
- `package_type`: 套餐类型(formal/addon)
- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null)
- `real_data_mb`: 真总流量(MB)
- `virtual_data_mb`: 虚总流量/停机阈值(MB)
- `package_used_mb`: 展示已使用流量(经虚流量换算)
- `package_remain_mb`: 展示剩余流量
- `activated_at`: 生效时间
- `expires_at`: 过期时间
- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效)
- `paid_amount`: 购买时实付金额(分),线下支付或无订单分配时为 null **[新增]**
#### Scenario: 查询卡的套餐历史
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效)
- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列,每条记录含 `paid_amount` 字段
#### Scenario: 查询设备的套餐历史
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages`
- **THEN** 系统返回该设备 device_id 下的所有套餐记录,含 `paid_amount` 字段
#### Scenario: 资产无套餐记录
- **WHEN** 管理员查询一张从未购买过套餐的卡
- **THEN** 系统返回空数组,不报错
#### Scenario: 线下支付套餐的 paid_amount 为 null
- **WHEN** 管理员查询一张通过线下支付购买套餐的卡
- **THEN** 对应套餐记录的 `paid_amount` 字段缺省(omitempty),不展示
---
### Requirement: 当前主套餐详情查询
系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。
**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package`
**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL`
**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包
**响应字段**:
- 完整套餐信息(同套餐历史列表中的单条记录字段)
- `paid_amount`: 购买时实付金额(分),线下支付或无订单分配时为 null **[新增]**
- 当无生效主套餐时,返回 HTTP 404
#### Scenario: 返回当前主套餐(含实付金额)
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包,主套餐 `paid_amount = 9900`
- **THEN** 系统只返回主套餐信息,响应中包含 `paid_amount: 9900`,不包含加油包
#### Scenario: 无当前生效主套餐
- **WHEN** 管理员查询没有生效中主套餐的资产
- **THEN** 系统返回 HTTP 404

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 套餐使用记录快照实付金额
系统 SHALL 在创建 `PackageUsage` 记录时,将购买该套餐对应订单的实付金额(`tb_order.actual_paid_amount`)快照至 `tb_package_usage.paid_amount` 字段,单位为分(人民币)。
**字段规范**:
- 数据库字段:`paid_amount BIGINT NULLABLE`
- Go 字段:`PaidAmount *int64`(单位:分)
- 赋值来源:`order.ActualPaidAmount`,直接赋值,不转换
- 为 null 的情况:线下支付(无实际收款)或无订单关联的企业分配套餐
**覆盖范围(所有 PackageUsage 写入点)**:
- `internal/service/order/service.go` `activateMainPackage()`
- `internal/service/order/service.go` `activateAddonPackage()`
- `internal/task/auto_purchase.go` `activateMainPackage()`
- `internal/task/auto_purchase.go` `activateAddonPackage()`
**存量数据**:通过迁移 SQL `UPDATE tb_package_usage SET paid_amount = o.actual_paid_amount FROM tb_order o WHERE pu.order_id = o.id AND pu.order_id != 0` 回填,`order_id = 0` 的记录保持 null。
#### Scenario: 钱包支付套餐激活时快照实付金额
- **WHEN** 用户以钱包支付购买套餐,订单 `actual_paid_amount = 9900`(分)
- **THEN** 激活生成的 `PackageUsage.paid_amount = 9900`
#### Scenario: 微信支付回调后激活套餐时快照实付金额
- **WHEN** 微信支付回调触发 `HandlePaymentCallback`,`actual_paid_amount = 19900`,随后调用 `activatePackage`
- **THEN** 生成的 `PackageUsage.paid_amount = 19900`
#### Scenario: 线下支付套餐激活时 paid_amount 为 null
- **WHEN** 管理员以线下支付方式为客户激活套餐,`order.actual_paid_amount = nil`
- **THEN** `PackageUsage.paid_amount = nil`,接口响应中 `paid_amount` 字段缺省(omitempty)
#### Scenario: 自动购包任务激活套餐时快照实付金额
- **WHEN** C 端钱包充值触发 `auto_purchase` 任务,自动购买套餐,扣款 `paidAmount = 5900`
- **THEN** 激活生成的 `PackageUsage.paid_amount = 5900`
#### Scenario: 加油包激活时独立快照实付金额
- **WHEN** 用户购买加油包,对应订单 `actual_paid_amount = 2900`
- **THEN** 加油包 `PackageUsage.paid_amount = 2900`,与主套餐的 `paid_amount` 相互独立

View File

@@ -0,0 +1,32 @@
## 1. 数据库迁移
- [x] 1.1 新建迁移文件 `migrations/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 WHERE order_id != 0`)
- [x] 1.2 新建迁移文件 `migrations/000129_add_paid_amount_to_package_usage.down.sql`:`ALTER TABLE tb_package_usage DROP COLUMN IF EXISTS paid_amount`
- [x] 1.3 执行迁移并通过 PostgreSQL MCP 验证字段已添加,存量数据已回填
## 2. Model 层
- [x] 2.1 在 `internal/model/package.go` 的 `PackageUsage` 结构体中新增字段 `PaidAmount *int64`,添加 GORM tag `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照(分,从订单复制,无订单或线下支付时为null)"` 及 JSON tag `json:"paid_amount,omitempty"`
## 3. 套餐激活写入点
- [x] 3.1 `internal/service/order/service.go` `activateMainPackage()`:在初始化 `usage := &model.PackageUsage{...}` 中新增 `PaidAmount: order.ActualPaidAmount`
- [x] 3.2 `internal/service/order/service.go` `activateAddonPackage()`:在初始化 `usage := &model.PackageUsage{...}` 中新增 `PaidAmount: order.ActualPaidAmount`
- [x] 3.3 `internal/task/auto_purchase.go` `activateMainPackage()`(line ~507):在初始化 `usage := &model.PackageUsage{...}` 中新增 `PaidAmount: order.ActualPaidAmount`
- [x] 3.4 `internal/task/auto_purchase.go` `activateAddonPackage()`(line ~578):在初始化 `usage := &model.PackageUsage{...}` 中新增 `PaidAmount: order.ActualPaidAmount`
## 4. DTO 层
- [x] 4.1 `internal/model/dto/asset_dto.go` 的 `AssetPackageResponse` 结构体新增字段 `PaidAmount *int64`,添加 JSON tag `json:"paid_amount,omitempty"`,description tag `description:"购买实付金额(分),线下支付或无订单分配时为空"`
## 5. Asset Service 查询填充
- [x] 5.1 `internal/service/asset/service.go` `GetCurrentPackage()` 方法:在构建 `AssetPackageResponse` 时填充 `PaidAmount: usage.PaidAmount`
- [x] 5.2 `internal/service/asset/service.go` `GetPackages()` 方法:在构建每条 `AssetPackageResponse` 时填充 `PaidAmount: usage.PaidAmount`
## 6. 验证
- [x] 6.1 通过 PostgreSQL MCP 验证新套餐记录的 `paid_amount` 字段已正确写入(分别验证钱包支付和线下支付场景)
- [ ] 6.2 调用 `GET /api/admin/assets/{id}/current-package` 验证响应包含 `paid_amount` 字段
- [ ] 6.3 调用 `GET /api/admin/assets/{id}/packages` 验证响应 items 中包含 `paid_amount` 字段
- [x] 6.4 运行 `go build ./...` 确认无编译错误