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

@@ -111,6 +111,7 @@ type AssetPackageResponse struct {
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID加油包时有值"`
Priority int `json:"priority" description:"优先级"`
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买实付金额(分),线下支付或无订单分配时为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}

View File

@@ -78,6 +78,7 @@ type PackageUsage struct {
HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"`
PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:'';comment:套餐名称快照(从Package复制,用于历史记录展示)" json:"package_name"`
PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照从订单复制无订单或线下支付时为null" json:"paid_amount,omitempty"`
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`

View File

@@ -693,6 +693,7 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
PaidAmount: u.PaidAmount,
CreatedAt: u.CreatedAt,
}
all = append(all, item)
@@ -770,6 +771,7 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
PaidAmount: usage.PaidAmount,
CreatedAt: usage.CreatedAt,
}, nil
}

View File

@@ -1843,6 +1843,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
PaidAmount: order.ActualPaidAmount,
}
if carrierType == "iot_card" {
@@ -1928,6 +1929,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
ActivatedAt: &activatedAt,
ExpiresAt: &expiresAt,
DataResetCycle: pkg.DataResetCycle,
PaidAmount: order.ActualPaidAmount,
}
if carrierType == "iot_card" {

View File

@@ -519,6 +519,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation,
PaidAmount: order.ActualPaidAmount,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {
@@ -592,6 +593,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
ExpiresAt: expiresAt,
DataResetCycle: pkg.DataResetCycle,
Generation: order.Generation,
PaidAmount: order.ActualPaidAmount,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {

View File

@@ -0,0 +1 @@
ALTER TABLE tb_package_usage DROP COLUMN IF EXISTS paid_amount;

View File

@@ -0,0 +1,15 @@
-- 为 tb_package_usage 新增购买实付金额快照字段
-- paid_amount: 购买时从订单复制的实付金额nullable
-- 线下支付或企业无订单分配时为 null
ALTER TABLE tb_package_usage ADD COLUMN IF NOT EXISTS paid_amount BIGINT;
COMMENT ON COLUMN tb_package_usage.paid_amount IS '购买实付金额快照从订单复制无订单或线下支付时为null';
-- 存量数据回填:通过 order_id 关联 tb_order 回填 actual_paid_amount
-- order_id = 0 的记录(企业无订单分配)保持 null语义正确
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;

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 ./...` 确认无编译错误

View File

@@ -108,22 +108,28 @@
- `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 条记录,按创建时间倒序排列
- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列,每条记录含 `paid_amount` 字段
#### Scenario: 查询设备的套餐历史
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages`
- **THEN** 系统返回该设备 device_id 下的所有套餐记录
- **THEN** 系统返回该设备 device_id 下的所有套餐记录,含 `paid_amount` 字段
#### Scenario: 资产无套餐记录
- **WHEN** 管理员查询一张从未购买过套餐的卡
- **THEN** 系统返回空数组,不报错
#### Scenario: 线下支付套餐的 paid_amount 为 null
- **WHEN** 管理员查询一张通过线下支付购买套餐的卡
- **THEN** 对应套餐记录的 `paid_amount` 字段缺省omitempty不展示
---
### Requirement: 当前主套餐详情查询
@@ -138,12 +144,13 @@
**响应字段**:
- 完整套餐信息(同套餐历史列表中的单条记录字段)
- `paid_amount`: 购买时实付金额(分),线下支付或无订单分配时为 null
- 当无生效主套餐时,返回 HTTP 404
#### Scenario: 返回当前主套餐
#### Scenario: 返回当前主套餐(含实付金额)
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包
- **THEN** 系统只返回主套餐信息,不包含加油包
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包,主套餐 `paid_amount = 9900`
- **THEN** 系统只返回主套餐信息,响应中包含 `paid_amount: 9900`不包含加油包
#### Scenario: 无当前生效主套餐

View File

@@ -0,0 +1,50 @@
# package-usage-paid-amount-snapshot Specification
## Purpose
在套餐使用记录(`tb_package_usage`)创建时,快照购买该套餐对应订单的实付金额,使资产套餐接口能直接返回购买价格,无需 JOIN 订单表。
## 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` 相互独立