From b936925c8aa4a0b5d436a39f84339a9f6a38e7fd Mon Sep 17 00:00:00 2001 From: name <136912576+is7Qin@users.noreply.github.com> Date: Thu, 14 May 2026 23:48:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E6=8C=89=E6=AC=A1/=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=AE=A1=E8=B4=B9=E6=A8=A1=E5=BC=8F=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=20token=20=E5=8C=BA=E9=97=B4=E9=87=8D=E5=8F=A0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image 与 per_request 模式的层级按 tier_label (1K/2K/4K) 匹配, 不依赖 min/max token 范围, 多个层级共用 min=0/max=null 是预期形态。 原校验器一律按 token 上下文分段处理, 新增第二条图片层级时会被 "无上限区间只能是最后一个" 误拦, 导致 OpenAI gpt-image 等模型 无法保存按次定价。 validateIntervals 新增 mode 参数, image / per_request 模式跳过 区间重叠与 last-unlimited 检查, 保留单条 min/max 自洽与价格非负 校验。token 模式行为不变。 --- .../admin/channel/__tests__/types.spec.ts | 79 +++++++++++++++++++ .../src/components/admin/channel/types.ts | 16 +++- frontend/src/views/admin/ChannelsView.vue | 2 +- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/channel/__tests__/types.spec.ts diff --git a/frontend/src/components/admin/channel/__tests__/types.spec.ts b/frontend/src/components/admin/channel/__tests__/types.spec.ts new file mode 100644 index 00000000..9fd8e066 --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/types.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { validateIntervals, type IntervalFormEntry } from '../types' + +function makeInterval(over: Partial): IntervalFormEntry { + return { + min_tokens: 0, + max_tokens: null, + tier_label: '', + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + per_request_price: null, + sort_order: 0, + ...over, + } +} + +describe('validateIntervals', () => { + describe('token mode', () => { + it('rejects unbounded interval that is not last', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }), + makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }), + ] + expect(validateIntervals(intervals, 'token')).toMatch(/无上限/) + }) + + it('accepts unbounded interval at the end', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ min_tokens: 0, max_tokens: 200000, input_price: 1, output_price: 1 }), + makeInterval({ min_tokens: 200000, max_tokens: null, input_price: 2, output_price: 2 }), + ] + expect(validateIntervals(intervals, 'token')).toBeNull() + }) + + it('rejects overlapping intervals', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ min_tokens: 0, max_tokens: 250000, input_price: 1, output_price: 1 }), + makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }), + ] + expect(validateIntervals(intervals, 'token')).toMatch(/重叠/) + }) + + it('defaults mode to token when omitted', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }), + makeInterval({ min_tokens: 100, max_tokens: 200, input_price: 2, output_price: 2 }), + ] + expect(validateIntervals(intervals)).toMatch(/无上限/) + }) + }) + + describe('image / per_request mode', () => { + it('allows multiple unbounded tiers identified by label', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ tier_label: '1K', per_request_price: 0.04 }), + makeInterval({ tier_label: '2K', per_request_price: 0.06 }), + makeInterval({ tier_label: '4K', per_request_price: 0.08 }), + ] + expect(validateIntervals(intervals, 'image')).toBeNull() + expect(validateIntervals(intervals, 'per_request')).toBeNull() + }) + + it('still rejects negative prices', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ tier_label: '1K', per_request_price: -1 }), + ] + expect(validateIntervals(intervals, 'image')).toMatch(/不能为负数/) + }) + + it('still rejects max <= min on a single tier', () => { + const intervals: IntervalFormEntry[] = [ + makeInterval({ tier_label: '1K', min_tokens: 100, max_tokens: 50, per_request_price: 0.04 }), + ] + expect(validateIntervals(intervals, 'image')).toMatch(/必须大于/) + }) + }) +}) diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 955b6487..0f3cfc20 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -115,8 +115,17 @@ export function findModelConflict(models: string[]): [string, string] | null { // ── 区间校验 ────────────────────────────────────────────── -/** 校验区间列表的合法性,返回错误消息;通过则返回 null */ -export function validateIntervals(intervals: IntervalFormEntry[]): string | null { +/** 校验区间列表的合法性,返回错误消息;通过则返回 null + * + * mode 决定区间语义: + * - token:区间是上下文 token 数分段 (min, max],不能重叠,无上限段必须放最后 + * - per_request / image:区间是按 tier_label 分层(1K/2K/4K 等),后端按 label + * 匹配,不依赖 min/max,因此跳过重叠 / last-unlimited 校验 + */ +export function validateIntervals( + intervals: IntervalFormEntry[], + mode: BillingMode = 'token', +): string | null { if (!intervals || intervals.length === 0) return null // 按 min_tokens 排序(不修改原数组) @@ -126,6 +135,9 @@ export function validateIntervals(intervals: IntervalFormEntry[]): string | null const err = validateSingleInterval(sorted[i], i) if (err) return err } + + // per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验 + if (mode !== 'token') return null return checkIntervalOverlap(sorted) } diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 89be573e..b2d6d8e6 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -1418,7 +1418,7 @@ async function handleSubmit() { for (const section of form.platforms.filter(s => s.enabled)) { for (const entry of section.model_pricing) { if (!entry.intervals || entry.intervals.length === 0) continue - const intervalErr = validateIntervals(entry.intervals) + const intervalErr = validateIntervals(entry.intervals, entry.billing_mode) if (intervalErr) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')