From 2b3a9cb33fbf88daee9f68a6dcacc4e4c48678e7 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 18 Apr 2026 10:19:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=AE=B0=E5=BD=95=E5=AE=9E=E4=BB=98=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E5=BF=AB=E7=85=A7=E5=AD=97=E6=AE=B5=20paid=5Famount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增迁移 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 订单表 --- internal/model/dto/asset_dto.go | 1 + internal/model/package.go | 1 + internal/service/asset/service.go | 2 + internal/service/order/service.go | 2 + internal/task/auto_purchase.go | 2 + ..._add_paid_amount_to_package_usage.down.sql | 1 + ...29_add_paid_amount_to_package_usage.up.sql | 15 +++ .../.openspec.yaml | 2 + .../design.md | 100 ++++++++++++++++++ .../proposal.md | 49 +++++++++ .../specs/asset-queries/spec.md | 78 ++++++++++++++ .../spec.md | 44 ++++++++ .../tasks.md | 32 ++++++ openspec/specs/asset-queries/spec.md | 17 ++- .../spec.md | 50 +++++++++ 15 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 migrations/000129_add_paid_amount_to_package_usage.down.sql create mode 100644 migrations/000129_add_paid_amount_to_package_usage.up.sql create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/design.md create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/proposal.md create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/asset-queries/spec.md create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/package-usage-paid-amount-snapshot/spec.md create mode 100644 openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/tasks.md create mode 100644 openspec/specs/package-usage-paid-amount-snapshot/spec.md diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 47b93b7..de18829 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -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:"创建时间"` } diff --git a/internal/model/package.go b/internal/model/package.go index c4f1483..45b5de1 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -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"` diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 2f10fa3..5941563 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -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 } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index eaf2bb4..0c25fbd 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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" { diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index d10e393..b027433 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -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 { diff --git a/migrations/000129_add_paid_amount_to_package_usage.down.sql b/migrations/000129_add_paid_amount_to_package_usage.down.sql new file mode 100644 index 0000000..56c3343 --- /dev/null +++ b/migrations/000129_add_paid_amount_to_package_usage.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_package_usage DROP COLUMN IF EXISTS paid_amount; diff --git a/migrations/000129_add_paid_amount_to_package_usage.up.sql b/migrations/000129_add_paid_amount_to_package_usage.up.sql new file mode 100644 index 0000000..f315813 --- /dev/null +++ b/migrations/000129_add_paid_amount_to_package_usage.up.sql @@ -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; diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/.openspec.yaml b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/.openspec.yaml new file mode 100644 index 0000000..204fc5a --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-18 diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/design.md b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/design.md new file mode 100644 index 0000000..92fffa0 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/design.md @@ -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 + +无。时序、字段设计、回填策略均已确认。 diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/proposal.md b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/proposal.md new file mode 100644 index 0000000..397a6b9 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/asset-queries/spec.md b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/asset-queries/spec.md new file mode 100644 index 0000000..dfb6505 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/asset-queries/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/package-usage-paid-amount-snapshot/spec.md b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/package-usage-paid-amount-snapshot/spec.md new file mode 100644 index 0000000..29e4a31 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/specs/package-usage-paid-amount-snapshot/spec.md @@ -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` 相互独立 diff --git a/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/tasks.md b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/tasks.md new file mode 100644 index 0000000..6622f06 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-add-paid-amount-snapshot-to-package-usage/tasks.md @@ -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 ./...` 确认无编译错误 diff --git a/openspec/specs/asset-queries/spec.md b/openspec/specs/asset-queries/spec.md index 989e012..520bbe9 100644 --- a/openspec/specs/asset-queries/spec.md +++ b/openspec/specs/asset-queries/spec.md @@ -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: 无当前生效主套餐 diff --git a/openspec/specs/package-usage-paid-amount-snapshot/spec.md b/openspec/specs/package-usage-paid-amount-snapshot/spec.md new file mode 100644 index 0000000..8a49083 --- /dev/null +++ b/openspec/specs/package-usage-paid-amount-snapshot/spec.md @@ -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` 相互独立