diff --git a/service/pricing/charge_calc.go b/service/pricing/charge_calc.go index 8403516..0c93a4d 100644 --- a/service/pricing/charge_calc.go +++ b/service/pricing/charge_calc.go @@ -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 diff --git a/service/pricing/charge_calc_test.go b/service/pricing/charge_calc_test.go index 04dd513..6489b32 100644 --- a/service/pricing/charge_calc_test.go +++ b/service/pricing/charge_calc_test.go @@ -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) - } -} - -// P1:validate 拒绝缺 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") - } -} - -// P1:validate 拒绝缺 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") - } -} - -// P2:modelTokenCalculator.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) - } -} - -// M3:cached > 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 钳 0:0 + 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) } }