fix(channels): 追加模型时不再改写已留空的价格条目

渠道定价条目价格留空时按各模型官方默认价回退计费,但向这类条目追加
模型时,前端会用「第一个新增模型」的默认价回填整条价格字段,保存后
条目内所有模型被静默统一成同一个价格,造成少计费或多计费。

自动填充现在只作用于「空条目首次加入单个模型」:条目已有模型时追加、
或一次加入多个模型时都不再改写价格,留空条目继续按各模型默认价回退。

- 新增 PricingEntryCard.modelDefaultPrice.spec.ts 覆盖 4 种场景
- 把该 spec 加入 Makefile 的 FRONTEND_CRITICAL_VITEST,使 CI 覆盖该回归

Closes #7796
This commit is contained in:
duckducktw
2026-10-01 23:59:58 +08:00
parent d6adebd22d
commit 4407849142
3 changed files with 114 additions and 3 deletions
+2 -1
View File
@@ -21,7 +21,8 @@ FRONTEND_CRITICAL_VITEST := \
src/views/admin/__tests__/SettingsView.spec.ts \
src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts \
src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts
# 一键编译前后端
build: build-backend build-frontend
@@ -411,9 +411,11 @@ async function onModelsUpdate(newModels: string[]) {
const oldModels = props.entry.models
emit('update', { ...props.entry, models: newModels })
// 只在新增模型且当前无价格时自动填充
// 仅在「空条目首次加入单个模型」时自动填充默认价:
// 条目已有模型时(即使价格留空、按各模型的官方默认价回退)追加模型不应改写整条价格,
// 一次加入多个模型也不应把第一个模型的价格套用到整条,否则条目内所有模型都会被静默改价。
const addedModels = newModels.filter(m => !oldModels.includes(m))
if (addedModels.length === 0) return
if (oldModels.length !== 0 || addedModels.length !== 1) return
// 检查是否所有价格字段都为空
const e = props.entry
@@ -0,0 +1,108 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PricingEntryCard from '../PricingEntryCard.vue'
import type { PricingFormEntry } from '../types'
import channelsAPI from '@/api/admin/channels'
vi.mock('@/api/admin/channels', () => ({
default: { getModelDefaultPricing: vi.fn() },
}))
vi.mock('vue-i18n', async importOriginal => ({
...await importOriginal<typeof import('vue-i18n')>(),
useI18n: () => ({ t: (key: string) => key }),
}))
const getModelDefaultPricing = vi.mocked(channelsAPI.getModelDefaultPricing)
function createEntry(models: string[] = []): PricingFormEntry {
return {
models,
billing_mode: 'token',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
fast_multiplier: null,
flex_multiplier: null,
reasoning_effort_multipliers: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [],
time_pricing: {
timezone: 'Asia/Shanghai',
weekdays_only: false,
periods: [],
},
}
}
function addModels(entry: PricingFormEntry, models: string[]) {
const wrapper = shallowMount(PricingEntryCard, { props: { entry } })
wrapper.findComponent({ name: 'ModelTagInput' }).vm.$emit('update:models', models)
return wrapper
}
const emittedEntries = (wrapper: ReturnType<typeof shallowMount>) =>
(wrapper.emitted('update') ?? []).map(e => e[0] as PricingFormEntry)
describe('PricingEntryCard default price auto-fill', () => {
beforeEach(() => {
vi.clearAllMocks()
getModelDefaultPricing.mockResolvedValue({ found: true, input_price: 3e-6, output_price: 15e-6 })
})
it('auto-fills the default price when a model is added to an empty entry', async () => {
const wrapper = addModels(createEntry(), ['claude-opus-4-8'])
await flushPromises()
expect(getModelDefaultPricing).toHaveBeenCalledTimes(1)
expect(getModelDefaultPricing).toHaveBeenCalledWith('claude-opus-4-8')
expect(emittedEntries(wrapper).at(-1)).toMatchObject({
models: ['claude-opus-4-8'],
input_price: 3,
output_price: 15,
})
})
it('does not overwrite prices when appending a model to an entry that already has one', async () => {
const entry = createEntry(['claude-opus-4-7'])
const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8'])
await flushPromises()
expect(getModelDefaultPricing).not.toHaveBeenCalled()
expect(emittedEntries(wrapper).at(-1)).toMatchObject({
models: ['claude-opus-4-7', 'claude-opus-4-8'],
})
for (const emitted of emittedEntries(wrapper)) {
expect(emitted.input_price).toBeNull()
expect(emitted.output_price).toBeNull()
expect(emitted.cache_write_price).toBeNull()
expect(emitted.cache_read_price).toBeNull()
}
})
it('does not auto-fill a shared price when several models are added to an empty entry at once', async () => {
const wrapper = addModels(createEntry(), ['claude-opus-4-7', 'claude-opus-4-8'])
await flushPromises()
expect(getModelDefaultPricing).not.toHaveBeenCalled()
expect(emittedEntries(wrapper).at(-1)).toMatchObject({
models: ['claude-opus-4-7', 'claude-opus-4-8'],
input_price: null,
output_price: null,
})
})
it('keeps manual prices untouched when a model is appended', async () => {
const entry = { ...createEntry(['claude-opus-4-7']), input_price: '1.5' }
const wrapper = addModels(entry, ['claude-opus-4-7', 'claude-opus-4-8'])
await flushPromises()
expect(getModelDefaultPricing).not.toHaveBeenCalled()
for (const emitted of emittedEntries(wrapper)) {
expect(emitted.input_price).toBe('1.5')
}
})
})