From ff6f1640c46c2fa205bcb4b49e95e72d91afe632 Mon Sep 17 00:00:00 2001 From: name <136912576+is7Qin@users.noreply.github.com> Date: Fri, 15 May 2026 13:14:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E5=90=8E=E7=AB=AF=E6=8C=89?= =?UTF-8?q?=E6=AC=A1/=E5=9B=BE=E7=89=87=E8=AE=A1=E8=B4=B9=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20token=20=E5=8C=BA=E9=97=B4=E9=87=8D=E5=8F=A0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端在上一个 commit 已对 image / per_request 模式跳过 unbounded-last 和重叠检查, 但保存时后端仍按 token 语义校验, 导致添加第二个图片层级 时报错: invalid pricing intervals for platform 'openai' models [gpt-image-2 gpt-image-1.5 gpt-image-1]: interval #1: unbounded interval (max_tokens=null) must be the last one ValidateIntervals 加 mode 参数, 与前端校验逻辑对齐: - token 模式行为不变 (区间重叠 / last-unlimited 仍校验) - per_request / image 模式跳过区间重叠和 last-unlimited 检查, 保留单条 min/max 自洽校验与价格非负校验。 调用方 validatePricingIntervals 把 pricing.BillingMode 透给校验器。 既有单测全部加上 BillingModeToken 显式参数, 新增 3 个 image 模式用例 (允许多条 unbounded / 仍拒绝负价 / 仍拒绝 max <= min)。 --- backend/internal/service/channel.go | 20 ++++++-- backend/internal/service/channel_service.go | 2 +- backend/internal/service/channel_test.go | 51 +++++++++++++++++---- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 158bf8a3..760f688d 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -262,10 +262,17 @@ func deepCopyFeaturesConfig(src map[string]any) map[string]any { } // ValidateIntervals 校验区间列表的合法性。 -// 规则:MinTokens >= 0;MaxTokens 若非 nil 则 > 0 且 > MinTokens; -// 所有价格字段 >= 0;区间按 MinTokens 排序后无重叠((min, max] 语义); -// 无界区间(MaxTokens=nil)必须是最后一个。间隙允许(回退默认价格)。 -func ValidateIntervals(intervals []PricingInterval) error { +// +// mode 决定区间语义: +// - BillingModeToken(含空值):区间是上下文 token 数分段 (min, max], +// 按 MinTokens 排序后无重叠,无界区间(MaxTokens=nil)必须是最后一个。 +// - BillingModePerRequest / BillingModeImage:区间是按 tier_label +// (1K/2K/4K 等) 分层,匹配走 label 不依赖 min/max,因此跳过区间重叠 +// 与 last-unlimited 校验,仅做单条字段自洽(min/max/价格非负)检查。 +// +// 通用规则:MinTokens >= 0;MaxTokens 若非 nil 则 > 0 且 > MinTokens; +// 所有价格字段 >= 0。 +func ValidateIntervals(intervals []PricingInterval, mode BillingMode) error { if len(intervals) == 0 { return nil } @@ -280,6 +287,11 @@ func ValidateIntervals(intervals []PricingInterval) error { return err } } + + // per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验 + if mode == BillingModePerRequest || mode == BillingModeImage { + return nil + } return validateIntervalOverlap(sorted) } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 4e08df4a..4bf0147f 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -951,7 +951,7 @@ func validateNoConflictingMappings(mapping map[string]map[string]string) error { func validatePricingIntervals(pricingList []ChannelModelPricing) error { for _, pricing := range pricingList { - if err := ValidateIntervals(pricing.Intervals); err != nil { + if err := ValidateIntervals(pricing.Intervals, pricing.BillingMode); err != nil { return infraerrors.BadRequest( "INVALID_PRICING_INTERVALS", fmt.Sprintf("invalid pricing intervals for platform '%s' models %v: %v", diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 164861fb..2f371f8a 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -311,8 +311,8 @@ func TestChannelClone_EdgeCases(t *testing.T) { // --- ValidateIntervals --- func TestValidateIntervals_Empty(t *testing.T) { - require.NoError(t, ValidateIntervals(nil)) - require.NoError(t, ValidateIntervals([]PricingInterval{})) + require.NoError(t, ValidateIntervals(nil, BillingModeToken)) + require.NoError(t, ValidateIntervals([]PricingInterval{}, BillingModeToken)) } func TestValidateIntervals_ValidIntervals(t *testing.T) { @@ -357,7 +357,7 @@ func TestValidateIntervals_ValidIntervals(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.NoError(t, ValidateIntervals(tt.intervals)) + require.NoError(t, ValidateIntervals(tt.intervals, BillingModeToken)) }) } } @@ -366,7 +366,7 @@ func TestValidateIntervals_NegativeMinTokens(t *testing.T) { intervals := []PricingInterval{ {MinTokens: -1, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "min_tokens") require.Contains(t, err.Error(), ">= 0") @@ -376,7 +376,7 @@ func TestValidateIntervals_MaxTokensZero(t *testing.T) { intervals := []PricingInterval{ {MinTokens: 0, MaxTokens: testPtrInt(0), InputPrice: testPtrFloat64(1e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "> 0") @@ -386,7 +386,7 @@ func TestValidateIntervals_MaxLessThanMin(t *testing.T) { intervals := []PricingInterval{ {MinTokens: 100, MaxTokens: testPtrInt(50), InputPrice: testPtrFloat64(1e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "> min_tokens") @@ -396,7 +396,7 @@ func TestValidateIntervals_MaxEqualsMin(t *testing.T) { intervals := []PricingInterval{ {MinTokens: 100, MaxTokens: testPtrInt(100), InputPrice: testPtrFloat64(1e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "max_tokens") require.Contains(t, err.Error(), "> min_tokens") @@ -407,7 +407,7 @@ func TestValidateIntervals_NegativePrice(t *testing.T) { intervals := []PricingInterval{ {MinTokens: 0, MaxTokens: testPtrInt(100), InputPrice: &negPrice}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "input_price") require.Contains(t, err.Error(), ">= 0") @@ -418,7 +418,7 @@ func TestValidateIntervals_OverlappingIntervals(t *testing.T) { {MinTokens: 0, MaxTokens: testPtrInt(200), InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 100, MaxTokens: testPtrInt(300), InputPrice: testPtrFloat64(2e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "overlap") } @@ -428,12 +428,43 @@ func TestValidateIntervals_UnboundedNotLast(t *testing.T) { {MinTokens: 0, MaxTokens: nil, InputPrice: testPtrFloat64(1e-6)}, {MinTokens: 128000, MaxTokens: testPtrInt(256000), InputPrice: testPtrFloat64(2e-6)}, } - err := ValidateIntervals(intervals) + err := ValidateIntervals(intervals, BillingModeToken) require.Error(t, err) require.Contains(t, err.Error(), "unbounded") require.Contains(t, err.Error(), "last") } +func TestValidateIntervals_ImageModeAllowsMultipleUnboundedTiers(t *testing.T) { + // image / per_request 按 tier_label 匹配,多条 min=0/max=nil 是合法形态。 + intervals := []PricingInterval{ + {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}, + {MinTokens: 0, MaxTokens: nil, TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.06)}, + {MinTokens: 0, MaxTokens: nil, TierLabel: "4K", PerRequestPrice: testPtrFloat64(0.08)}, + } + require.NoError(t, ValidateIntervals(intervals, BillingModeImage)) + require.NoError(t, ValidateIntervals(intervals, BillingModePerRequest)) +} + +func TestValidateIntervals_ImageModeStillRejectsNegativePrice(t *testing.T) { + // image 模式只跳过区间重叠校验,单条字段自洽(价格非负)仍要校验。 + intervals := []PricingInterval{ + {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(-1)}, + } + err := ValidateIntervals(intervals, BillingModeImage) + require.Error(t, err) + require.Contains(t, err.Error(), "must be >= 0") +} + +func TestValidateIntervals_ImageModeStillRejectsBadMaxTokens(t *testing.T) { + // image 模式仍校验 max <= min 这种单条不合法。 + intervals := []PricingInterval{ + {MinTokens: 100, MaxTokens: testPtrInt(50), TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}, + } + err := ValidateIntervals(intervals, BillingModeImage) + require.Error(t, err) + require.Contains(t, err.Error(), "must be > min_tokens") +} + func TestSupportedModels_ExactKeysAndPricing(t *testing.T) { ch := &Channel{ ModelPricing: []ChannelModelPricing{