mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
fix(channels): 追加模型时不再改写已留空的价格条目
渠道定价条目价格留空时按各模型官方默认价回退计费,但向这类条目追加 模型时,前端会用「第一个新增模型」的默认价回填整条价格字段,保存后 条目内所有模型被静默统一成同一个价格,造成少计费或多计费。 自动填充现在只作用于「空条目首次加入单个模型」:条目已有模型时追加、 或一次加入多个模型时都不再改写价格,留空条目继续按各模型默认价回退。 - 新增 PricingEntryCard.modelDefaultPrice.spec.ts 覆盖 4 种场景 - 把该 spec 加入 Makefile 的 FRONTEND_CRITICAL_VITEST,使 CI 覆盖该回归 Closes #7796
This commit is contained in:
@@ -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
|
||||
|
||||
+108
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user