From 365ef1fdf79b51f3aec991d335311a201f5aa116 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 21 Apr 2026 01:05:14 +0800 Subject: [PATCH] refactor(channels): consolidate pricing index, tighten types, polish DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the available-channels review pass. No behavior change for end users; tightens internals based on three independent code reviews. Backend - service/channel.go: collapse buildPricingLookup + pricedNamesFor into a single platformPricingIndex (byLower + originalCase + ordered names), built once per SupportedModels call. Fixes a casing- consistency bug where the same logical model appeared with mapping case in the exact branch but pricing case in the wildcard branch — pricing's original case now wins everywhere. - service/channel.go: doc that a mapping key of just "*" expands to every priced model on the platform (intentional "passthrough all"). - service/channel_available.go: normalize empty BillingModelSource to channel_mapped at construction time, removing the same fallback duplicated in the admin DTO mapper and the admin Vue template. - handler/admin/available_channel_handler.go: unexport availableChannelToAdminResponse (same-package usage only); mapper is now a pure passthrough. - handler/available_channel_handler.go: drop the middleware2 alias (no name collision in this file). Frontend - utils/pricing.ts: extract formatScaled, used by SupportedModelChip and PricingRow. - api/admin/channels.ts: re-export BillingMode from constants/channel; tighten Channel.status / billing_model_source to ChannelStatus / BillingModelSource (and same for AvailableChannel). - components/channels/AvailableChannelsTable.vue: drop dead withDefaults wrapper (loading is required, both call sites pass it). - views/admin/AvailableChannelsView.vue: drop the redundant || BILLING_MODEL_SOURCE_CHANNEL_MAPPED fallback (now applied in service layer); remove unused import. - i18n zh + en: delete unused tierLabel and tokenRange keys from both availableChannels.pricing and admin.availableChannels.pricing. Tests - New: SupportedModels_ExactKeyUsesPricedCaseWhenAvailable locks the pricing-case-wins rule. - New: SupportedModels_AsteriskOnlyMappingExpandsAllPriced documents the "*" expansion rule. - Admin handler: existing tests adjusted to pass an explicit BillingModelSource (default-fill is now exercised by service tests). --- .../admin/available_channel_handler.go | 14 +-- .../admin/available_channel_handler_test.go | 8 +- .../handler/available_channel_handler.go | 4 +- backend/internal/service/channel.go | 96 +++++++++---------- backend/internal/service/channel_available.go | 7 +- backend/internal/service/channel_test.go | 31 ++++++ frontend/src/api/admin/channels.ts | 11 ++- .../channels/AvailableChannelsTable.vue | 21 ++-- .../src/components/channels/PricingRow.vue | 6 +- .../channels/SupportedModelChip.vue | 6 +- frontend/src/i18n/locales/en.ts | 4 - frontend/src/i18n/locales/zh.ts | 4 - frontend/src/utils/pricing.ts | 13 +++ .../src/views/admin/AvailableChannelsView.vue | 11 +-- 14 files changed, 128 insertions(+), 108 deletions(-) create mode 100644 frontend/src/utils/pricing.ts diff --git a/backend/internal/handler/admin/available_channel_handler.go b/backend/internal/handler/admin/available_channel_handler.go index 53776105..45b8f357 100644 --- a/backend/internal/handler/admin/available_channel_handler.go +++ b/backend/internal/handler/admin/available_channel_handler.go @@ -45,9 +45,9 @@ type availableChannelResponse struct { SupportedModels []supportedModelResponse `json:"supported_models"` } -// AvailableChannelToAdminResponse 将 service 层的 AvailableChannel 转为管理员 DTO。 -// 导出供同 package 的复用;也用于构造测试 fixture。 -func AvailableChannelToAdminResponse(ch service.AvailableChannel) availableChannelResponse { +// availableChannelToAdminResponse 将 service 层的 AvailableChannel 转为管理员 DTO。 +// 同 package 内复用;也用于构造测试 fixture。 +func availableChannelToAdminResponse(ch service.AvailableChannel) availableChannelResponse { groups := make([]availableGroupResponse, 0, len(ch.Groups)) for _, g := range ch.Groups { groups = append(groups, availableGroupResponse{ID: g.ID, Name: g.Name, Platform: g.Platform}) @@ -66,16 +66,12 @@ func AvailableChannelToAdminResponse(ch service.AvailableChannel) availableChann Pricing: pricing, }) } - billingSource := ch.BillingModelSource - if billingSource == "" { - billingSource = service.BillingModelSourceChannelMapped - } return availableChannelResponse{ ID: ch.ID, Name: ch.Name, Description: ch.Description, Status: ch.Status, - BillingModelSource: billingSource, + BillingModelSource: ch.BillingModelSource, RestrictModels: ch.RestrictModels, Groups: groups, SupportedModels: models, @@ -93,7 +89,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { out := make([]availableChannelResponse, 0, len(channels)) for _, ch := range channels { - out = append(out, AvailableChannelToAdminResponse(ch)) + out = append(out, availableChannelToAdminResponse(ch)) } response.Success(c, gin.H{"items": out}) } diff --git a/backend/internal/handler/admin/available_channel_handler_test.go b/backend/internal/handler/admin/available_channel_handler_test.go index 687e8dad..7d249383 100644 --- a/backend/internal/handler/admin/available_channel_handler_test.go +++ b/backend/internal/handler/admin/available_channel_handler_test.go @@ -12,13 +12,13 @@ import ( func TestAvailableChannelToAdminResponse_IncludesFullDTO(t *testing.T) { // 管理员视图应包含 id / status / billing_model_source / restrict_models 等 - // 管理字段;BillingModelSource 为空时应默认回填 channel_mapped。 + // 管理字段;mapper 是纯透传,BillingModelSource 的默认回填由 service 层负责。 input := service.AvailableChannel{ ID: 42, Name: "ch", Description: "d", Status: service.StatusActive, - BillingModelSource: "", // 验证默认值填充 + BillingModelSource: service.BillingModelSourceChannelMapped, RestrictModels: true, Groups: []service.AvailableGroupRef{ {ID: 1, Name: "g1", Platform: "anthropic"}, @@ -28,7 +28,7 @@ func TestAvailableChannelToAdminResponse_IncludesFullDTO(t *testing.T) { }, } - resp := AvailableChannelToAdminResponse(input) + resp := availableChannelToAdminResponse(input) require.Equal(t, int64(42), resp.ID) require.Equal(t, "ch", resp.Name) require.Equal(t, service.StatusActive, resp.Status) @@ -52,6 +52,6 @@ func TestAvailableChannelToAdminResponse_PreservesExplicitBillingSource(t *testi input := service.AvailableChannel{ BillingModelSource: service.BillingModelSourceUpstream, } - resp := AvailableChannelToAdminResponse(input) + resp := availableChannelToAdminResponse(input) require.Equal(t, service.BillingModelSourceUpstream, resp.BillingModelSource) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 25452fc8..d19fa9b6 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -2,7 +2,7 @@ package handler import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -83,7 +83,7 @@ type userAvailableChannel struct { // List 列出当前用户可见的「可用渠道」。 // GET /api/v1/channels/available func (h *AvailableChannelHandler) List(c *gin.Context) { - subject, ok := middleware2.GetAuthSubjectFromContext(c) + subject, ok := middleware.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index de31e829..d142146d 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -391,59 +391,50 @@ func (c *Channel) GetModelPricingByPlatform(platform, model string) *ChannelMode return nil } -// pricingLookup 是渠道定价在单个计算过程中的索引:platform → (lowerName → *pricing)。 -// 用于将 SupportedModels 的定价解析从 O(N*M) 降到 O(N+M)。 -type pricingLookup map[string]map[string]*ChannelModelPricing +// platformPricingIndex 是单个平台下定价信息的复合索引。 +// 一次扫描即可同时支持精确查找(exact 分支)与有序遍历(wildcard 分支), +// 避免 SupportedModels 对每个平台重复扫描定价列表。 +// +// byLower 与 names/originalCase 共享同一套去重规则:以 lower-case 模型名为 key, +// 首个命中保留其原始大小写。names 维持按定价行扫描顺序的稳定迭代。 +type platformPricingIndex struct { + byLower map[string]*ChannelModelPricing // lowercased model name → pricing (Clone'd) + originalCase map[string]string // lowercased model name → original-case model name + names []string // priced model names in their ORIGINAL case, insertion-ordered, deduped case-insensitively (first wins) +} -// buildPricingLookup 对渠道的定价列表做一次扫描,生成 platform+模型名 的索引。 +// buildPricingIndex 对渠道的定价列表做一次扫描,按 platform 聚合为查找索引。 // 索引值是定价条目的 Clone 指针,调用方可安全按需返回副本而不污染缓存。 -// wildcard 后缀(如 "claude-*")不会被索引(它们不是精确模型名)。 -func buildPricingLookup(pricings []ChannelModelPricing) pricingLookup { - lookup := make(pricingLookup, len(pricings)) +// 通配符后缀条目(如 "claude-*")不被索引(它们是模式,不是具体模型名)。 +// 同一平台中以大小写不敏感方式去重,先出现者保留原始大小写。 +func buildPricingIndex(pricings []ChannelModelPricing) map[string]*platformPricingIndex { + idx := make(map[string]*platformPricingIndex) for i := range pricings { p := pricings[i] - byModel, ok := lookup[p.Platform] + pidx, ok := idx[p.Platform] if !ok { - byModel = make(map[string]*ChannelModelPricing, len(p.Models)) - lookup[p.Platform] = byModel + pidx = &platformPricingIndex{ + byLower: make(map[string]*ChannelModelPricing), + originalCase: make(map[string]string), + names: make([]string, 0), + } + idx[p.Platform] = pidx } for _, m := range p.Models { if _, wild := splitWildcardSuffix(m); wild { continue } lower := strings.ToLower(m) - if _, exists := byModel[lower]; exists { - continue // 首个命中胜出(保持 case-insensitive 去重后第一个定价) + if _, exists := pidx.byLower[lower]; exists { + continue // 首个命中胜出(case-insensitive 去重后第一个定价 / 第一个原始大小写) } cp := pricings[i].Clone() - byModel[lower] = &cp + pidx.byLower[lower] = &cp + pidx.originalCase[lower] = m + pidx.names = append(pidx.names, m) } } - return lookup -} - -// pricedNamesFor 返回指定平台下已索引的精确模型名(保留原始大小写,按添加顺序)。 -// 它是从 pricingLookup 中取 keys 并回查原始 ModelPricing 以得到原样字符串。 -func pricedNamesFor(pricings []ChannelModelPricing, platform string) []string { - seen := make(map[string]struct{}) - out := make([]string, 0) - for i := range pricings { - if pricings[i].Platform != platform { - continue - } - for _, m := range pricings[i].Models { - if _, wild := splitWildcardSuffix(m); wild { - continue - } - lower := strings.ToLower(m) - if _, ok := seen[lower]; ok { - continue - } - seen[lower] = struct{}{} - out = append(out, m) - } - } - return out + return idx } // SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。 @@ -452,16 +443,19 @@ func pricedNamesFor(pricings []ChannelModelPricing, platform string) []string { // - 遍历 Channel.ModelMapping 的每个 platform 条目; // - 映射 key 不带尾部 "*":直接作为一个支持模型名(即使没有匹配的定价行,也会产出 Pricing=nil 的条目); // - 映射 key 带尾部 "*":用同 platform 的 ModelPricing.Models 做前缀匹配展开(定价中带 "*" 的条目被忽略,因为它们本身就是模式,不是具体模型名); +// - 映射 key 为 `"*"`(单独一个星号)将展开为该平台所有定价模型(前缀为空 → 全匹配)。这是刻意行为,用于"将该平台所有模型透传"的场景; // - 未在 ModelMapping 中出现的 platform 不会产出任何条目——这是**刻意设计**("没配映射就不显示"),即使该平台有定价行。 // -// 每个结果尝试从 pricingLookup(平台+模型名索引)查找精确定价,未配置则 Pricing=nil。 +// 当映射 key(exact 或 wildcard 展开后的候选)能命中定价时,结果中的 Name 使用**定价的原始大小写** +// (定价是模型身份的事实来源),否则保留映射 key 的原始大小写。 +// 每个结果尝试从 platform 索引查找精确定价,未配置则 Pricing=nil。 // 结果按 (Platform, Name) 稳定排序,并按 (Platform, lowercase(Name)) 去重。 func (c *Channel) SupportedModels() []SupportedModel { if c == nil || len(c.ModelMapping) == 0 { return nil } - lookup := buildPricingLookup(c.ModelPricing) + idx := buildPricingIndex(c.ModelPricing) type dedupKey struct { platform string @@ -470,20 +464,23 @@ func (c *Channel) SupportedModels() []SupportedModel { seen := make(map[dedupKey]struct{}) result := make([]SupportedModel, 0) - add := func(platform, name string) { + add := func(platform, name string, pidx *platformPricingIndex) { key := dedupKey{platform: platform, name: strings.ToLower(name)} if _, ok := seen[key]; ok { return } seen[key] = struct{}{} var pricing *ChannelModelPricing - if byModel, ok := lookup[platform]; ok { - if p, ok := byModel[strings.ToLower(name)]; ok { + displayName := name + if pidx != nil { + lower := strings.ToLower(name) + if p, ok := pidx.byLower[lower]; ok { pricing = p + displayName = pidx.originalCase[lower] // 定价大小写胜出 } } result = append(result, SupportedModel{ - Name: name, + Name: displayName, Platform: platform, Pricing: pricing, }) @@ -493,19 +490,22 @@ func (c *Channel) SupportedModels() []SupportedModel { if len(mapping) == 0 { continue } - pricedNames := pricedNamesFor(c.ModelPricing, platform) + pidx := idx[platform] // 可能为 nil(该平台无定价行) for src := range mapping { prefix, isWild := splitWildcardSuffix(src) if isWild { + if pidx == nil { + continue + } prefixLower := strings.ToLower(prefix) - for _, candidate := range pricedNames { + for _, candidate := range pidx.names { if strings.HasPrefix(strings.ToLower(candidate), prefixLower) { - add(platform, candidate) + add(platform, candidate, pidx) } } continue } - add(platform, src) + add(platform, src, pidx) } } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 700380c2..8e055518 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -65,12 +65,17 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, } sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) + billingSource := ch.BillingModelSource + if billingSource == "" { + billingSource = BillingModelSourceChannelMapped + } + out = append(out, AvailableChannel{ ID: ch.ID, Name: ch.Name, Description: ch.Description, Status: ch.Status, - BillingModelSource: ch.BillingModelSource, + BillingModelSource: billingSource, RestrictModels: ch.RestrictModels, Groups: groups, SupportedModels: ch.SupportedModels(), diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 812a3a63..7cb1b272 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -637,3 +637,34 @@ func TestSupportedModels_EmptyPlatformMapping(t *testing.T) { } require.Empty(t, ch.SupportedModels()) } + +func TestSupportedModels_ExactKeyUsesPricedCaseWhenAvailable(t *testing.T) { + // mapping key uses uppercase, pricing uses lowercase — pricing's case should win. + ch := &Channel{ + ModelPricing: []ChannelModelPricing{ + {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}}, + }, + ModelMapping: map[string]map[string]string{ + "openai": {"GPT-4o": "gpt-4o"}, + }, + } + got := ch.SupportedModels() + require.Len(t, got, 1) + require.Equal(t, "gpt-4o", got[0].Name) // pricing's case wins +} + +func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) { + // 映射 key 为单独的 "*":前缀为空 → 命中该平台所有定价模型(透传场景)。 + ch := &Channel{ + ModelPricing: []ChannelModelPricing{ + {ID: 1, Platform: "openai", Models: []string{"gpt-4o", "gpt-4o-mini"}}, + }, + ModelMapping: map[string]map[string]string{ + "openai": {"*": "gpt-4o"}, + }, + } + got := ch.SupportedModels() + require.Len(t, got, 2) + names := []string{got[0].Name, got[1].Name} + require.ElementsMatch(t, []string{"gpt-4o", "gpt-4o-mini"}, names) +} diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index eb7e91d8..7ad4af28 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -4,8 +4,9 @@ */ import { apiClient } from '../client' +import type { BillingMode, ChannelStatus, BillingModelSource } from '@/constants/channel' -export type BillingMode = 'token' | 'per_request' | 'image' +export type { BillingMode } from '@/constants/channel' export interface PricingInterval { id?: number @@ -46,8 +47,8 @@ export interface Channel { id: number name: string description: string - status: string - billing_model_source: string // "requested" | "upstream" + status: ChannelStatus + billing_model_source: BillingModelSource restrict_models: boolean features_config?: Record group_ids: number[] @@ -181,8 +182,8 @@ export interface AvailableChannel { id: number name: string description: string - status: string - billing_model_source: string + status: ChannelStatus + billing_model_source: BillingModelSource restrict_models: boolean groups: AvailableGroupRef[] supported_models: SupportedModel[] diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 403391a3..13f5d71e 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -85,18 +85,15 @@ interface Column { label: string } -withDefaults( - defineProps<{ - columns: Column[] - rows: Row[] - loading: boolean - pricingKeyPrefix: string - noPricingLabel: string - noModelsLabel: string - emptyLabel: string - }>(), - { loading: false } -) +defineProps<{ + columns: Column[] + rows: Row[] + loading: boolean + pricingKeyPrefix: string + noPricingLabel: string + noModelsLabel: string + emptyLabel: string +}>() const slots = useSlots() /** diff --git a/frontend/src/components/channels/PricingRow.vue b/frontend/src/components/channels/PricingRow.vue index 8db077c0..4134593b 100644 --- a/frontend/src/components/channels/PricingRow.vue +++ b/frontend/src/components/channels/PricingRow.vue @@ -7,6 +7,7 @@