mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-03-29 23:14:57 +00:00
refactor: extract temp-unavailable policy and reusable account UI
This commit is contained in:
@@ -17,8 +17,39 @@ const logger = require('../../utils/logger')
|
||||
const oauthHelper = require('../../utils/oauthHelper')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const {
|
||||
isEmptyValue,
|
||||
parseBooleanLike,
|
||||
normalizeOptionalNonNegativeInteger
|
||||
} = require('../../utils/tempUnavailablePolicy')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
|
||||
const TEMP_UNAVAILABLE_TTL_FIELDS = ['tempUnavailable503TtlSeconds', 'tempUnavailable5xxTtlSeconds']
|
||||
|
||||
const normalizeTempUnavailablePolicyPayload = (payload, options = {}) => {
|
||||
const { partial = false } = options
|
||||
const normalized = {}
|
||||
|
||||
for (const field of TEMP_UNAVAILABLE_TTL_FIELDS) {
|
||||
if (partial && !Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawValue = payload[field]
|
||||
const parsedValue = normalizeOptionalNonNegativeInteger(rawValue)
|
||||
if (!isEmptyValue(rawValue) && parsedValue === null) {
|
||||
return { error: `${field} must be a non-negative integer` }
|
||||
}
|
||||
normalized[field] = parsedValue
|
||||
}
|
||||
|
||||
if (!partial || Object.prototype.hasOwnProperty.call(payload, 'disableTempUnavailable')) {
|
||||
normalized.disableTempUnavailable = parseBooleanLike(payload.disableTempUnavailable)
|
||||
}
|
||||
|
||||
return { normalized }
|
||||
}
|
||||
|
||||
// 生成OAuth授权URL
|
||||
router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -594,7 +625,10 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
expiresAt,
|
||||
extInfo,
|
||||
maxConcurrency,
|
||||
interceptWarmup
|
||||
interceptWarmup,
|
||||
disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
@@ -623,6 +657,16 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
|
||||
}
|
||||
|
||||
const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } =
|
||||
normalizeTempUnavailablePolicyPayload({
|
||||
disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds
|
||||
})
|
||||
if (tempUnavailablePolicyError) {
|
||||
return res.status(400).json({ error: tempUnavailablePolicyError })
|
||||
}
|
||||
|
||||
const newAccount = await claudeAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
@@ -641,7 +685,10 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||
extInfo: extInfo || null,
|
||||
maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用
|
||||
interceptWarmup: interceptWarmup === true // 拦截预热请求:默认为false
|
||||
interceptWarmup: interceptWarmup === true, // 拦截预热请求:默认为false
|
||||
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
@@ -685,6 +732,13 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
|
||||
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
|
||||
}
|
||||
|
||||
const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } =
|
||||
normalizeTempUnavailablePolicyPayload(mappedUpdates, { partial: true })
|
||||
if (tempUnavailablePolicyError) {
|
||||
return res.status(400).json({ error: tempUnavailablePolicyError })
|
||||
}
|
||||
Object.assign(mappedUpdates, normalizedTempUnavailablePolicy)
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (
|
||||
mappedUpdates.accountType &&
|
||||
|
||||
@@ -18,6 +18,11 @@ const tokenRefreshService = require('../tokenRefreshService')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../../utils/dateHelper')
|
||||
const { isOpus45OrNewer } = require('../../utils/modelHelper')
|
||||
const {
|
||||
parseBooleanLike,
|
||||
normalizeOptionalNonNegativeInteger,
|
||||
normalizeTempUnavailablePolicyInput
|
||||
} = require('../../utils/tempUnavailablePolicy')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -94,10 +99,20 @@ class ClaudeAccountService {
|
||||
expiresAt = null, // 账户订阅到期时间
|
||||
extInfo = null, // 额外扩展信息
|
||||
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
interceptWarmup = false, // 拦截预热请求(标题生成、Warmup等)
|
||||
disableTempUnavailable = false, // 是否禁用账号级临时冷却(temp_unavailable)
|
||||
tempUnavailable503TtlSeconds = null, // 账号级 503 冷却秒数(null 跟随全局)
|
||||
tempUnavailable5xxTtlSeconds = null // 账号级 5xx 冷却秒数(null 跟随全局)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({
|
||||
disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds
|
||||
})
|
||||
const normalized503Ttl = normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds
|
||||
const normalized5xxTtl = normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
|
||||
|
||||
let accountData
|
||||
const normalizedExtInfo = this._normalizeExtInfo(extInfo, claudeAiOauth)
|
||||
@@ -143,7 +158,11 @@ class ClaudeAccountService {
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
interceptWarmup: interceptWarmup.toString(),
|
||||
// 账号级临时冷却覆盖(空字符串表示跟随全局配置)
|
||||
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(),
|
||||
tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '',
|
||||
tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : ''
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -179,7 +198,11 @@ class ClaudeAccountService {
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
interceptWarmup: interceptWarmup.toString(),
|
||||
// 账号级临时冷却覆盖(空字符串表示跟随全局配置)
|
||||
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(),
|
||||
tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '',
|
||||
tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +251,10 @@ class ClaudeAccountService {
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
extInfo: normalizedExtInfo,
|
||||
interceptWarmup
|
||||
interceptWarmup,
|
||||
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds: normalized503Ttl,
|
||||
tempUnavailable5xxTtlSeconds: normalized5xxTtl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +571,11 @@ class ClaudeAccountService {
|
||||
'subscriptionInfo',
|
||||
account.id
|
||||
)
|
||||
const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({
|
||||
disableTempUnavailable: account.disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds: account.tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds: account.tempUnavailable5xxTtlSeconds
|
||||
})
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
@@ -619,7 +650,13 @@ class ClaudeAccountService {
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: account.interceptWarmup === 'true'
|
||||
interceptWarmup: account.interceptWarmup === 'true',
|
||||
// 账号级临时冷却覆盖
|
||||
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds:
|
||||
normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds,
|
||||
tempUnavailable5xxTtlSeconds:
|
||||
normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -713,7 +750,10 @@ class ClaudeAccountService {
|
||||
'subscriptionExpiresAt',
|
||||
'extInfo',
|
||||
'maxConcurrency',
|
||||
'interceptWarmup'
|
||||
'interceptWarmup',
|
||||
'disableTempUnavailable',
|
||||
'tempUnavailable503TtlSeconds',
|
||||
'tempUnavailable5xxTtlSeconds'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
let shouldClearAutoStopFields = false
|
||||
@@ -730,6 +770,14 @@ class ClaudeAccountService {
|
||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||
} else if (field === 'priority' || field === 'maxConcurrency') {
|
||||
updatedData[field] = value.toString()
|
||||
} else if (field === 'disableTempUnavailable') {
|
||||
updatedData[field] = parseBooleanLike(value) ? 'true' : 'false'
|
||||
} else if (
|
||||
field === 'tempUnavailable503TtlSeconds' ||
|
||||
field === 'tempUnavailable5xxTtlSeconds'
|
||||
) {
|
||||
const normalizedTtl = normalizeOptionalNonNegativeInteger(value)
|
||||
updatedData[field] = normalizedTtl !== null ? normalizedTtl.toString() : ''
|
||||
} else if (field === 'subscriptionInfo') {
|
||||
// 处理订阅信息更新
|
||||
updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
|
||||
56
src/utils/tempUnavailablePolicy.js
Normal file
56
src/utils/tempUnavailablePolicy.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const isEmptyValue = (value) => value === undefined || value === null || value === ''
|
||||
|
||||
const parseBooleanLike = (value) => {
|
||||
if (value === true || value === false) {
|
||||
return value
|
||||
}
|
||||
if (value === 1 || value === '1') {
|
||||
return true
|
||||
}
|
||||
if (value === 0 || value === '0') {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return normalized === 'true' || normalized === 'yes' || normalized === 'on'
|
||||
}
|
||||
|
||||
const normalizeOptionalNonNegativeInteger = (value) => {
|
||||
if (isEmptyValue(value)) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null
|
||||
}
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const normalizeTempUnavailablePolicyInput = (value = {}) => ({
|
||||
disableTempUnavailable: parseBooleanLike(value.disableTempUnavailable),
|
||||
tempUnavailable503TtlSeconds: normalizeOptionalNonNegativeInteger(
|
||||
value.tempUnavailable503TtlSeconds
|
||||
),
|
||||
tempUnavailable5xxTtlSeconds: normalizeOptionalNonNegativeInteger(
|
||||
value.tempUnavailable5xxTtlSeconds
|
||||
)
|
||||
})
|
||||
|
||||
const normalizeTempUnavailablePolicyFromAccountData = (accountData = {}) => {
|
||||
const normalized = normalizeTempUnavailablePolicyInput(accountData)
|
||||
return {
|
||||
disableTempUnavailable: normalized.disableTempUnavailable,
|
||||
ttl503Seconds: normalized.tempUnavailable503TtlSeconds,
|
||||
ttl5xxSeconds: normalized.tempUnavailable5xxTtlSeconds
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isEmptyValue,
|
||||
parseBooleanLike,
|
||||
normalizeOptionalNonNegativeInteger,
|
||||
normalizeTempUnavailablePolicyInput,
|
||||
normalizeTempUnavailablePolicyFromAccountData
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const logger = require('./logger')
|
||||
const { normalizeTempUnavailablePolicyFromAccountData } = require('./tempUnavailablePolicy')
|
||||
|
||||
const TEMP_UNAVAILABLE_PREFIX = 'temp_unavailable'
|
||||
const ERROR_HISTORY_PREFIX = 'error_history'
|
||||
@@ -57,6 +58,87 @@ const getRedis = () => {
|
||||
return _redis
|
||||
}
|
||||
|
||||
// 可读取账号级临时暂停配置的 Redis key 前缀映射
|
||||
const ACCOUNT_KEY_PREFIX_BY_TYPE = {
|
||||
'claude-official': 'claude:account:',
|
||||
claude: 'claude:account:'
|
||||
}
|
||||
|
||||
const EMPTY_TEMP_UNAVAILABLE_POLICY = {
|
||||
disableTempUnavailable: false,
|
||||
ttl503Seconds: null,
|
||||
ttl5xxSeconds: null
|
||||
}
|
||||
|
||||
const getAccountTempUnavailablePolicy = async (accountId, accountType) => {
|
||||
try {
|
||||
const accountPrefix = ACCOUNT_KEY_PREFIX_BY_TYPE[accountType]
|
||||
if (!accountPrefix) {
|
||||
return EMPTY_TEMP_UNAVAILABLE_POLICY
|
||||
}
|
||||
|
||||
const redis = getRedis()
|
||||
const client = redis.getClientSafe()
|
||||
const accountData = await client.hgetall(`${accountPrefix}${accountId}`)
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return EMPTY_TEMP_UNAVAILABLE_POLICY
|
||||
}
|
||||
|
||||
return normalizeTempUnavailablePolicyFromAccountData(accountData)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`⚠️ [UpstreamError] Failed to load account temp-unavailable policy for ${accountType}:${accountId}: ${error.message}`
|
||||
)
|
||||
return EMPTY_TEMP_UNAVAILABLE_POLICY
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAccountTtlOverride = ({ policy, statusCode, errorType }) => {
|
||||
if (!policy) {
|
||||
return { skip: false, ttlOverrideSeconds: null, reason: '' }
|
||||
}
|
||||
|
||||
if (policy.disableTempUnavailable) {
|
||||
return {
|
||||
skip: true,
|
||||
ttlOverrideSeconds: null,
|
||||
reason: 'account_temp_unavailable_disabled'
|
||||
}
|
||||
}
|
||||
|
||||
if (statusCode === 503 && policy.ttl503Seconds !== null) {
|
||||
if (policy.ttl503Seconds <= 0) {
|
||||
return {
|
||||
skip: true,
|
||||
ttlOverrideSeconds: null,
|
||||
reason: 'account_503_ttl_disabled'
|
||||
}
|
||||
}
|
||||
return {
|
||||
skip: false,
|
||||
ttlOverrideSeconds: policy.ttl503Seconds,
|
||||
reason: 'account_503_ttl_override'
|
||||
}
|
||||
}
|
||||
|
||||
if (errorType === 'server_error' && policy.ttl5xxSeconds !== null) {
|
||||
if (policy.ttl5xxSeconds <= 0) {
|
||||
return {
|
||||
skip: true,
|
||||
ttlOverrideSeconds: null,
|
||||
reason: 'account_5xx_ttl_disabled'
|
||||
}
|
||||
}
|
||||
return {
|
||||
skip: false,
|
||||
ttlOverrideSeconds: policy.ttl5xxSeconds,
|
||||
reason: 'account_5xx_ttl_override'
|
||||
}
|
||||
}
|
||||
|
||||
return { skip: false, ttlOverrideSeconds: null, reason: '' }
|
||||
}
|
||||
|
||||
// 根据 HTTP 状态码分类错误类型
|
||||
const classifyError = (statusCode) => {
|
||||
if (statusCode === 529) {
|
||||
@@ -216,18 +298,41 @@ const markTempUnavailable = async (
|
||||
return { success: false, reason: 'not_a_pausable_error' }
|
||||
}
|
||||
|
||||
const policy = await getAccountTempUnavailablePolicy(accountId, accountType)
|
||||
const policyDecision = resolveAccountTtlOverride({
|
||||
policy,
|
||||
statusCode,
|
||||
errorType
|
||||
})
|
||||
|
||||
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
|
||||
if (policyDecision.skip) {
|
||||
const redis = getRedis()
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(key).catch(() => {})
|
||||
logger.info(
|
||||
`⏭️ [UpstreamError] Skip temp-unavailable for account ${accountId} (${accountType}), reason: ${policyDecision.reason}`
|
||||
)
|
||||
return { success: true, skipped: true, reason: policyDecision.reason }
|
||||
}
|
||||
|
||||
const ttlConfig = getTtlConfig()
|
||||
const parsedCustomTtl = Number(customTtl)
|
||||
const ttlSeconds =
|
||||
let ttlSeconds =
|
||||
Number.isFinite(parsedCustomTtl) && parsedCustomTtl > 0
|
||||
? Math.ceil(parsedCustomTtl)
|
||||
: ttlConfig[errorType]
|
||||
if (
|
||||
Number.isFinite(policyDecision.ttlOverrideSeconds) &&
|
||||
policyDecision.ttlOverrideSeconds > 0
|
||||
) {
|
||||
ttlSeconds = policyDecision.ttlOverrideSeconds
|
||||
}
|
||||
const markedAtIso = new Date().toISOString()
|
||||
const expiresAtIso = new Date(Date.now() + ttlSeconds * 1000).toISOString()
|
||||
|
||||
const redis = getRedis()
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
|
||||
await client.setex(
|
||||
key,
|
||||
ttlSeconds,
|
||||
|
||||
@@ -1632,6 +1632,13 @@
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TempUnavailablePolicyFields
|
||||
v-if="form.platform === 'claude'"
|
||||
v-model:disable-temp-unavailable="form.disableTempUnavailable"
|
||||
v-model:temp-unavailable-503-ttl-seconds="form.tempUnavailable503TtlSeconds"
|
||||
v-model:temp-unavailable-5xx-ttl-seconds="form.tempUnavailable5xxTtlSeconds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段 -->
|
||||
@@ -3410,6 +3417,13 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TempUnavailablePolicyFields
|
||||
v-if="form.platform === 'claude'"
|
||||
v-model:disable-temp-unavailable="form.disableTempUnavailable"
|
||||
v-model:temp-unavailable-503-ttl-seconds="form.tempUnavailable503TtlSeconds"
|
||||
v-model:temp-unavailable-5xx-ttl-seconds="form.tempUnavailable5xxTtlSeconds"
|
||||
/>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||
<div v-if="form.platform === 'openai-responses'" class="space-y-4">
|
||||
<div>
|
||||
@@ -4035,6 +4049,7 @@ import * as httpApis from '@/utils/http_apis'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import TempUnavailablePolicyFields from './TempUnavailablePolicyFields.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import GroupManagementModal from './GroupManagementModal.vue'
|
||||
import ApiKeyManagementModal from './ApiKeyManagementModal.vue'
|
||||
@@ -4270,6 +4285,27 @@ const initProxyConfig = () => {
|
||||
return normalizeProxyFormState(props.account?.proxy)
|
||||
}
|
||||
|
||||
const toFormCooldownOverrideValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return ''
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : ''
|
||||
}
|
||||
|
||||
const normalizeAccountCooldownOverride = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null
|
||||
}
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const toFormBoolean = (value) => value === true || value === 'true'
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
@@ -4326,9 +4362,14 @@ const form = ref({
|
||||
})(),
|
||||
userAgent: props.account?.userAgent || '',
|
||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||
disableAutoProtection:
|
||||
props.account?.disableAutoProtection === true ||
|
||||
props.account?.disableAutoProtection === 'true',
|
||||
disableAutoProtection: toFormBoolean(props.account?.disableAutoProtection),
|
||||
disableTempUnavailable: toFormBoolean(props.account?.disableTempUnavailable),
|
||||
tempUnavailable503TtlSeconds: toFormCooldownOverrideValue(
|
||||
props.account?.tempUnavailable503TtlSeconds
|
||||
),
|
||||
tempUnavailable5xxTtlSeconds: toFormCooldownOverrideValue(
|
||||
props.account?.tempUnavailable5xxTtlSeconds
|
||||
),
|
||||
// 额度管理字段
|
||||
dailyQuota: props.account?.dailyQuota || 0,
|
||||
dailyUsage: props.account?.dailyUsage || 0,
|
||||
@@ -4367,6 +4408,16 @@ const form = ref({
|
||||
expiresAt: props.account?.expiresAt || null
|
||||
})
|
||||
|
||||
const buildClaudeTempUnavailablePolicyPayload = () => ({
|
||||
disableTempUnavailable: !!form.value.disableTempUnavailable,
|
||||
tempUnavailable503TtlSeconds: normalizeAccountCooldownOverride(
|
||||
form.value.tempUnavailable503TtlSeconds
|
||||
),
|
||||
tempUnavailable5xxTtlSeconds: normalizeAccountCooldownOverride(
|
||||
form.value.tempUnavailable5xxTtlSeconds
|
||||
)
|
||||
})
|
||||
|
||||
// 模型限制配置
|
||||
const modelRestrictionMode = ref('whitelist') // 'whitelist' 或 'mapping'
|
||||
const allowedModels = ref([
|
||||
@@ -5031,6 +5082,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||
Object.assign(data, buildClaudeTempUnavailablePolicyPayload())
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -5775,6 +5827,7 @@ const updateAccount = async () => {
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||
Object.assign(data, buildClaudeTempUnavailablePolicyPayload())
|
||||
// 更新订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -6454,8 +6507,14 @@ watch(
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||
// 上游错误处理
|
||||
disableAutoProtection:
|
||||
newAccount.disableAutoProtection === true || newAccount.disableAutoProtection === 'true'
|
||||
disableAutoProtection: toFormBoolean(newAccount.disableAutoProtection),
|
||||
disableTempUnavailable: toFormBoolean(newAccount.disableTempUnavailable),
|
||||
tempUnavailable503TtlSeconds: toFormCooldownOverrideValue(
|
||||
newAccount.tempUnavailable503TtlSeconds
|
||||
),
|
||||
tempUnavailable5xxTtlSeconds: toFormCooldownOverrideValue(
|
||||
newAccount.tempUnavailable5xxTtlSeconds
|
||||
)
|
||||
}
|
||||
|
||||
// 如果是Claude Console账户,加载实时使用情况
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div :class="resolvedContainerClass">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
账号级临时冷却覆盖
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
:checked="disableTempUnavailable"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
@change="handleDisableChange"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
禁用该账号临时冷却(不再因 503/5xx 自动进入 TTL)
|
||||
</span>
|
||||
</label>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
503 冷却秒数(留空=全局,0=关闭)
|
||||
</label>
|
||||
<input
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="例如 30"
|
||||
type="number"
|
||||
:value="tempUnavailable503TtlSeconds"
|
||||
@input="handle503Input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
5xx 冷却秒数(留空=全局,0=关闭)
|
||||
</label>
|
||||
<input
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="例如 120"
|
||||
type="number"
|
||||
:value="tempUnavailable5xxTtlSeconds"
|
||||
@input="handle5xxInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
disableTempUnavailable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tempUnavailable503TtlSeconds: {
|
||||
type: [Number, String],
|
||||
default: ''
|
||||
},
|
||||
tempUnavailable5xxTtlSeconds: {
|
||||
type: [Number, String],
|
||||
default: ''
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:disableTempUnavailable',
|
||||
'update:tempUnavailable503TtlSeconds',
|
||||
'update:tempUnavailable5xxTtlSeconds'
|
||||
])
|
||||
|
||||
const resolvedContainerClass = computed(() => {
|
||||
const baseClass = 'rounded-lg border border-amber-200/60 p-3 dark:border-amber-700/40'
|
||||
return props.containerClass ? `${baseClass} ${props.containerClass}` : baseClass
|
||||
})
|
||||
|
||||
const normalizeNumericInput = (value) => {
|
||||
if (value === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : ''
|
||||
}
|
||||
|
||||
const handleDisableChange = (event) => {
|
||||
emit('update:disableTempUnavailable', event.target.checked)
|
||||
}
|
||||
|
||||
const handle503Input = (event) => {
|
||||
emit('update:tempUnavailable503TtlSeconds', normalizeNumericInput(event.target.value))
|
||||
}
|
||||
|
||||
const handle5xxInput = (event) => {
|
||||
emit('update:tempUnavailable5xxTtlSeconds', normalizeNumericInput(event.target.value))
|
||||
}
|
||||
</script>
|
||||
@@ -2301,6 +2301,7 @@ const handleCancel = () => {
|
||||
const accounts = ref([])
|
||||
const accountsLoading = ref(false)
|
||||
const refreshingBalances = ref(false)
|
||||
const tempUnavailableNowTs = ref(Date.now())
|
||||
const accountsSortBy = ref('name')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||
@@ -3792,7 +3793,29 @@ const toPositiveInteger = (value) => {
|
||||
|
||||
const getTempUnavailableRemainingSeconds = (tempUnavailable) => {
|
||||
if (!tempUnavailable) return 0
|
||||
return toPositiveInteger(tempUnavailable.remainingSeconds || tempUnavailable.ttl)
|
||||
const serverRemainingSeconds = toPositiveInteger(
|
||||
tempUnavailable.remainingSeconds || tempUnavailable.ttl
|
||||
)
|
||||
|
||||
const recoveryAt = getTempUnavailableRecoveryAt(tempUnavailable)
|
||||
if (!recoveryAt) {
|
||||
return serverRemainingSeconds
|
||||
}
|
||||
|
||||
const recoveryAtTimestamp = new Date(recoveryAt).getTime()
|
||||
if (Number.isNaN(recoveryAtTimestamp)) {
|
||||
return serverRemainingSeconds
|
||||
}
|
||||
|
||||
const liveRemainingSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((recoveryAtTimestamp - tempUnavailableNowTs.value) / 1000)
|
||||
)
|
||||
|
||||
if (serverRemainingSeconds <= 0) {
|
||||
return liveRemainingSeconds
|
||||
}
|
||||
return Math.min(serverRemainingSeconds, liveRemainingSeconds)
|
||||
}
|
||||
|
||||
const getTempUnavailableCooldownSeconds = (tempUnavailable) => {
|
||||
@@ -5212,11 +5235,17 @@ const checkHorizontalScroll = () => {
|
||||
|
||||
// 窗口大小变化时重新检测
|
||||
let resizeObserver = null
|
||||
let tempUnavailableCountdownTimer = null
|
||||
|
||||
onMounted(() => {
|
||||
// 首次加载时强制刷新所有数据
|
||||
loadAccounts(true)
|
||||
|
||||
// 让临时不可用剩余时间在页面停留时也可见地递减
|
||||
tempUnavailableCountdownTimer = setInterval(() => {
|
||||
tempUnavailableNowTs.value = Date.now()
|
||||
}, 1000)
|
||||
|
||||
// 设置ResizeObserver监听表格容器大小变化
|
||||
nextTick(() => {
|
||||
if (tableContainerRef.value) {
|
||||
@@ -5236,6 +5265,10 @@ onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (tempUnavailableCountdownTimer) {
|
||||
clearInterval(tempUnavailableCountdownTimer)
|
||||
tempUnavailableCountdownTimer = null
|
||||
}
|
||||
window.removeEventListener('resize', checkHorizontalScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user