diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index a7e9bffe3..744b8f43d 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -962,19 +962,35 @@ func conflictsBetween(a, b modelEntry) bool { } } -// toModelEntry 将模型名转换为 modelEntry +// toModelEntry 将模型名转换为 modelEntry(用于模型映射的冲突检测)。 +// 归一化必须与 expandMappingToCache 写缓存键的方式一致:映射缓存只做 strings.ToLower。 func toModelEntry(pattern string) modelEntry { prefix, isWild := splitWildcardSuffix(strings.ToLower(pattern)) return modelEntry{pattern: pattern, prefix: prefix, wildcard: isWild} } +// toPricingModelEntry 将模型名转换为 modelEntry(用于模型定价的冲突检测)。 +// +// 与 toModelEntry 的区别:定价缓存的键走 normalizeChannelPricingModelName +// (额外做 TrimSpace,并把 claude-* 的 "." 换成 "-"),冲突检测必须用同一套归一化, +// 否则两个校验时看着不同、写进缓存后键相同的定价会互相静默覆盖。 +func toPricingModelEntry(pattern string) modelEntry { + // 先剥通配符再归一化,与 expandPricingToCache 的处理顺序保持一致 + prefix, isWild := splitWildcardSuffix(pattern) + return modelEntry{ + pattern: pattern, + prefix: normalizeChannelPricingModelName(prefix), + wildcard: isWild, + } +} + // validateNoConflictingModels 检查定价列表中是否有冲突模型模式(同一平台下)。 // 冲突包括:精确重复、通配符之间的前缀包含、通配符与精确名的前缀匹配。 func validateNoConflictingModels(pricingList []ChannelModelPricing) error { byPlatform := make(map[string][]modelEntry) for _, p := range pricingList { for _, model := range p.Models { - byPlatform[p.Platform] = append(byPlatform[p.Platform], toModelEntry(model)) + byPlatform[p.Platform] = append(byPlatform[p.Platform], toPricingModelEntry(model)) } } for platform, entries := range byPlatform { diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index a89e5063a..f56f61e91 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -414,6 +414,44 @@ func TestValidateNoConflictingModels(t *testing.T) { wantErr: true, errContains: "conflict", }, + // 以下三例:冲突检测必须与 normalizeChannelPricingModelName 用同一套归一化, + // 否则校验放行、写进缓存后键相同,后写的定价会静默覆盖前一条。 + { + name: "claude_dot_and_hyphen_spelling_conflict", + pricingList: []ChannelModelPricing{ + {Platform: "anthropic", Models: []string{"claude-sonnet-4.5"}}, + {Platform: "anthropic", Models: []string{"claude-sonnet-4-5"}}, + }, + wantErr: true, + errContains: "conflict", + }, + { + name: "claude_dot_and_hyphen_spelling_conflict_wildcard", + pricingList: []ChannelModelPricing{ + {Platform: "anthropic", Models: []string{"claude-sonnet-4.5*"}}, + {Platform: "anthropic", Models: []string{"claude-sonnet-4-5-x"}}, + }, + wantErr: true, + errContains: "conflict", + }, + { + name: "surrounding_whitespace_conflict", + pricingList: []ChannelModelPricing{ + {Platform: "openai", Models: []string{"gpt-5.6"}}, + {Platform: "openai", Models: []string{" gpt-5.6 "}}, + }, + wantErr: true, + errContains: "conflict", + }, + { + // 只有 claude-* 前缀才做 "." → "-",别把其它平台也一起归一化了 + name: "non_claude_dot_spelling_is_not_normalized", + pricingList: []ChannelModelPricing{ + {Platform: "openai", Models: []string{"gpt-5.6"}}, + {Platform: "openai", Models: []string{"gpt-5-6"}}, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -469,6 +507,16 @@ func TestValidateNoConflictingMappings(t *testing.T) { wantErr: true, errContains: "conflict", }, + { + // 映射缓存(expandMappingToCache)只做 strings.ToLower,不做定价那套 + // "." → "-",所以这两个源模式在缓存里是两个不同的键、并不冲突。 + // 这条用来卡住:定价侧的归一化修复不能顺手套到映射侧,否则会误报冲突。 + name: "mapping keeps dot and hyphen spelling separate", + mapping: map[string]map[string]string{ + "anthropic": {"claude-sonnet-4.5": "a", "claude-sonnet-4-5": "b"}, + }, + wantErr: false, + }, { name: "wildcard vs exact conflict", mapping: map[string]map[string]string{