fix(web): 渠道冷却是按原因分类的,提示不再写死「连续失败 X/3」
后端冷却策略已改成按失败原因区分(余额/配额类首次失败即冷却 30 分钟,其余按连续 进冷却次数指数退避 30s→60s→120s 封顶 10 分钟),前端还停留在「满 3 次进冷却 + 原始 秒数」的旧假设上,管理员看到的提示与真实状态对不上。 - Channel 类型补 cooldown_reason(network | server_error | rate_limited | quota_exhausted) - 冷却提示带失败原因;配额类额外提示「需充值或换 key」——唯一重试不会自愈的场景, 应当让管理员直接处理而不是等 30 分钟再看 - 连续失败提示去掉硬编码的 /3(触发阈值随原因变化) - 新增 fmtDur:1800 → 30m、5400 → 1h30m,替代直接显示秒数(附 4 组用例)
This commit is contained in:
1 parent
979973ddde
commit
e7f8a5e5ad
4 files changed
+70
-6
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { fmtCost, fmtMoney, fmtNum, fmtTime } from './format'
|
import { fmtCost, fmtDur, fmtMoney, fmtNum, fmtTime } from './format'
|
||||||
|
|
||||||
describe('fmtCost', () => {
|
describe('fmtCost', () => {
|
||||||
it('零值为 $0', () => expect(fmtCost(0)).toBe('$0'))
|
it('零值为 $0', () => expect(fmtCost(0)).toBe('$0'))
|
||||||
@@ -26,3 +26,23 @@ describe('fmtTime', () => {
|
|||||||
})
|
})
|
||||||
it('合法时间可解析', () => expect(fmtTime('2026-01-01T00:00:00Z')).not.toBe('-'))
|
it('合法时间可解析', () => expect(fmtTime('2026-01-01T00:00:00Z')).not.toBe('-'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('fmtDur', () => {
|
||||||
|
it('一分钟内用秒', () => {
|
||||||
|
expect(fmtDur(0)).toBe('0s')
|
||||||
|
expect(fmtDur(45)).toBe('45s')
|
||||||
|
})
|
||||||
|
it('分钟带余秒', () => {
|
||||||
|
expect(fmtDur(90)).toBe('1m30s')
|
||||||
|
expect(fmtDur(600)).toBe('10m')
|
||||||
|
expect(fmtDur(1800)).toBe('30m')
|
||||||
|
})
|
||||||
|
it('小时级', () => {
|
||||||
|
expect(fmtDur(7200)).toBe('2h')
|
||||||
|
expect(fmtDur(5400)).toBe('1h30m')
|
||||||
|
})
|
||||||
|
it('负数与小数被归一', () => {
|
||||||
|
expect(fmtDur(-5)).toBe('0s')
|
||||||
|
expect(fmtDur(59.6)).toBe('1m')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,3 +20,18 @@ export function fmtCost(v: number): string {
|
|||||||
if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
|
if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
|
||||||
return fmtMoney(v)
|
return fmtMoney(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtDur 秒数转人读时长:45 → 45s、90 → 1m30s、1800 → 30m、7200 → 2h。
|
||||||
|
// 冷却从 30s 的指数退避一路到配额类的 30 分钟,直接显示秒数已经不可读。
|
||||||
|
export function fmtDur(seconds: number): string {
|
||||||
|
const s = Math.max(0, Math.round(seconds))
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
if (s < 3600) {
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const r = s % 60
|
||||||
|
return r === 0 ? `${m}m` : `${m}m${r}s`
|
||||||
|
}
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
return m === 0 ? `${h}h` : `${h}h${m}m`
|
||||||
|
}
|
||||||
+2
-1
@@ -36,8 +36,9 @@ export interface Channel {
|
|||||||
timeout_ms: number
|
timeout_ms: number
|
||||||
max_concurrency: number
|
max_concurrency: number
|
||||||
health_status: string
|
health_status: string
|
||||||
runtime_fails: number // 运行期连续失败次数(进程内,满 3 次进冷却)
|
runtime_fails: number // 运行期连续失败次数(进程内;满阈值进冷却,余额/配额类首次即冷却)
|
||||||
cooldown_seconds: number // 运行期冷却剩余秒数,0 表示未冷却
|
cooldown_seconds: number // 运行期冷却剩余秒数,0 表示未冷却
|
||||||
|
cooldown_reason?: string // 最近失败原因:network | server_error | rate_limited | quota_exhausted
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack, PhArrowsClockwise
|
|||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||||||
import { fmtCost } from '@/lib/format'
|
import { fmtCost, fmtDur } from '@/lib/format'
|
||||||
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
@@ -177,13 +177,41 @@ async function testChannel(ch: Channel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示
|
// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示。
|
||||||
|
// 冷却时长与触发条件都随失败原因变化(余额/配额类首次失败即 30 分钟,其余指数退避),
|
||||||
|
// 不能再写死「/3」,也不能只给秒数。
|
||||||
|
const cooldownReasonLabel: Record<string, string> = {
|
||||||
|
network: '网络/超时',
|
||||||
|
server_error: '上游 5xx',
|
||||||
|
rate_limited: '上游限流',
|
||||||
|
quota_exhausted: '余额/配额耗尽',
|
||||||
|
}
|
||||||
|
|
||||||
|
function cooldownReason(ch: Channel): string {
|
||||||
|
const r = ch.cooldown_reason ?? ''
|
||||||
|
return cooldownReasonLabel[r] ?? r
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeHint(ch: Channel): string {
|
function runtimeHint(ch: Channel): string {
|
||||||
if (ch.cooldown_seconds > 0) return `冷却中 ${ch.cooldown_seconds}s`
|
if (ch.cooldown_seconds > 0) {
|
||||||
if (ch.runtime_fails > 0) return `连续失败 ${ch.runtime_fails}/3`
|
const reason = cooldownReason(ch)
|
||||||
|
const base = `冷却中 ${fmtDur(ch.cooldown_seconds)}`
|
||||||
|
if (isQuotaCooldown(ch)) return `${base} · ${reason},需充值或换 key`
|
||||||
|
return reason ? `${base} · ${reason}` : base
|
||||||
|
}
|
||||||
|
if (ch.runtime_fails > 0) {
|
||||||
|
const reason = cooldownReason(ch)
|
||||||
|
return reason ? `连续失败 ${ch.runtime_fails} 次 · ${reason}` : `连续失败 ${ch.runtime_fails} 次`
|
||||||
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 余额/配额耗尽是唯一的「重试不会自愈」场景:冷却结束也经常还是坏的,值得到管理员面前
|
||||||
|
// 直接点出来(充值或换 key),而不是让他等 30 分钟再看。
|
||||||
|
function isQuotaCooldown(ch: Channel): boolean {
|
||||||
|
return ch.cooldown_seconds > 0 && ch.cooldown_reason === 'quota_exhausted'
|
||||||
|
}
|
||||||
|
|
||||||
function needRecover(ch: Channel): boolean {
|
function needRecover(ch: Channel): boolean {
|
||||||
return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy'
|
return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user