diff --git a/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md b/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md new file mode 100644 index 0000000..80a3d85 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-pricing-mediaprices.md @@ -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`, 标准库 testing(TDD)。 + +## 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 -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: 重写 validateTieredBands(TDD) + +**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: PASS(6 个用例) + +- [ ] **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` helper;token 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,值须配置 price(token/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: PASS(5 个用例) + +- [ ] **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 只有 audio,video 输入落默认价 + 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 行加 mediaPrices(102-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、加 mediaPrices(151-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: 推理示例去掉文本遮蔽规则、音频差价改 mediaPrices(162-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.1(validateTieredBands)→ Task 2;§5.2(mediaPrices 校验 + ModelMediaSet)→ Task 1/3;§4.1(pickModelPrice + 两处 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`(Task1)Task3/4 消费;`pickModelPrice`/`validateMediaPrices` 签名在定义与调用处一致;`modelMatch` 无 MediaType 贯穿全部测试构造。 +- **测试互不依赖**:Task 3/4 测试追加到同一 `charge_calc_test.go`,Task 4 全量跑含 Task 2/3 用例(17 个),顺序无关。