feat: 新增套餐使用记录实付金额快照字段 paid_amount
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m19s
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:
@@ -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:"创建时间"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_package_usage DROP COLUMN IF EXISTS paid_amount;
|
||||
15
migrations/000129_add_paid_amount_to_package_usage.up.sql
Normal file
15
migrations/000129_add_paid_amount_to_package_usage.up.sql
Normal 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-18
|
||||
@@ -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
|
||||
|
||||
无。时序、字段设计、回填策略均已确认。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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` 相互独立
|
||||
@@ -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 ./...` 确认无编译错误
|
||||
@@ -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: 无当前生效主套餐
|
||||
|
||||
|
||||
50
openspec/specs/package-usage-paid-amount-snapshot/spec.md
Normal file
50
openspec/specs/package-usage-paid-amount-snapshot/spec.md
Normal 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` 相互独立
|
||||
Reference in New Issue
Block a user