docs(pricing): mediaPrices 媒体类型价与阶梯校验修复设计 v0.4

This commit is contained in:
2026-09-01 14:25:07 +08:00
parent f6ffb58794
commit 90ad1b86db
@@ -0,0 +1,244 @@
# 模型计费 mediaPrices(媒体类型价)与阶梯校验修复设计
> 版本:v0.4(本期)—— 修订 v0.3 `2026-08-28-pricing-enum-design.md` §4.2/§5.2。将模型规则"输入媒体"维度从 `match` 命中条件**改为规则级 `mediaPrices` 媒体价 map**,重写阶梯档位重叠校验,解决「有音频/无音频 × 档位」矩阵配置无法保存的问题与前端"媒体类型 4 选 1"体验问题。
> 范围:shop-user-trade **pricing 计价模块**。只动模型计费结构(`charge_calc.go` + `consts`);workflow/business 计算器与 DB 结构不动。
> 约定:金额一律**元**(float64,2 位小数),不足 1 分向上取整(`ceilFen`)——沿用 v0.3。
---
## 1. 背景与问题
管理端保存模型计费配置时,提交"输入含音频价 + 输入不含音频价 × 输入 token 档位"的矩阵数据被 `validateTieredBands` 拒绝,报错 **「阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax」**。
根因(两层):
1. **设计缺陷**`validateTieredBands`charge_calc.go:306)对全部规则做全局两两比较,**不感知媒体维度**。不同媒体类型的同档位规则也被判为"重叠"→ 媒体 × 档位矩阵永远存不进去。
2. **表达缺陷**v0.3 用 `match.mediaType`(等值命中)表达"输入含某媒体",无法表达"**不含**某媒体"——`mediaType:"text"` 只匹配文本输入,图片/视频输入匹配不到;前端被迫做"文本/音频/视频/图片 4 选 1",选"非音频"时只能硬选"文本",语义是假的(用户确认)。
另:`validateTieredBands``InputLengthMin == 0` 的规则对直接 `continue`,导致**两个同起于 0 的档位重叠查不出**(潜伏 bug)。
---
## 2. 方案决策:mediaPrices map(规则级媒体价)
用**规则级 `mediaPrices map[string]*modelPrice`** 表达"输入含某媒体"的价差,**默认 `price` 即"不含以下媒体"**
```json
{
"name": "输入长度 [0,32k]",
"match": { "inputLengthMin": 0, "inputLengthMax": 32000 },
"price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 },
"mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } }
}
```
- 语义:命中规则后,`usage.MediaType``mediaPrices` 里有 key → 用该媒体价;**没有 → 默认 `price`**。"不含音频"自动成立(文本/图片/视频都落默认价),**不需要负向字段、不需要假装"文本"**。
- 多类型天然支持:`audio`/`video`/`image`/`text` 任意个 key,一个规则一套档位内可挂多套媒体价。
- 顺序无关:map 按键查,无遮蔽、无首条命中顺序依赖。
- `mediaPrices` 为空 = 纯单价格,与 v0.3 结构兼容。
**否决过的备选**
- `mediaTypeNot` 负向字段:不必要,默认价即负向侧。
- 单字段 `mediaPrice`(只支持一个媒体):不满足"多个媒体类型"。
- 继续用 `match.mediaType` 等值匹配:无法表达"不含",且与前端体验冲突。
---
## 3. rules JSON 结构变更
### 3.1 modelRulecharge_calc.go
```go
type modelRule struct {
Name string `json:"name"`
Match *modelMatch `json:"match,omitempty"` // 命中条件(不含输入媒体维度)
Price *modelPrice `json:"price"` // 默认价:输入不含以下媒体
MediaPrices map[string]*modelPrice `json:"mediaPrices,omitempty"` // 媒体类型→该媒体价(输入含该媒体时)
}
```
### 3.2 modelMatch(移除 MediaType
```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上界
}
```
- `modelRules`unit/tiered/rules/currency)、`modelPrice`input/output/cacheHit/unitPrice)不变。
- `discount` 字段**废弃**:v0.3 spec 文本与预览页的 `discount:null` 占位一律移除(代码 `modelRules` 本无该字段)。
---
## 4. 语义与运行时
### 4.1 取价(pickModelPrice
```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``p := pickModelPrice(rule, usage)` 后按 `p.Input/p.Output/p.CacheHit` 计费(原公式不变)。
- `modelUnitCalculator.charge``p := pickModelPrice(rule, usage)` 后按 `p.UnitPrice` 计费(原公式不变)。
- `modelRuleMatch`**删除 `MediaType` 等值判定**(该行是本设计唯一删除的匹配逻辑)。
### 4.2 上报契约(媒体类型)
`ChargeUsage.MediaType`(上报入参,字段保留)口径:
- 推理模型:输入**含音频**报 `"audio"`,否则报 `"text"`
- 视频模型:输入**含视频**报 `"video"`,否则报 `"text"`
- 模型结算接入为另期;本设计只定契约,结算方按此上报。
---
## 5. 校验规则
### 5.1 阶梯档位(validateTieredBands 重写)
按输入长度下界升序后**相邻不重叠**;无长度区间的规则(如 thinking 维度的兜底档)不参与重叠校验;上不封顶档(max=0)必须是最后一个长度档:
```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
}
```
要点:
- **修复同起 0 重叠**:两个档位 min 均为 0 时,第二个 `0 <= prevMax` → 报错(旧代码因 `InputLengthMin==0 continue` 漏检)。
- **顺序无关**:先排序再判,乱序提交也正确。
- 档位**相邻**(下档 min = 上档 max+1)由前端自动生成;后端只强约束不重叠(允许间隙,间隙内长度无档命中 → 结算时报「无匹配计费规则」,由配置质量保证)。
- 无长度规则(如 `thinking:false` 兜底档)不参与重叠校验,与长度档共存合法。
### 5.2 mediaPrices 校验
`modelTokenCalculator.validate``modelUnitCalculator.validate` 的规则循环内追加:
```go
if len(r.Rules[i].MediaPrices) > 0 {
for mt, mp := range r.Rules[i].MediaPrices {
if !pricingConsts.ModelMediaSet[mt] {
return nil, errors.New("model 费率 mediaPrices 键须为 text/audio/video/image")
}
if mp == nil {
return nil, errors.New("model 费率 mediaPrices 值须配置 price")
}
}
}
```
`consts/pricing` 新增媒体类型集合(复用常量,前端下拉同源):
```go
// ModelMediaSet 模型费率 mediaPrices 允许的媒体类型键
var ModelMediaSet = map[string]struct{}{
"text": {}, "audio": {}, "video": {}, "image": {},
}
```
---
## 6. 前端交互(已定稿)
模型计费规则编辑器(参考 `docs/superpowers/specs/pricing-config-preview.html` 已改版):
- **每条规则**`默认价 price`(输入不含以下媒体)+ `媒体类型价 mediaPrices` 列表(可增删;每项 = 媒体类型选择(音频/视频/图片/文本)+ 对应价格)。
- 不再有「媒体类型 4 选 1」强制下拉;要几种媒体就加几张卡。
- 档位区间前端**自动相邻**(下档 min = 上档 max+1),不再手填重叠值。
- 提交 JSON 结构见 §2 示例。
---
## 7. 数据修正示例(用户提交的 6 条 → 3 条)
```json
{
"unit": "per_1M", "tiered": true, "currency": "CNY",
"rules": [
{ "name": "输入长度 [0,32k]", "match": { "inputLengthMin": 0, "inputLengthMax": 32000 },
"price": { "input": 0.3, "output": 1.8, "cacheHit": 0.12 }, "mediaPrices": { "audio": { "input": 4.5, "output": 1.8, "cacheHit": 1.8 } } },
{ "name": "输入长度 (32k,128k]", "match": { "inputLengthMin": 32001, "inputLengthMax": 128000 },
"price": { "input": 0.45, "output": 0.18, "cacheHit": 2.7 }, "mediaPrices": { "audio": { "input": 6.75, "output": 2.7, "cacheHit": 2.7 } } },
{ "name": "输入长度 (128k,256k]","match": { "inputLengthMin": 128001, "inputLengthMax": 256000 },
"price": { "input": 0.9, "output": 5.4, "cacheHit": 0.36 }, "mediaPrices": { "audio": { "input": 13.5, "output": 5.4, "cacheHit": 5.4 } } }
]
}
```
(原数据问题:R1 max=3200 应为 32000R3/R5 档位边界 32000/128000 须 +1 相邻;无 mediaType 的文本规则遮蔽同名音频规则——新结构下不存在该问题。)
---
## 8. 兼容与迁移
- **`match.mediaType` 移除**:旧配置中 `match.mediaType` 被 Go JSON 反序列化**静默忽略**(字段删除)。旧"音频差价"规则需重配为 `price`(默认)+ `mediaPrices.audio`。本期结算未接、无线上真实配置,直接切换即可。
- **`discount` 废弃**:spec 文本与预览页占位删除(代码本无实现)。
- workflow/business 计算器、`pricing_config` DB 结构、`ChargeMode` 枚举、金额单位均不动。
---
## 9. 改动清单
1. `consts/pricing/`charge_mode.go 或新文件):加 `ModelMediaSet`text/audio/video/image
2. `service/pricing/charge_calc.go`
- `modelMatch``MediaType` 字段;`modelRule``MediaPrices map[string]*modelPrice`
- `modelRuleMatch``MediaType` 等值判定
- 新增 `pickModelPrice``modelTokenCalculator.charge` / `modelUnitCalculator.charge` 改用之
- `validateTieredBands` 重写(§5.1
- 两个 model validate 追加 `mediaPrices` 键/值校验(§5.2
3. 测试(新 `service/pricing/charge_calc_test.go`):见 §10
4. `docs/superpowers/specs/2026-08-28-pricing-enum-design.md`:§4.2 示例与 §8 决策表同步更新(指向本设计)
5. `docs/superpowers/specs/pricing-config-preview.html`:已按本设计改版(mockup
6. `go build ./...` + `go test ./service/pricing/`(需 `GF_GCFG_PATH=C:/App/GolandProjects/shop-user-trade` 规避 common/consul init 环境问题)
## 10. 测试要点
- **validateTieredBands**:相邻档合法;两档同起 0 重叠报错;乱序提交仍正确判重叠;上不封顶在最后合法、其后还有长度档报错;无长度规则(thinking 兜底)不干扰。
- **pickModelPrice / charge**:含音频命中 `mediaPrices.audio`;不含音频走默认 `price`;多媒体类型各命中;`mediaPrices` 空走默认;命中规则后无 `mediaPrices` key 的媒体类型(如 video 输入配了 audio-only)走默认。
- **validate**`mediaPrices` 键非法报错;值 nil 报错;token/unit 两个计算器都覆盖。
## 11. 不改动
- `modelTokenCalculator` 计费公式、`modelUnitCalculator` 公式、`matchModelRule` 首条命中语义(非 tiered 配置仍按顺序优先)
- workflowper_item/per_second/per_token)、businessper_period)计算器
- `pricing_config` 表结构、`charge_order` 表结构、`ChargeUsage` 字段
- `GET /pricing/subjects` 与 charge-modes 接口