From 4a30397623b096591a67040b502950c167cd02ee Mon Sep 17 00:00:00 2001 From: Wesley Liddick Date: Wed, 8 Jul 2026 18:37:33 +0000 Subject: [PATCH 1/2] fix: prevent channel pricing overrides from mutating shared fallback pricing --- backend/internal/service/billing_service.go | 3 +++ backend/internal/service/model_pricing_resolver.go | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 4c265aed3c..89755c8922 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -771,6 +771,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing if channelPricing == nil { return pricing, nil } + // 防止修改 fallbackPrices 中的共享指针 + cloned := *pricing + pricing = &cloned if channelPricing.InputPrice != nil { pricing.InputPricePerToken = *channelPricing.InputPrice pricing.InputPricePerTokenPriority = *channelPricing.InputPrice diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 029cb80259..0cc7a0ac47 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -150,6 +150,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin // 区间不匹配时回退到 BasePricing,也需要覆盖图片价格 if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} + } else { + // 防止修改 fallbackPrices 中的共享指针 + cloned := *resolved.BasePricing + resolved.BasePricing = &cloned } if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice @@ -163,6 +167,10 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin // 否则用 flat 字段覆盖 BasePricing if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} + } else { + // 防止修改 fallbackPrices 中的共享指针 + cloned := *resolved.BasePricing + resolved.BasePricing = &cloned } if chPricing.InputPrice != nil { From 88581912ba8fad4e48ba9303f811bbf3fd9a248f Mon Sep 17 00:00:00 2001 From: Wesley Liddick Date: Wed, 8 Jul 2026 19:23:38 +0000 Subject: [PATCH 2/2] test: add regression tests for fallback pricing pollution --- .../service/model_pricing_resolver_test.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 3b49b15556..d4169e5f0e 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -727,3 +727,63 @@ func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) require.True(t, pricing.ImageOutputPriceExplicit) require.Equal(t, 0.0, pricing.ImageOutputPricePerToken) } + +// =========================================================================== +// 10. Regression: channel overrides must not pollute fallbackPrices +// =========================================================================== + +// TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices verifies that the +// flat-override path in applyTokenOverrides clones the BasePricing struct +// before mutation, so the shared fallbackPrices map entry is not written through. +func TestApplyTokenOverrides_FlatDoesNotPolluteFallbackPrices(t *testing.T) { + r := newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), // base is 3e-6 + OutputPrice: testPtrFloat64(50e-6), // base is 15e-6 + }}) + + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) + + // Resolved pricing should reflect the channel override + require.NotNil(t, resolved) + require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12) + require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) + + // Global fallbackPrices must NOT be polluted + fp := r.billingService.fallbackPrices["claude-sonnet-4"] + require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted") + require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted") + require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted") +} + +// TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices verifies that +// the interval-override path also clones before mutation. +func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T) { + r := newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + Intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(2e-6), OutputPrice: testPtrFloat64(8e-6)}, + }, + }}) + + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) + + require.NotNil(t, resolved) + require.True(t, resolved.BasePricing.ImageOutputPriceExplicit) + + // Global fallbackPrices must NOT be polluted + fp := r.billingService.fallbackPrices["claude-sonnet-4"] + require.InDelta(t, 3e-6, fp.InputPricePerToken, 1e-12, "fallback InputPricePerToken polluted") + require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted") + require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted") +}