mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 为 Claude Console 账户添加并发控制机制
实现了完整的 Claude Console 账户并发任务数控制功能,防止单账户过载,提升服务稳定性。 **核心功能** - 🔒 **原子性并发控制**: 基于 Redis Sorted Set 实现的抢占式并发槽位管理,防止竞态条件 - 🔄 **自动租约刷新**: 流式请求每 5 分钟自动刷新租约,防止长连接租约过期 - 🚨 **智能降级处理**: 并发满额时自动清理粘性会话并重试其他账户(最多 1 次) - 🎯 **专用错误码**: 引入 `CONSOLE_ACCOUNT_CONCURRENCY_FULL` 错误码,区分并发限制和其他错误 - 📊 **批量性能优化**: 调度器使用 Promise.all 并行查询账户并发数,减少 Redis 往返 **后端实现** 1. **Redis 并发控制方法** (src/models/redis.js) - `incrConsoleAccountConcurrency()`: 增加并发计数(带租约) - `decrConsoleAccountConcurrency()`: 释放并发槽位 - `refreshConsoleAccountConcurrencyLease()`: 刷新租约(流式请求) - `getConsoleAccountConcurrency()`: 查询当前并发数 2. **账户服务增强** (src/services/claudeConsoleAccountService.js) - 添加 `maxConcurrentTasks` 字段(默认 0 表示无限制) - 获取账户时自动查询实时并发数 (`activeTaskCount`) - 支持更新并发限制配置 3. **转发服务并发保护** (src/services/claudeConsoleRelayService.js) - 请求前原子性抢占槽位,超限则立即回滚并抛出专用错误 - 流式请求启动定时器每 5 分钟刷新租约 - `finally` 块确保槽位释放(即使发生异常) - 为每个请求分配唯一 `requestId` 用于并发追踪 4. **统一调度器优化** (src/services/unifiedClaudeScheduler.js) - 获取可用账户时批量查询并发数(Promise.all 并行) - 预检查并发限制,避免选择已满的账户 - 检查分组成员时也验证并发状态 - 所有账户并发满额时抛出专用错误码 5. **API 路由降级处理** (src/routes/api.js) - 捕获 `CONSOLE_ACCOUNT_CONCURRENCY_FULL` 错误 - 自动清理粘性会话映射并重试(最多 1 次) - 重试失败返回 503 错误和友好提示 - count_tokens 端点也支持并发满额重试 6. **管理端点验证** (src/routes/admin.js) - 创建/更新账户时验证 `maxConcurrentTasks` 为非负整数 - 支持前端传入并发限制配置 **前端实现** 1. **表单字段** (web/admin-spa/src/components/accounts/AccountForm.vue) - 添加"最大并发任务数"输入框(创建和编辑模式) - 支持占位符提示"0 表示不限制" - 表单数据自动映射到后端 API 2. **实时监控** (web/admin-spa/src/views/AccountsView.vue) - 账户列表显示并发状态进度条和百分比 - 颜色编码:绿色(<80%)、黄色(80%-100%)、红色(100%) - 显示"X / Y"格式的并发数(如"2 / 5") - 未配置限制时显示"并发无限制"徽章
This commit is contained in:
@@ -1142,6 +1142,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并发控制字段 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
最大并发任务数
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.maxConcurrentTasks"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="0 表示不限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
限制该账户的并发请求数量,0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>模型限制 (可选)</label
|
||||
@@ -2540,6 +2557,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并发控制字段(编辑模式)-->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
最大并发任务数
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.maxConcurrentTasks"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="0 表示不限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
限制该账户的并发请求数量,0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>模型限制 (可选)</label
|
||||
@@ -2873,6 +2907,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并发控制字段 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
最大并发任务数
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.maxConcurrentTasks"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="0 表示不限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
限制该账户的并发请求数量,0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bedrock 特定字段(编辑模式)-->
|
||||
@@ -3542,6 +3593,8 @@ const form = ref({
|
||||
dailyQuota: props.account?.dailyQuota || 0,
|
||||
dailyUsage: props.account?.dailyUsage || 0,
|
||||
quotaResetTime: props.account?.quotaResetTime || '00:00',
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
||||
// Bedrock 特定字段
|
||||
accessKeyId: props.account?.accessKeyId || '',
|
||||
secretAccessKey: props.account?.secretAccessKey || '',
|
||||
@@ -4436,6 +4489,8 @@ const createAccount = async () => {
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
// 并发控制字段
|
||||
data.maxConcurrentTasks = form.value.maxConcurrentTasks || 0
|
||||
} else if (form.value.platform === 'openai-responses') {
|
||||
// OpenAI-Responses 账户特定数据
|
||||
data.baseApi = form.value.baseApi
|
||||
@@ -4738,6 +4793,8 @@ const updateAccount = async () => {
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
// 并发控制字段
|
||||
data.maxConcurrentTasks = form.value.maxConcurrentTasks || 0
|
||||
}
|
||||
|
||||
// OpenAI-Responses 特定更新
|
||||
|
||||
@@ -955,43 +955,90 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无统计</div>
|
||||
</div>
|
||||
<!-- Claude Console: 显示每日额度使用进度 -->
|
||||
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
|
||||
<div v-if="Number(account.dailyQuota) > 0">
|
||||
<!-- Claude Console: 显示每日额度和并发状态 -->
|
||||
<div v-else-if="account.platform === 'claude-console'" class="space-y-3">
|
||||
<div>
|
||||
<template v-if="Number(account.dailyQuota) > 0">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">额度进度</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ getQuotaUsagePercent(account).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
:class="[
|
||||
'h-2 rounded-full transition-all duration-300',
|
||||
getQuotaBarClass(getQuotaUsagePercent(account))
|
||||
]"
|
||||
:style="{ width: Math.min(100, getQuotaUsagePercent(account)) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
|
||||
Number(account.dailyQuota).toFixed(2)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
剩余 ${{ formatRemainingQuota(account) }}
|
||||
<span class="ml-2 text-gray-400"
|
||||
>重置 {{ account.quotaResetTime || '00:00' }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-sm text-gray-400">
|
||||
<i class="fas fa-minus" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">额度进度</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ getQuotaUsagePercent(account).toFixed(1) }}%
|
||||
<span class="text-gray-600 dark:text-gray-300">并发状态</span>
|
||||
<span
|
||||
v-if="Number(account.maxConcurrentTasks || 0) > 0"
|
||||
class="font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ getConsoleConcurrencyPercent(account).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="Number(account.maxConcurrentTasks || 0) > 0"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
:class="[
|
||||
'h-2 rounded-full transition-all duration-300',
|
||||
getQuotaBarClass(getQuotaUsagePercent(account))
|
||||
getConcurrencyBarClass(getConsoleConcurrencyPercent(account))
|
||||
]"
|
||||
:style="{ width: Math.min(100, getQuotaUsagePercent(account)) + '%' }"
|
||||
:style="{
|
||||
width: Math.min(100, getConsoleConcurrencyPercent(account)) + '%'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
|
||||
:class="[
|
||||
'min-w-[48px] text-xs font-medium',
|
||||
getConcurrencyLabelClass(account)
|
||||
]"
|
||||
>
|
||||
${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
|
||||
Number(account.dailyQuota).toFixed(2)
|
||||
}}
|
||||
{{ Number(account.activeTaskCount || 0) }} /
|
||||
{{ Number(account.maxConcurrentTasks || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
剩余 ${{ formatRemainingQuota(account) }}
|
||||
<span class="ml-2 text-gray-400"
|
||||
>重置 {{ account.quotaResetTime || '00:00' }}</span
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-infinity mr-1" />并发无限制
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
<i class="fas fa-minus" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||||
<div v-if="account.codexUsage" class="space-y-2">
|
||||
@@ -3622,6 +3669,35 @@ const getQuotaBarClass = (percent) => {
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// 并发使用百分比(Claude Console)
|
||||
const getConsoleConcurrencyPercent = (account) => {
|
||||
const max = Number(account?.maxConcurrentTasks || 0)
|
||||
if (!max || max <= 0) return 0
|
||||
const active = Number(account?.activeTaskCount || 0)
|
||||
return Math.min(100, (active / max) * 100)
|
||||
}
|
||||
|
||||
// 并发进度条颜色(Claude Console)
|
||||
const getConcurrencyBarClass = (percent) => {
|
||||
if (percent >= 100) return 'bg-red-500'
|
||||
if (percent >= 80) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// 并发标签颜色(Claude Console)
|
||||
const getConcurrencyLabelClass = (account) => {
|
||||
const max = Number(account?.maxConcurrentTasks || 0)
|
||||
if (!max || max <= 0) return 'text-gray-500 dark:text-gray-400'
|
||||
const active = Number(account?.activeTaskCount || 0)
|
||||
if (active >= max) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
if (active >= max * 0.8) {
|
||||
return 'text-yellow-600 dark:text-yellow-400'
|
||||
}
|
||||
return 'text-gray-700 dark:text-gray-200'
|
||||
}
|
||||
|
||||
// 剩余额度(Claude Console)
|
||||
const formatRemainingQuota = (account) => {
|
||||
const used = Number(account?.usage?.daily?.cost || 0)
|
||||
|
||||
Reference in New Issue
Block a user