fix(pricing): validateTieredBands 按下界排序判档位相邻 + 修同起 0 漏检

This commit is contained in:
2026-09-01 14:43:23 +08:00
parent 63bc74eca9
commit 4c54be1758
2 changed files with 69 additions and 172 deletions
+26 -10
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
"sort"
pricingConsts "shop-user-trade/consts/pricing"
)
@@ -299,17 +300,32 @@ func (c modelTokenCalculator) validate(rulesJSON string) (interface{}, error) {
return &r, nil
}
// validateTieredBands 阶梯分档校验:档位区间相邻不重叠(下档 max+1 = 上档 min
// validateTieredBands 阶梯分档校验:带长度区间的规则按下界升序后相邻不重叠。
// 无长度区间规则不参与;上不封顶档(max=0)须为最后一个长度档。
func validateTieredBands(rules []modelRule) error {
for i := 0; i < len(rules); i++ {
for j := i + 1; j < len(rules); j++ {
a, b := rules[i].Match, rules[j].Match
if a == nil || b == nil || a.InputLengthMax == 0 || b.InputLengthMin == 0 {
continue // 无档位约束的规则不参与重叠校验
}
if b.InputLengthMin <= a.InputLengthMax {
return errors.New("阶梯档位区间重叠:规则间 InputLengthMin 须 > 前一档 InputLengthMax")
}
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
+43 -162
View File
@@ -1,189 +1,70 @@
package service
import (
"testing"
)
import "testing"
// 阶梯命中:思考≤32000 → input 0.6/output 3.6>32000 → 0.9/5.4
const modelTokenTieredJSON = `{
"unit":"per_1M","tiered":true,
"rules":[
{"name":"思考≤32000","match":{"thinking":true,"inputLengthMax":32000,"mediaType":"text"},"price":{"input":0.6,"output":3.6,"cacheHit":0.12}},
{"name":"思考>32000","match":{"thinking":true,"inputLengthMin":32001,"mediaType":"text"},"price":{"input":0.9,"output":5.4,"cacheHit":0.18}}
]}`
func boolPtr(v bool) *bool { return &v }
func ptr(b bool) *bool { return &b }
func TestModelTokenCalculator_TieredMatch(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
rules, err := c.validate(modelTokenTieredJSON)
if err != nil {
t.Fatalf("validate err: %v", err)
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}},
}
// 第一档:prompt 20000 → (0.6*20000 + 3.6*1000)/1e6 = 0.0156 → ceil → 0.02
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 20000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
if err != nil {
t.Fatalf("charge err: %v", err)
}
if got != 0.02 {
t.Fatalf("tier1 got %v want 0.02", got)
}
// 第二档:prompt 50000 → (0.9*50000 + 5.4*1000)/1e6 = 0.0504 → ceil → 0.06
got, err = c.charge(rules, &ChargeUsage{PromptTokens: 50000, CompletionTokens: 1000, MediaType: "text", Thinking: ptr(true)})
if err != nil {
t.Fatalf("charge err: %v", err)
}
if got != 0.06 {
t.Fatalf("tier2 got %v want 0.06", got)
if err := validateTieredBands(rules); err != nil {
t.Fatalf("相邻档位应通过: %v", err)
}
}
func TestModelTokenCalculator_NoMatch(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
rules, err := c.validate(modelTokenTieredJSON)
if err != nil {
t.Fatalf("validate err: %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}},
}
// thinking=false 不在任一档 → 无匹配报错
_, err = c.charge(rules, &ChargeUsage{PromptTokens: 1000, MediaType: "text", Thinking: ptr(false)})
if err == nil {
t.Fatal("expected no-match error")
if err := validateTieredBands(rules); err == nil {
t.Fatal("两个同起 0 的档位必须判重叠")
}
}
func TestModelTokenCalculator_CacheHit(t *testing.T) {
c := modelTokenCalculator{base: 1000}
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
if err != nil {
t.Fatalf("validate err: %v", err)
func TestValidateTieredBandsUnsortedOverlapDetected(t *testing.T) {
// 乱序提交仍须判重叠(旧实现依赖提交顺序,此用例对旧实现漏检)
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 50000, InputLengthMax: 64000}},
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 52000}},
}
// (2000-1000)/1000*1 + 1000/1000*0.1 + 500/1000*2 = 1 + 0.1 + 1 = 2.1
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 2000, CompletionTokens: 500, CachedTokens: 1000})
if err != nil {
t.Fatalf("charge err: %v", err)
}
if got != 2.1 {
t.Fatalf("cache got %v want 2.1", got)
if err := validateTieredBands(rules); err == nil {
t.Fatal("重叠必须与提交顺序无关地被检出")
}
}
func TestModelUnitCalculator(t *testing.T) {
// 音频按时长(分钟):90 秒 × 2 元/分钟 = 3
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
rules, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":2}}]}`)
if err != nil {
t.Fatalf("validate err: %v", err)
func TestValidateTieredBandsOpenEndedLastOK(t *testing.T) {
rules := []modelRule{
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
{Match: &modelMatch{InputLengthMin: 32001, InputLengthMax: 0}}, // 上不封顶
}
got, err := c.charge(rules, &ChargeUsage{DurationSec: 90})
if err != nil || got != 3 {
t.Fatalf("per_minute got %v want 3, err %v", got, err)
}
// 音频按字数:10000 字 × 0.0005 = 5(独立 per_char 规则)
c2 := modelUnitCalculator{base: 1, usage: usageCharCount}
rules2, err := c2.validate(`{"unit":"per_char","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.0005}}]}`)
if err != nil {
t.Fatalf("validate err: %v", err)
}
got, err = c2.charge(rules2, &ChargeUsage{CharCount: 10000})
if err != nil || got != 5 {
t.Fatalf("per_char got %v want 5, err %v", got, err)
}
// 图片按张数×分辨率
c3 := modelUnitCalculator{base: 1, usage: usageImageCount}
rules3, err := c3.validate(`{"unit":"per_1","tiered":false,"rules":[
{"name":"512x512","match":{"outputResolution":"512x512"},"price":{"unitPrice":0.2}},
{"name":"1024x1024","match":{"outputResolution":"1024x1024"},"price":{"unitPrice":0.5}}]}`)
if err != nil {
t.Fatalf("validate err: %v", err)
}
got, err = c3.charge(rules3, &ChargeUsage{ImageCount: 3, OutputResolution: "1024x1024"})
if err != nil || got != 1.5 {
t.Fatalf("per_1 got %v want 1.5, err %v", got, err)
if err := validateTieredBands(rules); err != nil {
t.Fatalf("上不封顶在最后应通过: %v", err)
}
}
func TestPerPeriodCalculator(t *testing.T) {
c := perPeriodCalculator{}
rules, err := c.validate(`{"period":"year","price":1999}`)
if err != nil {
t.Fatalf("validate err: %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}},
}
got, err := c.charge(rules, &ChargeUsage{})
if err != nil || got != 1999 {
t.Fatalf("per_period got %v want 1999, err %v", got, err)
}
// 非法周期报错
if _, err = c.validate(`{"period":"month","price":100}`); err == nil {
t.Fatal("expected invalid period error")
if err := validateTieredBands(rules); err == nil {
t.Fatal("上不封顶档之后不能再有长度档")
}
}
func TestCalcChargeBySubjectType(t *testing.T) {
// workflow per_second 走整秒向上取整计算器:ceil(2.2)=3 × 0.9 = 2.7
got, err := calcCharge("workflow", "per_second", `{"unitPrice":0.9}`, &ChargeUsage{DurationSec: 2.2})
if err != nil || got != 2.7 {
t.Fatalf("workflow per_second got %v want 2.7, err %v", got, err)
func TestValidateTieredBandsNonLengthRuleIgnored(t *testing.T) {
// thinking 兜底档无长度区间,不应参与重叠校验
rules := []modelRule{
{Match: &modelMatch{Thinking: boolPtr(false)}},
{Match: &modelMatch{InputLengthMin: 0, InputLengthMax: 32000}},
}
// model per_second 走精确秒单位计算器:2.2 × 0.9 = 1.98
got, err = calcCharge("model", "per_second", `{"unit":"per_second","tiered":false,"rules":[{"name":"统一","price":{"unitPrice":0.9}}]}`, &ChargeUsage{DurationSec: 2.2})
if err != nil || got != 1.98 {
t.Fatalf("model per_second got %v want 1.98, err %v", got, err)
}
}
// P1validate 拒绝缺 price 的规则(token 计算器)
func TestModelTokenCalculator_ValidateNilPrice(t *testing.T) {
c := modelTokenCalculator{base: 1e6}
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
t.Fatal("expected nil price validation error")
}
}
// P1validate 拒绝缺 price 的规则(单位计算器)
func TestModelUnitCalculator_ValidateNilPrice(t *testing.T) {
c := modelUnitCalculator{base: 60, usage: usageDurationSec}
if _, err := c.validate(`{"unit":"per_minute","tiered":false,"rules":[{"name":"无价"}]}`); err == nil {
t.Fatal("expected nil price validation error")
}
}
// P2modelTokenCalculator.validate 拒绝 unit 与实例 base 不匹配的配置
func TestModelTokenCalculator_ValidateUnitBaseMismatch(t *testing.T) {
// per_1M 实例(base=1e6)配 per_1K 单位 → 校验失败
c := modelTokenCalculator{base: 1e6}
if _, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
t.Fatal("expected unit/base mismatch error for per_1M instance with per_1K unit")
}
// per_1K 实例(base=1000)配 per_1M 单位 → 校验失败
c2 := modelTokenCalculator{base: 1000}
if _, err := c2.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err == nil {
t.Fatal("expected unit/base mismatch error for per_1K instance with per_1M unit")
}
// 匹配时不报错
if _, err := c.validate(`{"unit":"per_1M","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if _, err := c2.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1}}]}`); err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
// M3cached > prompt 时金额不为负(promptInput 钳为 0
func TestModelTokenCalculator_CachedGreaterThanPrompt(t *testing.T) {
c := modelTokenCalculator{base: 1000}
rules, err := c.validate(`{"unit":"per_1K","tiered":false,"rules":[{"name":"统一","price":{"input":1,"output":2,"cacheHit":0.1}}]}`)
if err != nil {
t.Fatalf("validate err: %v", err)
}
// cached 3000 > prompt 1000 → promptInput 钳 00 + 3000/1000*0.1 + 0 = 0.3
got, err := c.charge(rules, &ChargeUsage{PromptTokens: 1000, CompletionTokens: 0, CachedTokens: 3000})
if err != nil {
t.Fatalf("charge err: %v", err)
}
if got < 0 {
t.Fatalf("got negative amount %v", got)
}
if got != 0.3 {
t.Fatalf("got %v want 0.3", got)
if err := validateTieredBands(rules); err != nil {
t.Fatalf("无长度规则不应干扰: %v", err)
}
}