fix: resolve 5 audit findings in channel/credits/scheduling
P0-1: Credits degraded response retry + fail-open - Add isAntigravityDegradedResponse() to detect transient API failures - Retry up to 3 times with exponential backoff (500ms/1s/2s) - Invalidate singleflight cache between retries - Fail-open after exhausting retries instead of 5h circuit break P1-1: Fix channel restriction pre-check timing conflict - Swap checkClaudeCodeRestriction before checkChannelPricingRestriction - Ensures channel restriction is checked against final fallback groupID P1-2: Add interval pricing validation (frontend + backend) - Backend: ValidateIntervals() with boundary, price, overlap checks - Frontend: validateIntervals() with Chinese error messages - Rules: MinTokens>=0, MaxTokens>MinTokens, prices>=0, no overlap P2: Fix cross-platform same-model pricing/mapping override - Store cache keys using original platform instead of group platform - Lookup across matching platforms (antigravity→anthropic→gemini) - Prevents anthropic/gemini same-name models from overwriting each other
This commit is contained in:
@@ -113,6 +113,70 @@ export function findModelConflict(models: string[]): [string, string] | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── 区间校验 ──────────────────────────────────────────────
|
||||
|
||||
/** 校验区间列表的合法性,返回错误消息;通过则返回 null */
|
||||
export function validateIntervals(intervals: IntervalFormEntry[]): string | null {
|
||||
if (!intervals || intervals.length === 0) return null
|
||||
|
||||
// 按 min_tokens 排序(不修改原数组)
|
||||
const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const err = validateSingleInterval(sorted[i], i)
|
||||
if (err) return err
|
||||
}
|
||||
return checkIntervalOverlap(sorted)
|
||||
}
|
||||
|
||||
function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
|
||||
if (iv.min_tokens < 0) {
|
||||
return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
|
||||
}
|
||||
if (iv.max_tokens != null) {
|
||||
if (iv.max_tokens <= 0) {
|
||||
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
|
||||
}
|
||||
if (iv.max_tokens <= iv.min_tokens) {
|
||||
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
|
||||
}
|
||||
}
|
||||
return validateIntervalPrices(iv, idx)
|
||||
}
|
||||
|
||||
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
|
||||
const prices: [string, number | string | null][] = [
|
||||
['输入价格', iv.input_price],
|
||||
['输出价格', iv.output_price],
|
||||
['缓存写入价格', iv.cache_write_price],
|
||||
['缓存读取价格', iv.cache_read_price],
|
||||
['单次价格', iv.per_request_price],
|
||||
]
|
||||
for (const [name, val] of prices) {
|
||||
if (val != null && val !== '' && Number(val) < 0) {
|
||||
return `区间 #${idx + 1}: ${name}不能为负数`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
// 无上限区间必须是最后一个
|
||||
if (sorted[i].max_tokens == null && i < sorted.length - 1) {
|
||||
return `区间 #${i + 1}: 无上限区间(最大 token 数为空)只能是最后一个`
|
||||
}
|
||||
if (i === 0) continue
|
||||
const prev = sorted[i - 1]
|
||||
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
|
||||
if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
|
||||
const prevMax = prev.max_tokens == null ? '∞' : String(prev.max_tokens)
|
||||
return `区间 #${i} 和 #${i + 1} 重叠:前一个区间上界 (${prevMax}) 大于当前区间下界 (${sorted[i].min_tokens})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 平台对应的模型 tag 样式(背景+文字) */
|
||||
export function getPlatformTagClass(platform: string): string {
|
||||
switch (platform) {
|
||||
|
||||
@@ -418,7 +418,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest } from '@/api/admin/channels'
|
||||
import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -922,6 +922,21 @@ 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)
|
||||
if (intervalErr) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
const modelLabel = entry.models.join(', ') || '未命名'
|
||||
appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { group_ids, model_pricing, model_mapping } = formToAPI()
|
||||
|
||||
submitting.value = true
|
||||
|
||||
Reference in New Issue
Block a user