docs(pricing): mediaPrices 媒体类型价与阶梯校验修复设计 v0.4
This commit is contained in:
@@ -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 modelRule(charge_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 应为 32000;R3/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 配置仍按顺序优先)
|
||||
- workflow(per_item/per_second/per_token)、business(per_period)计算器
|
||||
- `pricing_config` 表结构、`charge_order` 表结构、`ChargeUsage` 字段
|
||||
- `GET /pricing/subjects` 与 charge-modes 接口
|
||||
Reference in New Issue
Block a user