refactor(channels): consolidate pricing index, tighten types, polish DTOs

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).
This commit is contained in:
erio
2026-04-21 01:05:14 +08:00
parent 654cfb6480
commit 365ef1fdf7
14 changed files with 128 additions and 108 deletions

View File

@@ -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<string, unknown>
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[]

View File

@@ -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()
/**

View File

@@ -7,6 +7,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatScaled } from '@/utils/pricing'
const props = withDefaults(
defineProps<{
@@ -18,11 +19,6 @@ const props = withDefaults(
{ value: null }
)
function formatScaled(value: number | null, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}
const display = computed(() =>
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
)

View File

@@ -120,6 +120,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingRow from './PricingRow.vue'
import { formatScaled } from '@/utils/pricing'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_PER_REQUEST,
@@ -193,11 +194,6 @@ const billingModeLabel = computed(() => {
}
})
function formatScaled(value: number | null | undefined, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}
function formatRange(min: number, max: number | null): string {
const maxLabel = max == null ? '∞' : String(max)
return `(${min}, ${maxLabel}]`

View File

@@ -955,8 +955,6 @@ export default {
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
tierLabel: 'Tier',
tokenRange: 'Token Range',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}
@@ -2048,8 +2046,6 @@ export default {
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
tierLabel: 'Tier',
tokenRange: 'Token Range',
unitPerMillion: '/ 1M tokens',
unitPerRequest: '/ request'
}

View File

@@ -959,8 +959,6 @@ export default {
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
tierLabel: '层级',
tokenRange: 'Token 区间',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}
@@ -2127,8 +2125,6 @@ export default {
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
tierLabel: '层级',
tokenRange: 'Token 区间',
unitPerMillion: '/ 1M token',
unitPerRequest: '/ 次'
}

View File

@@ -0,0 +1,13 @@
/**
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
*
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
*
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
*/
export function formatScaled(value: number | null, scale: number): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
}

View File

@@ -59,11 +59,7 @@
<template #cell-billing_model_source="{ row }">
<span class="text-xs text-gray-700 dark:text-gray-300">
{{
t(
`admin.availableChannels.billingSource.${row.billing_model_source || BILLING_MODEL_SOURCE_CHANNEL_MAPPED}`
)
}}
{{ t(`admin.availableChannels.billingSource.${row.billing_model_source}`) }}
</span>
</template>
</AvailableChannelsTable>
@@ -82,10 +78,7 @@ import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
CHANNEL_STATUS_ACTIVE,
BILLING_MODEL_SOURCE_CHANNEL_MAPPED
} from '@/constants/channel'
import { CHANNEL_STATUS_ACTIVE } from '@/constants/channel'
const { t } = useI18n()
const appStore = useAppStore()