From 92dc61d401958899334c947a466b7328319eaa2b Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 29 Jul 2026 12:30:41 +0800 Subject: [PATCH] fix(channels): show composite models by platform Expand visible Composite groups into each configured concrete model platform while preserving ordinary group isolation and empty-state behavior.\n\nFixes #4985 --- .../handler/available_channel_handler.go | 37 ++++++- .../handler/available_channel_handler_test.go | 98 +++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 05d64e2002..300eb1b1a7 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -12,12 +12,11 @@ import ( // AvailableChannelHandler 处理用户侧「可用渠道」查询。 // -// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤: +// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤: // 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道; // 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些; -// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型, -// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问 -// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏; +// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台 +// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力; // 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels // / 内部 ID / Status 等管理字段)。 type AvailableChannelHandler struct { @@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { } // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: -// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。 +// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。 +// +// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的 +// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道 +// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。 // 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。 func buildPlatformSections( ch service.AvailableChannel, visibleGroups []userAvailableGroup, ) []userChannelPlatformSection { groupsByPlatform := make(map[string][]userAvailableGroup, 4) + compositeGroups := make([]userAvailableGroup, 0, 1) for _, g := range visibleGroups { if g.Platform == "" { continue } + if g.Platform == service.PlatformComposite { + compositeGroups = append(compositeGroups, g) + continue + } groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g) } + + if len(compositeGroups) > 0 { + modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels)) + for i := range ch.SupportedModels { + if platform := ch.SupportedModels[i].Platform; platform != "" { + modelPlatforms[platform] = struct{}{} + } + } + if len(modelPlatforms) == 0 { + groupsByPlatform[service.PlatformComposite] = append( + groupsByPlatform[service.PlatformComposite], + compositeGroups..., + ) + } else { + for platform := range modelPlatforms { + groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...) + } + } + } if len(groupsByPlatform) == 0 { return nil } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index bb53297980..24d7d1cb45 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { require.Len(t, sections[0].SupportedModels, 1) require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) } + +func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2.5e-6 + ch := service.AvailableChannel{ + Name: "composite-channel", + SupportedModels: []service.SupportedModel{ + { + Name: "claude-sonnet-4-6", + Platform: service.PlatformAnthropic, + Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice}, + }, + { + Name: "gpt-5", + Platform: service.PlatformOpenAI, + Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice}, + }, + }, + } + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + for _, section := range sections { + require.Len(t, section.Groups, 1) + require.Equal(t, int64(9), section.Groups[0].ID) + require.Equal(t, service.PlatformComposite, section.Groups[0].Platform) + require.Len(t, section.SupportedModels, 1) + require.Equal(t, section.Platform, section.SupportedModels[0].Platform) + require.NotNil(t, section.SupportedModels[0].Pricing) + } + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) + require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Len(t, sections[0].SupportedModels, 1) + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, []int64{1, 9}, []int64{ + sections[0].Groups[0].ID, + sections[0].Groups[1].ID, + }) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + require.Len(t, sections[1].Groups, 1) + require.Equal(t, int64(9), sections[1].Groups[0].ID) +} + +func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) { + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(service.AvailableChannel{ + SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}}, + }, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformComposite, sections[0].Platform) + require.Len(t, sections[0].Groups, 1) + require.Empty(t, sections[0].SupportedModels) +}