docs(pricing): mediaPrices 实现计划 v0.4

This commit is contained in:
2026-09-01 14:37:19 +08:00
parent 90ad1b86db
commit 11486c0647
@@ -0,0 +1,615 @@
# 模型计费 mediaPrices(媒体类型价)与阶梯校验修复 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将模型费率规则的"输入媒体"维度从 `match.mediaType` 命中条件改为规则级 `mediaPrices` 媒体价 map(默认 `price` = 不含以下媒体),并重写阶梯档位重叠校验(按长度下界排序判相邻 + 修同起 0 漏检),使"有音频/无音频 × 档位"矩阵配置可保存。
**Architecture:**`service/pricing/charge_calc.go` + `consts/pricing/charge_mode.go` 变更。`modelRule``MediaPrices map[string]*modelPrice``modelMatch``MediaType`;运行时新增 `pickModelPrice(rule, usage)`mediaPrices 命中用媒体价,否则默认价)供 token/unit 两个 model 计算器共用;`validateTieredBands` 重写 + 共享 `validateMediaPrices` helper(两计算器共用)。
**Tech Stack:** Go 1.26.1, 标准库 `encoding/json`/`errors`/`sort`/`math`, 标准库 testingTDD)。
## Global Constraints
- 金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`
- `modelRule``MediaPrices map[string]*modelPrice``modelMatch` **删 `MediaType` 字段**`modelRuleMatch` **删 mediaType 等值判定**(仅此一处删匹配逻辑)
- 运行时取价统一 `pickModelPrice(rule, usage)``mediaPrices[usage.MediaType]` 命中用媒体价,否则默认 `rule.Price``modelTokenCalculator.charge``modelUnitCalculator.charge` 都改
- `validateTieredBands`:带长度区间规则按下界升序后**相邻不重叠**;无长度规则不参与;上不封顶档(max=0)须为最后一个长度档;**修复两个同起 0 档位漏检**
- `mediaPrices` 校验:键须 ∈ `ModelMediaSet``text/audio/video/image`),值非 nil`modelTokenCalculator.validate``modelUnitCalculator.validate` 都加,共享 `validateMediaPrices` helper
- `ChargeUsage.MediaType` 字段**保留**(上报入参,不删)
- 不动 workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位
- **提交排除用户 WIP**:工作树有大量未完成改动(钱包重构、老 pricing 测试删除等,`git status --short` 可查)。commit 一律 `git add <精确路径> && git commit --only <精确路径>`,提交前 `git status --short` 核对只含本任务文件
- **注意**`service/pricing/charge_calc.go` 工作树已含用户未提交 WIP(删 `ItemCount`/`mode()` 等清理,`go build` 已通过)。本计划的编辑叠加其上,commit 该文件时会一并带上这批 WIP 改动(同区域清理,无法用 `--only` 分离,属预期)
- 测试命令(已验证无需额外 env):`cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run <TestName> -count=1`
- 错误文案与 spec §5.2 逐字一致:`"model 费率 mediaPrices 键须为 text/audio/video/image"``"model 费率 mediaPrices 值须配置 price"``"阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax"``"阶梯档位区间重叠:上不封顶档后不能再有档位"`
---
## Task 1: 常量 ModelMediaSet + 结构变更(mediaPrices 入 modelRule、match 去 MediaType
**Files:**
- Modify: `consts/pricing/charge_mode.go``ModelUnitSet` 块之后追加)
- Modify: `service/pricing/charge_calc.go``modelRule` 204-208、`modelMatch` 211-218、`modelRuleMatch` 239-241
**Interfaces:**
- Produces: `pricingConsts.ModelMediaSet map[string]struct{}{text,audio,video,image}``modelRule` 新增 `MediaPrices` 字段;`modelMatch``MediaType``modelRuleMatch` 不再读 `m.MediaType`
- 纯结构变更,无行为变化;Task 2-4 依赖此结构
- [ ] **Step 1: `consts/pricing/charge_mode.go` 追加 ModelMediaSet**
`ModelUnitSet` 块(第 23 行 `}`)之后追加:
```go
// ModelMediaSet 模型费率 mediaPrices 允许的媒体类型键(输入媒体白名单,前端下拉同源)
var ModelMediaSet = map[string]struct{}{
"text": {}, "audio": {}, "video": {}, "image": {},
}
```
- [ ] **Step 2: `service/pricing/charge_calc.go` 改 `modelRule`204-208**
```go
type modelRule struct {
Name string `json:"name"`
Match *modelMatch `json:"match,omitempty"` // 空=任意调用
Price *modelPrice `json:"price"` // 默认价:输入不含以下媒体
MediaPrices map[string]*modelPrice `json:"mediaPrices,omitempty"` // 媒体类型→该媒体价(输入含该媒体时)
}
```
- [ ] **Step 3: `modelMatch` 删 `MediaType` 字段(212**
```go
type modelMatch struct {
Thinking *bool `json:"thinking,omitempty"` // 思考/非思考
OutputAudio *bool `json:"outputAudio,omitempty"` // 输出有声/无声
OutputResolution string `json:"outputResolution,omitempty"` // 输出分辨率
InputLengthMin int64 `json:"inputLengthMin,omitempty"` // 输入token下界
InputLengthMax int64 `json:"inputLengthMax,omitempty"` // 输入token上界
}
```
- [ ] **Step 4: `modelRuleMatch` 删 mediaType 等值判定(239-241**
删除以下 3 行(整个 if 块):
```go
if m.MediaType != "" && m.MediaType != u.MediaType {
return false
}
```
- [ ] **Step 5: 编译验证**
Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./consts/... ./service/pricing/`
Expected: PASS(无输出即成功)
- [ ] **Step 6: 提交**
```bash
git add consts/pricing/charge_mode.go service/pricing/charge_calc.go
git status --short # 确认暂存区只含上述两文件(charge_calc.go 会带上已有 WIP 清理)
git commit --only consts/pricing/charge_mode.go service/pricing/charge_calc.go -m "refactor(pricing): modelRule 加 mediaPrices + match 移除 mediaType"
```
---
## Task 2: 重写 validateTieredBandsTDD
**Files:**
- Test: `service/pricing/charge_calc_test.go`(新建)
- Modify: `service/pricing/charge_calc.go`import 加 `sort`305-319 重写)
**Interfaces:**
- Consumes: Task 1 的 `modelRule`/`modelMatch` 新结构
- Produces: 新语义 `validateTieredBands(rules []modelRule) error`——带长度规则按下界升序判相邻;无长度规则跳过;上不封顶最后
- [ ] **Step 1: 写失败测试 `service/pricing/charge_calc_test.go`**
```go
package service
import "testing"
func boolPtr(v bool) *bool { return &v }
func TestValidateTieredBandsAdjacentOK(t *testing.T) {
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 128000}},
{Match: &modelMatch{InputLengthMin: 128001, InputLengthMax: 256000}},
}
if err := validateTieredBands(rules); err != nil {
t.Fatalf("相邻档位应通过: %v", err)
}
}
func TestValidateTieredBandsSameStartRejected(t *testing.T) {
// 旧实现对 InputLengthMin==0 的规则对 continue,两个同起 0 的档位查不出重叠
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 64000}},
}
if err := validateTieredBands(rules); err == nil {
t.Fatal("两个同起 0 的档位必须判重叠")
}
}
func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) {
// 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检)
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}},
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}},
}
if err := validateTieredBands(rules); err == nil {
t.Fatal("重叠必须与提交顺序无关地被检出")
}
}
func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) {
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
}
if err := validateTieredBands(rules); err != nil {
t.Fatalf("上不封顶在最后应通过: %v", err)
}
}
func TestValidateTieredBandsOpenEndedNotLastRejected(t *testing.T) {
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
{Match: &modelMatch{InputLengthMin: 64001, InputLengthMax: 96000}},
}
if err := validateTieredBands(rules); err == nil {
t.Fatal("上不封顶档之后不能再有长度档")
}
}
func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) {
// thinking 兜底档无长度区间,不应参与重叠校验
rules := []modelRule{
{Match: &modelMatch{Thinking: boolPtr(false)}},
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
}
if err := validateTieredBands(rules); err != nil {
t.Fatalf("无长度规则不应干扰: %v", err)
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1 2>&1 | head -20`
Expected: FAIL`TestValidateTieredBandsSameStartRejected``TestValidateTieredBandsUnsortedOverlapDetected` 因旧实现返回 nil 而失败)
- [ ] **Step 3: 重写 `validateTieredBands`305-319+ import 加 `sort`**
import 块第 3 行 `"errors"` 之后加 `"sort"`
```go
import (
"encoding/json"
"errors"
"fmt"
"math"
"sort"
pricingConsts "shop-user-trade/consts/pricing"
)
```
替换整个旧函数(305-319):
```go
// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。
// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。
func validateTieredBands(rules []modelRule) error {
var bands []modelRule
for _, r := range rules {
if r.Match != nil && (r.Match.InputLengthMin > 0 || r.Match.InputLengthMax > 0) {
bands = append(bands, r)
}
}
sort.Slice(bands, func(i, j int) bool {
return bands[i].Match.InputLengthMin < bands[j].Match.InputLengthMin
})
var prevMax int64 = -1
openEnded := false
for _, r := range bands {
m := r.Match
if openEnded {
return errors.New("阶梯档位区间重叠:上不封顶档后不能再有档位")
}
if m.InputLengthMin <= prevMax {
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
}
if m.InputLengthMax == 0 {
openEnded = true // 上不封顶,之后不可再有长度档
} else {
prevMax = m.InputLengthMax
}
}
return nil
}
```
- [ ] **Step 4: 运行确认通过**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestValidateTieredBands -count=1`
Expected: PASS6 个用例)
- [ ] **Step 5: 提交**
```bash
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
git status --short
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "fix(pricing): validateTieredBands 按下界排序判档位相邻 + 修同起 0 漏检"
```
---
## Task 3: mediaPrices 键/值校验(TDD
**Files:**
- Test: `service/pricing/charge_calc_test.go`(末尾追加)
- Modify: `service/pricing/charge_calc.go`(新增 `validateMediaPrices` helpertoken validate 292-296、unit validate 359-363 各加一处调用)
**Interfaces:**
- Consumes: Task 1 的 `ModelMediaSet``modelRule.MediaPrices`
- Produces: `validateMediaPrices(mp map[string]*modelPrice) error`(共享 helper,两计算器调用)
- [ ] **Step 1: 追加失败测试**
```go
func TestTokenValidateMediaPricesBadKey(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audios":{"input":2}}}]}`)
if err == nil {
t.Fatal("mediaPrices 非法键必须报错")
}
}
func TestTokenValidateMediaPricesNilValue(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":null}}]}`)
if err == nil {
t.Fatal("mediaPrices 值为空必须报错")
}
}
func TestTokenValidateMediaPricesValid(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
_, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":1},"mediaPrices":{"audio":{"input":2},"video":{"input":3}}}]}`)
if err != nil {
t.Fatalf("合法 mediaPrices 应通过: %v", err)
}
}
func TestUnitValidateMediaPricesBadKey(t *testing.T) {
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"snd":{"unitPrice":2}}}]}`)
if err == nil {
t.Fatal("mediaPrices 非法键必须报错")
}
}
func TestUnitValidateMediaPricesValid(t *testing.T) {
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
_, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
if err != nil {
t.Fatalf("合法 mediaPrices 应通过: %v", err)
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1 2>&1 | head -20`
Expected: FAIL`TestTokenValidateMediaPricesBadKey``TestTokenValidateMediaPricesNilValue``TestUnitValidateMediaPricesBadKey` 旧实现不校验而失败)
- [ ] **Step 3: 新增 `validateMediaPrices` helper + 两处调用**
`modelRuleMatch` 之后(`matchModelRule` 之前)插入:
```go
// validateMediaPrices mediaPrices 校验:键须 ∈ ModelMediaSet,值须配置 pricetoken/unit 计算器共用)
func validateMediaPrices(mp map[string]*modelPrice) error {
for mt, p := range mp {
if !pricingConsts.ModelMediaSet[mt] {
return errors.New("model 费率 mediaPrices 键须为 text/audio/video/image")
}
if p == nil {
return errors.New("model 费率 mediaPrices 值须配置 price")
}
}
return nil
}
```
`modelTokenCalculator.validate` 的规则循环(292-296)改为:
```go
for i := range r.Rules {
if r.Rules[i].Price == nil {
return nil, errors.New("model 费率规则须配置 price")
}
if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil {
return nil, err
}
}
```
`modelUnitCalculator.validate` 的规则循环(359-363)改为:
```go
for i := range r.Rules {
if r.Rules[i].Price == nil {
return nil, errors.New("model 费率规则须配置 price")
}
if err := validateMediaPrices(r.Rules[i].MediaPrices); err != nil {
return nil, err
}
}
```
- [ ] **Step 4: 运行确认通过**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestMediaPrices -count=1`
Expected: PASS5 个用例)
- [ ] **Step 5: 提交**
```bash
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
git status --short
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): mediaPrices 键/值校验 + ModelMediaSet 白名单"
```
---
## Task 4: pickModelPrice 运行时取价 + 两处 charge 接线(TDD
**Files:**
- Test: `service/pricing/charge_calc_test.go`(末尾追加)
- Modify: `service/pricing/charge_calc.go`(新增 `pickModelPrice`token charge 327 改 `p := pickModelPrice(rule, usage)`unit charge 373 改)
**Interfaces:**
- Consumes: Task 1 的 `modelRule.MediaPrices``ChargeUsage.MediaType`(保留字段)
- Produces: `pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice`
- [ ] **Step 1: 追加失败测试**
```go
func TestTokenChargeMediaPricesAudio(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
rules, err := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
if err != nil {
t.Fatalf("validate: %v", err)
}
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "audio"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 4.5 {
t.Fatalf("含音频应收媒体价 4.5,实收 %v", got)
}
}
func TestTokenChargeDefaultWithoutMedia(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
rules, _ := c.validate(`{"unit":"per_1M","rules":[{"name":"r","price":{"input":0.3,"output":1.8,"cacheHit":0.12},"mediaPrices":{"audio":{"input":4.5,"output":1.8,"cacheHit":1.8}}}]}`)
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1e6, MediaType: "text"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 0.3 {
t.Fatalf("不含媒体应收默认价 0.3,实收 %v", got)
}
}
func TestTokenChargeMediaPricesMultiType(t *testing.T) {
c := modelTokenCalculator{base: 1000}
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5},"video":{"input":6}}}]}`)
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 6 {
t.Fatalf("video 应收媒体价 6,实收 %v", got)
}
}
func TestTokenChargeMediaPricesUnmatchedTypeUsesDefault(t *testing.T) {
// mediaPrices 只有 audiovideo 输入落默认价
c := modelTokenCalculator{base: 1000}
rules, _ := c.validate(`{"unit":"per_1K","rules":[{"name":"r","price":{"input":0.3},"mediaPrices":{"audio":{"input":4.5}}}]}`)
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "video"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 0.3 {
t.Fatalf("未配 mediaPrices 的媒体应收默认价 0.3,实收 %v", got)
}
}
func TestUnitChargeMediaPrices(t *testing.T) {
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
rules, err := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
if err != nil {
t.Fatalf("validate: %v", err)
}
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "audio"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 6 {
t.Fatalf("audio 单位价应收 6,实收 %v", got)
}
}
func TestUnitChargeMediaPricesDefault(t *testing.T) {
c := modelUnitCalculator{base: 1, usage: usageDurationSec}
rules, _ := c.validate(`{"unit":"per_second","rules":[{"name":"r","price":{"unitPrice":1},"mediaPrices":{"audio":{"unitPrice":2}}}]}`)
got, err := c.charge(rules, &ChargeUsage{DurationSec: 3, MediaType: "video"})
if err != nil {
t.Fatalf("charge: %v", err)
}
if got != 3 {
t.Fatalf("未配媒体价应收默认 3,实收 %v", got)
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -run TestTokenChargeMediaPricesAudio -count=1 2>&1 | head -15`
Expected: FAIL(旧实现 `p := rule.Price`,含音频用例收 0.3 而非 4.5)
- [ ] **Step 3: 新增 `pickModelPrice` + 两处 charge 接线**
`matchModelRule` 之后(token 计算器之前)插入:
```go
// pickModelPrice 命中规则后按输入媒体取价:mediaPrices 命中使用媒体价,否则默认 price。
func pickModelPrice(rule *modelRule, usage *ChargeUsage) *modelPrice {
if len(rule.MediaPrices) > 0 {
if p, ok := rule.MediaPrices[usage.MediaType]; ok {
return p
}
}
return rule.Price
}
```
`modelTokenCalculator.charge` 第 327 行 `p := rule.Price` 改为:
```go
p := pickModelPrice(rule, usage)
```
`modelUnitCalculator.charge` 第 373 行改为:
```go
p := pickModelPrice(rule, usage)
return ceilFen(roundCost(c.usage(usage) / c.base * p.UnitPrice)), nil
```
`validate` 已保证 `rule.Price``mediaPrices` 各值为非 nil`pickModelPrice` 返回必非 nil。)
- [ ] **Step 4: 运行确认通过**
Run: `cd /c/App/GolandProjects/shop-user-trade && go test ./service/pricing/ -count=1`
Expected: PASS(全部用例,含 Task 2/3/4 共 17 个)
- [ ] **Step 5: 提交**
```bash
git add service/pricing/charge_calc.go service/pricing/charge_calc_test.go
git status --short
git commit --only service/pricing/charge_calc.go service/pricing/charge_calc_test.go -m "feat(pricing): pickModelPrice 按输入媒体取媒体价/默认价"
```
---
## Task 5: 旧 spec §4.2/§5.2/§8 文档同步 + 全量验证
**Files:**
- Modify: `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`9 处精确替换)
- 无 Go 代码改动
**Interfaces:**
- 无(纯文档;同步 v0.4 设计,指向 `2026-09-01-pricing-media-price-design.md`
- [ ] **Step 1: 统一结构 JSON 去掉 `discount:null`、rules 行加 mediaPrices102-112**
```json
{
"unit": "per_1M",
"tiered": false,
"rules": [
{ "name": "规则名", "match": {...}, "price": {...}, "mediaPrices": {"audio": {...}} }
],
"currency": "CNY"
}
```
- [ ] **Step 2: match 表删 `mediaType` 行(130**
删除该行:
```
| `mediaType` | text/audio/video/image | 输入媒体类型 | 视频「输入含/不含视频」、推理「输入含/不含音频」 |
```
- [ ] **Step 3: price 段追加 mediaPrices 说明(136-138 之后)**
在「非 token 单位…」行之后追加:
```
**mediaPrices 媒体类型价**(可选):`媒体类型 → price` 的 map,键 ∈ {text,audio,video,image}。输入含该媒体时用对应价;否则用默认 `price`(**默认价 = 输入不含以下媒体**)。一个规则可挂多套媒体价;缺 key 即走默认价。
```
- [ ] **Step 4: model-gateway 关系注记改写(142**
```markdown
> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaPrices.audio` 表达)。`discount` 废弃(代码无实现)。v0.4 修订全文见 `2026-09-01-pricing-media-price-design.md`。
```
- [ ] **Step 5: 视频示例去 mediaType、加 mediaPrices151-152**
```json
{ "name": "无声-720p", "match": {"outputAudio": false, "outputResolution": "720p"}, "price": {"unitPrice": 0.8}, "mediaPrices": {"video": {"unitPrice": 1.0}} },
{ "name": "有声-1080p", "match": {"outputAudio": true, "outputResolution": "1080p"}, "price": {"unitPrice": 1.5}, "mediaPrices": {"video": {"unitPrice": 2.0}} }
```
- [ ] **Step 6: 推理示例去掉文本遮蔽规则、音频差价改 mediaPrices162-165**
```json
{ "name": "思考-输入≤32000", "match": {"thinking": true, "inputLengthMax": 32000}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12}, "mediaPrices": {"audio": {"input": 9, "output": 3.6, "cacheHit": 1.8}} },
{ "name": "思考-输入32001~128000", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18}, "mediaPrices": {"audio": {"input": 13.5, "output": 5.4, "cacheHit": 2.7}} },
{ "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} }
```
- [ ] **Step 7: Save 校验行同步(181**
```markdown
- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}``tiered:true` 仅 token 单位且 inputLength 档按下界排序相邻不重叠(上不封顶档须最后);mediaPrices 键 ∈ {text,audio,video,image} 且值非空;match 字段取值合法;price 非负。
```
- [ ] **Step 8: §5.2 折扣行改写(210**
```markdown
- 折扣:废弃(v0.4 修订为规则级 `mediaPrices`,见 `2026-09-01-pricing-media-price-design.md`)。
```
- [ ] **Step 9: §8 决策表「价格项收敛」行改写(296)**
```markdown
| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 `mediaPrices.audio` 表达(删 inputAudio/cacheHitAudio/cacheStorageHour 与 match.mediaType |
```
`ChargeUsage.MediaType` §5.3 225 行保留不动——上报入参字段。)
- [ ] **Step 10: 全量编译 + 全量测试**
Run: `cd /c/App/GolandProjects/shop-user-trade && go build ./... && go test ./service/pricing/ ./model/dto/pricing/ -count=1`
Expected: PASS
- [ ] **Step 11: 提交**
```bash
git add docs/superpowers/specs/2026-08-28-pricing-enum-design.md
git status --short
git commit --only docs/superpowers/specs/2026-08-28-pricing-enum-design.md -m "docs(pricing): 枚举设计 §4.2/§5.2/§8 同步 mediaPrices v0.4"
```
---
## Self-Review 记录
- **Spec 覆盖**v0.4 spec §3(结构变更)→ Task 1;§5.1validateTieredBands)→ Task 2;§5.2mediaPrices 校验 + ModelMediaSet)→ Task 1/3;§4.1pickModelPrice + 两处 charge)→ Task 4;§8 兼容与迁移(match.mediaType 移除、discount 废弃、旧 spec 同步)→ Task 1/5;§10 测试要点 → Task 2-4 全覆盖(相邻合法/同起0/乱序/上不封顶/无长度规则;audio/默认价/多类型/未匹配走默认;键非法/值 nil/两计算器)。
- **占位符扫描**:无 TBD/TODO;所有代码块为完整可粘贴代码;错误文案与 spec §5.2 逐字一致。
- **类型一致性**`ModelMediaSet`Task1 定义)Task3 消费;`modelRule.MediaPrices`Task1Task3/4 消费;`pickModelPrice`/`validateMediaPrices` 签名在定义与调用处一致;`modelMatch` 无 MediaType 贯穿全部测试构造。
- **测试互不依赖**:Task 3/4 测试追加到同一 `charge_calc_test.go`Task 4 全量跑含 Task 2/3 用例(17 个),顺序无关。