diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index dcb68dc5..fa1a87c1 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -111,6 +111,18 @@ func (c *Channel) IsActive() bool { return c.Status == StatusActive } +// normalizeBillingModelSource 若 BillingModelSource 为空则回填默认值 ChannelMapped。 +// 作为 *Channel 的实体方法集中管理默认值,service 层只需在 Channel 进入内存 +// (缓存装填、repo 读出)时调用一次,下游读路径就无需重复兜底。 +func (c *Channel) normalizeBillingModelSource() { + if c == nil { + return + } + if c.BillingModelSource == "" { + c.BillingModelSource = BillingModelSourceChannelMapped + } +} + // GetModelPricing 根据模型名查找渠道定价,未找到返回 nil。 // 精确匹配,大小写不敏感。返回值拷贝,不污染缓存。 func (c *Channel) GetModelPricing(model string) *ChannelModelPricing { diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index a162d81d..7f6d1e85 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -66,7 +66,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, } sort.SliceStable(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) - normalizeBillingModelSource(ch) + ch.normalizeBillingModelSource() out = append(out, AvailableChannel{ ID: ch.ID, diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 4f22e205..51984400 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -301,6 +301,9 @@ func (s *ChannelService) fetchChannelData(ctx context.Context) ([]Channel, map[i } // populateChannelCache 将渠道列表和分组平台映射填充到缓存快照中。 +// 装填时对每个 Channel 统一归一化 BillingModelSource,让缓存命中的所有下游 +// (gateway routing / billing / 未来任何 cache-backed 读路径)都拿到已归一化的实体, +// 避免"每个出口各自记得 normalize"反模式。 func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *channelCache { cache := newEmptyChannelCache() cache.groupPlatform = groupPlatforms @@ -308,6 +311,7 @@ func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) * cache.loadedAt = time.Now() for i := range channels { + channels[i].normalizeBillingModelSource() ch := &channels[i] cache.byID[ch.ID] = ch for _, gid := range ch.GroupIDs { @@ -518,14 +522,13 @@ func (s *ChannelService) ResolveChannelMappingAndRestrict(ctx context.Context, g // resolveMapping 基于已查找的渠道信息解析模型映射。 // antigravity 分组依次尝试所有匹配平台,确保跨平台同名映射各自独立。 func resolveMapping(lk *channelLookup, groupID int64, model string) ChannelMappingResult { + // lk.channel 来自已装填的缓存,BillingModelSource 已在 populateChannelCache 阶段归一化, + // 这里无需重复兜底。 result := ChannelMappingResult{ MappedModel: model, ChannelID: lk.channel.ID, BillingModelSource: lk.channel.BillingModelSource, } - if result.BillingModelSource == "" { - result.BillingModelSource = BillingModelSourceChannelMapped - } modelLower := strings.ToLower(model) if mapped := lookupMappingAcrossPlatforms(lk.cache, groupID, lk.platform, modelLower); mapped != "" { @@ -686,7 +689,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) ApplyPricingToAccountStats: input.ApplyPricingToAccountStats, AccountStatsPricingRules: input.AccountStatsPricingRules, } - normalizeBillingModelSource(channel) + channel.normalizeBillingModelSource() if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err @@ -706,7 +709,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) if err != nil { return nil, err } - normalizeBillingModelSource(created) + created.normalizeBillingModelSource() return created, nil } @@ -717,18 +720,10 @@ func (s *ChannelService) GetByID(ctx context.Context, id int64) (*Channel, error if err != nil { return nil, err } - normalizeBillingModelSource(ch) + ch.normalizeBillingModelSource() return ch, nil } -// normalizeBillingModelSource 若 BillingModelSource 为空则回填默认值 ChannelMapped。 -// 统一在 service 层完成,避免 handler 响应层重复兜底。 -func normalizeBillingModelSource(ch *Channel) { - if ch != nil && ch.BillingModelSource == "" { - ch.BillingModelSource = BillingModelSourceChannelMapped - } -} - // Update 更新渠道 func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChannelInput) (*Channel, error) { channel, err := s.repo.GetByID(ctx, id) @@ -762,7 +757,7 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan if err != nil { return nil, err } - normalizeBillingModelSource(updated) + updated.normalizeBillingModelSource() return updated, nil } @@ -886,7 +881,7 @@ func (s *ChannelService) List(ctx context.Context, params pagination.PaginationP return nil, nil, err } for i := range channels { - normalizeBillingModelSource(&channels[i]) + channels[i].normalizeBillingModelSource() } return channels, res, nil } diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 0bd19518..96aa82a9 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -62,6 +62,7 @@ import DataTable from '@/components/common/DataTable.vue' import Icon from '@/components/icons/Icon.vue' import SupportedModelChip from './SupportedModelChip.vue' import type { UserSupportedModel } from '@/api/channels' +import type { ChannelStatus, BillingModelSource } from '@/constants/channel' interface GroupRef { id: number @@ -75,7 +76,10 @@ interface Row { groups: GroupRef[] // 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。 supported_models: UserSupportedModel[] - [key: string]: unknown + // admin 独有字段:用精确类型代替 `unknown`,让消费端无需 `as` 断言, + // 也能在后端新增 union 成员时让前端 Record 查表立刻出空而非崩溃。 + status?: ChannelStatus + billing_model_source?: BillingModelSource } interface Column { diff --git a/frontend/src/views/admin/AvailableChannelsView.vue b/frontend/src/views/admin/AvailableChannelsView.vue index 74e85618..a9b2462f 100644 --- a/frontend/src/views/admin/AvailableChannelsView.vue +++ b/frontend/src/views/admin/AvailableChannelsView.vue @@ -46,16 +46,16 @@ @@ -101,7 +101,7 @@ const columns = computed(() => [ /** * 显示样式:i18n label + Tailwind class,按 ChannelStatus 完整穷举。 - * 用 Record 强制未来新增状态时 TS 编译失败,避免遗漏分支。 + * Record 键类型强制未来新增 ChannelStatus 成员时 TS 编译失败,避免遗漏分支。 */ const statusStyles = computed>(() => ({ [CHANNEL_STATUS_ACTIVE]: { @@ -124,6 +124,19 @@ const billingSourceLabels = computed>(() => ( [BILLING_MODEL_SOURCE_CHANNEL_MAPPED]: t('admin.availableChannels.billingSource.channel_mapped') })) +// 运行时兜底:即便 service 层归一化漏点或后端新增未同步的 enum 值传入, +// 也不会触发 undefined.cls 崩溃;统一降级为 "-"。 +const DEFAULT_STATUS_STYLE = { label: '-', cls: '' } +const DEFAULT_BILLING_LABEL = '-' + +function statusStyleOf(status: ChannelStatus | undefined): { label: string; cls: string } { + return status ? statusStyles.value[status] : DEFAULT_STATUS_STYLE +} + +function billingSourceLabelOf(src: BillingModelSource | undefined): string { + return src ? billingSourceLabels.value[src] : DEFAULT_BILLING_LABEL +} + const filteredChannels = computed(() => { const q = searchQuery.value.trim().toLowerCase() if (!q) return channels.value