docs(pricing): mediaPrices 实现计划 v0.4
This commit is contained in:
@@ -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 <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: 重写 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 个),顺序无关。
|
||||
Reference in New Issue
Block a user