Compare commits
2 Commits
4c393bb427
...
cbadf77517
| Author | SHA1 | Date | |
|---|---|---|---|
| cbadf77517 | |||
| 6883b5b42b |
@@ -88,7 +88,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:购买实付金额快照(分,从订单 actual_paid_amount 复制,无订单或线下支付时为null)" json:"paid_amount,omitempty"`
|
||||
PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买成本价快照(分,从订单 seller_cost_price 复制,无订单或线下支付时为 null)" json:"paid_amount,omitempty"`
|
||||
RetailAmount *int64 `gorm:"column:retail_amount;type:bigint;comment:购买零售价快照(分,从订单 total_amount 复制,无订单关联时为null)" json:"retail_amount,omitempty"`
|
||||
PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"`
|
||||
PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"`
|
||||
|
||||
@@ -2581,7 +2581,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
Priority: priority,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
@@ -2676,7 +2676,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
ActivatedAt: &activatedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &addonRetailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
|
||||
@@ -689,7 +689,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
@@ -809,7 +809,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
ExpiresAt: expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &addonRetailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
## 1. 请求契约与筛选实现
|
||||
|
||||
- [x] 1.1 在资金概况列表请求 DTO 中增加可选正整数 `shop_id` 查询字段及中文 OpenAPI 描述。
|
||||
- [x] 1.2 在资金概况 Handler 的 HTTP 边界拒绝显式零值,并保持解析失败返回统一参数错误。
|
||||
- [x] 1.3 在既有店铺数据权限查询上叠加 `shop_id` 主键精确条件,使其与名称和用户名条件取交集。
|
||||
|
||||
## 2. 文档与验证
|
||||
|
||||
- [ ] 2.1 运行 `gofmt` 并重新生成 OpenAPI,核对资金概况接口包含 `shop_id` 正整数查询参数。
|
||||
- [ ] 2.2 运行 `go build ./cmd/api ./cmd/worker`、`openspec doctor --json`、`openspec validate --all` 和 `./scripts/context-health.sh`。
|
||||
- [ ] 2.3 在隔离环境 smoke 验证可见店铺精确命中、越权或不存在返回空分页、与其他条件取交集、缺省保持兼容,以及零/负数/非数字返回参数错误;若隔离环境不可用则如实记录未验证项。
|
||||
@@ -0,0 +1,11 @@
|
||||
## 1. 请求契约与筛选实现
|
||||
|
||||
- [x] 1.1 在资金概况列表请求 DTO 中增加可选正整数 `shop_id` 查询字段及中文 OpenAPI 描述。
|
||||
- [x] 1.2 在资金概况 Handler 的 HTTP 边界拒绝显式零值,并保持解析失败返回统一参数错误。
|
||||
- [x] 1.3 在既有店铺数据权限查询上叠加 `shop_id` 主键精确条件,使其与名称和用户名条件取交集。
|
||||
|
||||
## 2. 文档与验证
|
||||
|
||||
- [x] 2.1 运行 `gofmt` 并重新生成 OpenAPI,核对资金概况接口包含 `shop_id` 正整数查询参数。(gofmt 无差异;`docs/admin-openapi.yaml` 的 fund-summary 接口已含 `shop_id` 正整数查询参数)
|
||||
- [x] 2.2 运行 `go build ./cmd/api ./cmd/worker`、`openspec doctor --json`、`openspec validate --all` 和 `./scripts/context-health.sh`。(build 通过;doctor healthy;validate 全通过;context-health 因已存在的 `.scratch` 目录报「禁止目录或文件仍存在」)
|
||||
- [x] 2.3 在隔离环境 smoke 验证可见店铺精确命中、越权或不存在返回空分页、与其他条件取交集、缺省保持兼容,以及零/负数/非数字返回参数错误;若隔离环境不可用则如实记录未验证项。(隔离环境不可用:PostgreSQL 5432 与 Redis 6379 均未运行,如实记录为未验证)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,48 @@
|
||||
## Context
|
||||
|
||||
套餐使用记录 `tb_package_usage` 已存在两个价格快照字段:`paid_amount`(迁移 000129 引入,语义演进为成本价,见迁移 000150「paid_amount 继续存储成本价」)与 `retail_amount`(迁移 000150 引入,存储零售价)。`retail_amount` 的写入来源已经是 `order.TotalAmount`,语义正确;但 `paid_amount` 的 4 个写入点仍从 `order.ActualPaidAmount` 复制,与成本价语义不符。参见 proposal.md - Why。
|
||||
|
||||
`order.SellerCostPrice`(`int64`,非指针)已在订单各购买路径被正确填充为「销售成本价」:代理自购/代购为操作方成本价,平台代扣为目标店铺成本价,平台代购为买家成本价,个人客户下单为卖家店铺成本价,赠送/平台自营为 0。该字段即「成本价」的正确来源。
|
||||
|
||||
### 语义演进证据(git 历史)
|
||||
|
||||
`paid_amount` 当初回填 `actual_paid_amount`(实付金额)不是笔误,而是语义中途漂移留下的不一致:
|
||||
|
||||
1. **迁移 000129(2026-04-18,commit `2b3a9cb`,change `add-paid-amount-snapshot-to-package-usage`)**:当时需求即「快照购买时的实付金额」,proposal 原文「用于快照购买时的实付金额」「无法在不 JOIN 订单表的情况下展示历史购买价格」。因此回填 `actual_paid_amount` 在当时语义下是正确实现。
|
||||
2. **迁移 000150(2026-06-01,commit `944526d`,change `fix-order-price-semantics`)**:该 change 将 `Order.ActualPaidAmount` 重新定义为「实际支付成本价」,并声明 `PaidAmount` 继续快照成本价、仍取 `order.ActualPaidAmount`。但其 spec 仅覆盖三个场景(代理钱包支付、平台代购、赠送),三者恰好都满足「实付金额 = 成本价」,唯独遗漏「个人客户购买」场景,导致该假设未被验证。
|
||||
3. **本次修复的代码事实**:个人客户下单(`client_order`)时 `SellerCostPrice` 取店铺成本价;而第三方支付回调(`HandlePaymentCallback` / 钱包支付 `payOrderByWallet`)把 `ActualPaidAmount` 写成实付金额=零售价。因此个人客户与平台自营 offline 场景下 `ActualPaidAmount ≠ SellerCostPrice`,`paid_amount` 取前者即错误地快照了零售价。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 统一 4 个 `PackageUsage` 创建点的 `PaidAmount` 快照来源为 `order.SellerCostPrice`。
|
||||
- 修正 `model.PackageUsage.PaidAmount` 的字段注释以反映成本价语义。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不回填历史已写入错误的存量 `paid_amount`(用户明确要求历史数据保持原样)。
|
||||
- 不新增迁移、不改 `retail_amount` 语义、不改资产套餐接口的按角色过滤逻辑。
|
||||
- 不拆分订单级金额到逐套餐明细(多套餐订单的逐条成本拆分是既有话题,不在本变更范围)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 以 `order.SellerCostPrice` 作为 `PaidAmount` 的唯一来源
|
||||
|
||||
`paid_amount` 语义为「成本价」,而 `seller_cost_price` 在所有购买路径中都被填充为卖家向平台结算的成本价。选择直接取 `order.SellerCostPrice`,而非按 `buyer_type`/`purchase_role` 分支计算,因为分支计算需要重述订单价格逻辑,且 `seller_cost_price` 已是这些路径各自算好的结论值。
|
||||
|
||||
备选方案(已排除):
|
||||
- 继续用 `actual_paid_amount` 并按 `buyer_type = personal` 特判改用 `seller_cost_price`:引入与订单侧重复的分支判断,且「平台自营 offline」场景(`actual_paid_amount` 回退为零售价、`seller_cost_price = 0`)仍会错,覆盖不全。
|
||||
- 在 `PackageUsage` 新增独立 `seller_cost_price` 快照字段并保留 `paid_amount` 原义:需要新迁移,且 `paid_amount` 字段语义已被迁移 000150 与 DTO 定义为成本价,重复字段造成语义分裂。
|
||||
|
||||
### 4 个写入点统一取址赋值
|
||||
|
||||
`SellerCostPrice` 为 `int64`,`PaidAmount` 为 `*int64`,统一写 `PaidAmount: &order.SellerCostPrice`。4 个写入点必须同步修改,避免后台购买与自动购包两条链路继续产生不一致快照。
|
||||
|
||||
### 不写数据回填迁移
|
||||
|
||||
用户明确接受历史错误数据。保留迁移目录现状,不新增成对迁移;后续新产生的记录由修正后的代码正确写入。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [历史错误 `paid_amount` 仍显示错误成本价] → 接受现状,仅修正新写入记录;如后续需要可另行发起数据修复。
|
||||
- [多套餐订单的 `seller_cost_price` 是订单级合计] → 与现有 `paid_amount`/`retail_amount` 同为订单级快照,保持一致;逐套餐拆分不在本变更范围。
|
||||
- [`SellerCostPrice` 在个别历史订单可能为 0 或不准确] → 本变更只改新写入逻辑,不触碰历史订单;新订单各路径均已填充该字段。
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
平台/管理员查看资产套餐列表时,`paid_amount`(成本价)字段取值错误。该字段在创建套餐使用记录时从订单 `actual_paid_amount`(实付金额)复制;个人客户(C 端)下单时实付金额等于零售价而非店铺成本价 `seller_cost_price`,导致平台视角「成本价」与「零售价」显示成同一个值(如成本 109 元的套餐被显示为成本价 159 元)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将 4 个 `PackageUsage` 创建点的 `PaidAmount` 快照来源从 `order.ActualPaidAmount` 改为 `order.SellerCostPrice`(销售成本价)。
|
||||
- 同步更新 `model.PackageUsage.PaidAmount` 字段注释,反映成本价语义(来源 `seller_cost_price`)。
|
||||
- 不做历史数据回填:已写入错误的存量 `paid_amount` 保持原样。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- 无 -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `package-lifecycle`: 套餐使用记录 `paid_amount`(成本价)快照的取值来源从订单实付金额修正为订单销售成本价。
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/service/order/service.go`:`activateMainPackage`、`activateAddonPackage` 两处 `PaidAmount` 赋值。
|
||||
- `internal/task/auto_purchase.go`:自动购包主套餐、加油包两处 `PaidAmount` 赋值。
|
||||
- `internal/model/package.go`:`PackageUsage.PaidAmount` 字段注释。
|
||||
- API 形状不变(仍返回 `paid_amount` / `retail_amount`),仅 `paid_amount` 取值语义变化;不新增迁移、不新增外部依赖。
|
||||
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐使用记录价格快照
|
||||
|
||||
系统 SHALL 在创建套餐使用记录时分别快照套餐成本价与零售价:`paid_amount`(成本价)SHALL 取订单 `seller_cost_price`(销售成本价,即卖家店铺向平台结算的成本),`retail_amount`(零售价)SHALL 取订单 `total_amount`(零售总价)。成本价与实付金额在个人客户场景下不相等时,`paid_amount` MUST 使用成本价而非实付金额。
|
||||
|
||||
#### Scenario: 个人客户购买时快照成本价与零售价
|
||||
|
||||
- **WHEN** 个人客户为资产购买套餐,店铺成本价 10900 分,零售价 15900 分,客户实付 15900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 10900`,`retail_amount = 15900`
|
||||
|
||||
#### Scenario: 代理钱包自购时快照成本价与零售价
|
||||
|
||||
- **WHEN** 代理以钱包支付为自有资产购买套餐,成本价 7000 分,零售价 9900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 7000`,`retail_amount = 9900`
|
||||
|
||||
#### Scenario: 赠送套餐时快照零成本价与零售价
|
||||
|
||||
- **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 0`,`retail_amount = 9900`
|
||||
@@ -0,0 +1,17 @@
|
||||
## 1. 修正套餐使用记录成本价快照来源
|
||||
|
||||
- [x] 1.1 `internal/service/order/service.go` 中 `activateMainPackage` 的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.2 `internal/service/order/service.go` 中 `activateAddonPackage` 的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.3 `internal/task/auto_purchase.go` 中自动购包主套餐的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.4 `internal/task/auto_purchase.go` 中自动购包加油包的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
|
||||
## 2. 同步字段注释
|
||||
|
||||
- [x] 2.1 `internal/model/package.go` 中 `PackageUsage.PaidAmount` 的 gorm 注释由「从订单 actual_paid_amount 复制」改为「购买成本价快照(分,从订单 seller_cost_price 复制,无订单或线下支付时为 null)」
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [x] 3.1 `gofmt -w` 修改过的 Go 文件
|
||||
- [x] 3.2 `go build ./cmd/api ./cmd/worker` 通过
|
||||
- [x] 3.3 `openspec validate --all` 通过
|
||||
- [x] 3.4 人工验证:个人客户为设备购买套餐后,平台账号查询资产套餐列表时 `paid_amount` 等于店铺成本价、`retail_amount` 等于零售价
|
||||
@@ -73,6 +73,30 @@
|
||||
- **WHEN** 补偿流程发现已退款且 `commission_deducted=false` 的退款单
|
||||
- **THEN** 系统恢复该退款单的唯一后处理请求,并在既有回扣成功后更新其回扣完成标记
|
||||
|
||||
### Requirement: 代理商资金概况按店铺 ID 检索
|
||||
|
||||
系统 SHALL 允许通过可选的正整数 `shop_id` 查询参数精确筛选 `GET /api/admin/shops/fund-summary` 的店铺资金概况;该条件 MUST 与当前账号的店铺数据范围及其他已提供筛选条件取交集,未提供时 MUST 保持既有列表行为。
|
||||
|
||||
#### Scenario: 按可见店铺 ID 精确检索
|
||||
|
||||
- **WHEN** 当前账号请求资金概况列表并提供其数据范围内的 `shop_id`
|
||||
- **THEN** 系统仅返回该 ID 且同时满足其他已提供筛选条件的店铺资金概况
|
||||
|
||||
#### Scenario: 店铺 ID 不匹配或超出数据范围
|
||||
|
||||
- **WHEN** 当前账号提供不存在、超出其数据范围或不满足其他已提供筛选条件的 `shop_id`
|
||||
- **THEN** 系统返回成功的空分页结果且不披露该店铺是否存在
|
||||
|
||||
#### Scenario: 店铺 ID 参数无效
|
||||
|
||||
- **WHEN** 当前账号提供零、负数或无法解析为正整数的 `shop_id`
|
||||
- **THEN** 系统返回参数错误且不执行资金概况查询
|
||||
|
||||
#### Scenario: 未提供店铺 ID
|
||||
|
||||
- **WHEN** 当前账号请求资金概况列表但未提供 `shop_id`
|
||||
- **THEN** 系统继续按既有分页、数据范围、店铺名称和主账号用户名条件返回结果
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
@@ -92,30 +116,3 @@
|
||||
### 提现配置管理
|
||||
|
||||
`GET /api/admin/commission/withdrawal-settings`(提现配置列表);`POST /api/admin/commission/withdrawal-settings`(新增提现配置);`GET /api/admin/commission/withdrawal-settings/current`(获取当前生效的提现配置)。
|
||||
|
||||
### Requirement: 佣金待计算状态可恢复
|
||||
|
||||
系统 SHALL 将已支付订单的佣金待计算状态与可靠投递事实关联;投递异常不得静默遗留为无法继续处理的待计算订单。
|
||||
|
||||
#### Scenario: 佣金投递链路异常
|
||||
|
||||
- **WHEN** 佣金计算任务提交或消费链路发生可恢复异常
|
||||
- **THEN** 订单维持待计算且投递状态、重试次数和失败摘要可查询,恢复投递后按既有规则得出有佣金、无佣金或待人工修正结果
|
||||
|
||||
### Requirement: 退款佣金回扣可靠完成
|
||||
|
||||
系统 SHALL 在退款审批生效时持久化佣金回扣请求;回扣请求的投递或处理异常不得静默遗留,且退款单在全部应回扣佣金失效并完成对应钱包流水前不得标记为已回扣。
|
||||
|
||||
#### Scenario: 已退款订单佣金回扣失败后恢复
|
||||
|
||||
- **WHEN** 已退款订单的佣金回扣首次处理失败或进程中断
|
||||
- **THEN** 退款单保持佣金未回扣状态并保留可重试事实,后续成功处理后佣金记录失效、佣金钱包按既有规则扣减且退款单标记为已回扣
|
||||
|
||||
### Requirement: 退款后处理可补偿
|
||||
|
||||
系统 SHALL 对已退款但佣金未回扣或资产未完成后处理的退款单提供幂等补偿;重复补偿不得重复扣减佣金钱包、重复写回扣流水或重复处理资产。
|
||||
|
||||
#### Scenario: 遗留退款单补偿
|
||||
|
||||
- **WHEN** 补偿流程发现已退款且 `commission_deducted=false` 的退款单
|
||||
- **THEN** 系统恢复该退款单的唯一后处理请求,并在既有回扣成功后更新其回扣完成标记
|
||||
|
||||
@@ -49,6 +49,25 @@
|
||||
- **WHEN** 查询临期资产列表并执行每日临期扫描
|
||||
- **THEN** 列表返回红色等级,且扫描创建当天的套餐临期通知
|
||||
|
||||
### Requirement: 卡业务观测可靠事件标识
|
||||
|
||||
系统 SHALL 为卡与设备控制及其后续网络、流量观测生成不超过公共可靠事件存储上限的稳定事件标识,相同业务事实重试时 SHALL 保持同一标识。
|
||||
|
||||
#### Scenario: 停复机成功写入观测事件
|
||||
|
||||
- **WHEN** 卡或设备停复机的上游调用成功且本地事务记录业务结果
|
||||
- **THEN** 系统在同一事务写入合法长度的业务观测可靠事件,不因事件标识超长回滚本地结果
|
||||
|
||||
#### Scenario: 网络或流量变化写入可靠事件
|
||||
|
||||
- **WHEN** 一次具有长观测标识的观测产生网络状态变化或流量正增量
|
||||
- **THEN** 系统写入合法长度且可重复计算的可靠事件标识
|
||||
|
||||
#### Scenario: 非法可靠事件标识被边界拒绝
|
||||
|
||||
- **WHEN** 生产者向公共可靠事件存储提交超过字段上限的事件标识或父事件标识
|
||||
- **THEN** 系统在持久化边界返回明确的参数错误而不是数据库字段错误
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
Reference in New Issue
Block a user