Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
1b18a1226d chore: sync VERSION file with release v1.1.220 [skip ci] 2025-12-04 13:01:54 +00:00
Wesley Liddick
0b2372abab Merge pull request #756 from SunSeekerX/feature_api_disable_switch
feat(account): 新增账户自动防护禁用开关
2025-12-04 08:01:35 -05:00
SunSeekerX
8aca1f9dd1 feat(account): 新增账户自动防护禁用开关
支持 disableAutoProtection 配置项,启用后上游 401/400/429/529 错误不再自动禁用账户
2025-12-04 20:47:12 +08:00
github-actions[bot]
b63f2f78fc chore: sync VERSION file with release v1.1.219 [skip ci] 2025-12-04 01:48:56 +00:00
Wesley Liddick
c971d239ff Merge pull request #752 from IanShaw027/fix/filter-cloudflare-cdn-headers
fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
2025-12-03 20:48:41 -05:00
Wesley Liddick
01d6e30e82 Merge pull request #751 from atoz03/feature/account-sort-toggle [skip ci]
feat(accounts): 支持账户排序正序/倒序切换
2025-12-03 20:48:24 -05:00
IanShaw027
5fd78b6411 fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
使用 Cloudflare 橙色云(CDN 代理模式)时,Cloudflare 会自动添加 CDN 相关的 headers
(cf-*, x-forwarded-*, cdn-loop 等),这会触发上游 API 提供商的安全检查:

1. 已确认问题:88code API 检测到 CDN headers 后返回 403 Forbidden,
   导致 Codex CLI 无法使用
2. 潜在风险:其他 API 提供商(OpenAI、Anthropic)可能也会因检测到
   代理/CDN 特征而采取限制措施

创建统一的 headerFilter 工具类,在所有转发服务中过滤 Cloudflare CDN headers,
使转发请求伪装成正常的直接客户端请求。

1. 新增 src/utils/headerFilter.js
   - 统一的 CDN headers 过滤列表(13 个 Cloudflare headers)
   - 提供 filterForOpenAI() 和 filterForClaude() 方法
   - 在现有过滤逻辑基础上添加 CDN header 过滤

2. 更新 src/services/openaiResponsesRelayService.js
   - 使用 filterForOpenAI() 替代内联的 _filterRequestHeaders()
   - 保持向后兼容性

3. 更新 src/services/claudeRelayService.js
   - 使用 filterForClaude() 替代 _filterClientHeaders() 实现
   - 简化代码,移除重复的 header 列表定义

4. 修复 src/routes/openaiRoutes.js
   - 添加对 input 字段的类型检查(可以是数组或字符串)
   - 防止 "startsWith is not a function" 错误

x-real-ip, x-forwarded-for, x-forwarded-proto, x-forwarded-host,
x-forwarded-port, x-accel-buffering, cf-ray, cf-connecting-ip,
cf-ipcountry, cf-visitor, cf-request-id, cdn-loop, true-client-ip

-  Codex CLI 通过中转服务成功调用 88code API(之前返回 403)
-  保留所有业务必需的 headers(conversation_id、session_id 等)
-  移除所有 Cloudflare CDN 痕迹
-  保持橙色云的 DDoS 防护和 CDN 加速优势
-  Docker 构建成功

1. 解决 88code 403 问题,Codex CLI 可正常使用
2. 降低因 CDN/代理特征被上游 API 识别的风险
3. 提升与各种 API 提供商的兼容性
4. 统一管理 CDN headers 过滤逻辑,便于维护
2025-12-03 07:07:12 -08:00
atoz03
9ad5c85c2c feat(accounts): 支持排序切换正序/倒序
- 统一下拉选择器和表头的排序变量
  - 再次点击同一排序选项/列头时切换排序方向
  - 动态更新排序图标指示当前方向
2025-12-03 20:25:26 +08:00
github-actions[bot]
279cd72f23 chore: sync VERSION file with release v1.1.218 [skip ci] 2025-12-02 12:52:01 +00:00
shaw
81e89d2dc4 feat: 支持sessionKey完成oauth授权 2025-12-02 20:43:47 +08:00
15 changed files with 7823 additions and 234 deletions

View File

@@ -1 +1 @@
1.1.217
1.1.220

6357
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
}
})
// =============================================================================
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
// =============================================================================
// 普通OAuth的Cookie自动授权
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based OAuth authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// Setup Token的Cookie自动授权
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程Setup Token模式
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based Setup Token authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {

View File

@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
groupId,
dailyQuota,
quotaResetTime,
maxConcurrentTasks
maxConcurrentTasks,
disableAutoProtection
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
}
// 校验上游错误自动防护开关
const normalizedDisableAutoProtection =
disableAutoProtection === true || disableAutoProtection === 'true'
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks:
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0
: 0,
disableAutoProtection: normalizedDisableAutoProtection
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
return res.status(404).json({ error: 'Account not found' })
}
// 规范化上游错误自动防护开关
if (mappedUpdates.disableAutoProtection !== undefined) {
mappedUpdates.disableAutoProtection =
mappedUpdates.disableAutoProtection === true ||
mappedUpdates.disableAutoProtection === 'true'
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除

View File

@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0 // 最大并发任务数0表示无限制
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options
// 验证必填字段
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数0表示无限制
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
}
const client = redis.getClientSafe()
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
quotaResetTime,
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true'
})
}
}
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
}
accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy)
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
if (updates.maxConcurrentTasks !== undefined) {
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
}
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段

View File

@@ -37,6 +37,8 @@ class ClaudeConsoleRelayService {
throw new Error('Claude Console Claude account not found')
}
const autoProtectionDisabled = account.disableAutoProtection === true
logger.info(
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
)
@@ -248,27 +250,41 @@ class ClaudeConsoleRelayService {
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
const errorDetails =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
}
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
@@ -597,6 +613,7 @@ class ClaudeConsoleRelayService {
})
response.data.on('end', async () => {
const autoProtectionDisabled = account.disableAutoProtection === true
// 记录原始错误消息到日志(方便调试,包含供应商信息)
logger.error(
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
@@ -609,25 +626,42 @@ class ClaudeConsoleRelayService {
)
if (response.status === 401) {
logger.warn(
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
}
} else if (response.status === 429) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
logger.warn(
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
logger.warn(
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
}
// 设置响应头
if (!responseStream.headersSent) {

View File

@@ -3,6 +3,7 @@ const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const ProxyHelper = require('../utils/proxyHelper')
const { filterForClaude } = require('../utils/headerFilter')
const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper')
@@ -877,62 +878,9 @@ class ClaudeRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
// 需要移除的敏感 headers
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding'
]
// 🆕 需要移除的浏览器相关 headers避免CORS问题
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
]
// 应该保留的 headers用于会话一致性和追踪
const allowedHeaders = [
'x-request-id',
'anthropic-version', // 保留API版本
'anthropic-beta' // 保留beta功能
]
const filteredHeaders = {}
// 转发客户端的非敏感 headers
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
// 如果在允许列表中,直接保留
if (allowedHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
// 如果不在敏感列表和浏览器列表中,也保留
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
return filterForClaude(clientHeaders)
}
_applyRequestIdentityTransform(body, headers, context = {}) {

View File

@@ -1,6 +1,7 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const { filterForOpenAI } = require('../utils/headerFilter')
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
const targetUrl = `${fullAccount.baseApi}${req.path}`
logger.info(`🎯 Forwarding to: ${targetUrl}`)
// 构建请求头
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
const headers = {
...this._filterRequestHeaders(req.headers),
...filterForOpenAI(req.headers),
Authorization: `Bearer ${fullAccount.apiKey}`,
'Content-Type': 'application/json'
}
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
return { resetsInSeconds, errorData }
}
// 过滤请求头
// 过滤请求头 - 已迁移到 headerFilter 工具类
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
_filterRequestHeaders(headers) {
const filtered = {}
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions'
]
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
return filterForOpenAI(headers)
}
// 估算费用(简化版本,实际应该根据不同的定价模型)

133
src/utils/headerFilter.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* 统一的 CDN Headers 过滤列表
*
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
* 避免触发上游 API如 88code的安全检查
*/
// Cloudflare CDN headers橙色云代理模式会添加这些
const cdnHeaders = [
'x-real-ip',
'x-forwarded-for',
'x-forwarded-proto',
'x-forwarded-host',
'x-forwarded-port',
'x-accel-buffering',
'cf-ray',
'cf-connecting-ip',
'cf-ipcountry',
'cf-visitor',
'cf-request-id',
'cdn-loop',
'true-client-ip'
]
/**
* 为 OpenAI/Responses API 过滤 headers
* 在原有 skipHeaders 基础上添加 CDN headers
*/
function filterForOpenAI(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
/**
* 为 Claude/Anthropic API 过滤 headers
* 在原有逻辑基础上添加 CDN headers 到敏感列表
*/
function filterForClaude(headers) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
...cdnHeaders // 添加 CDN headers
]
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access'
]
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
const filtered = {}
Object.keys(headers || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
}
})
return filtered
}
/**
* 为 Gemini API 过滤 headers如果需要转发客户端 headers 时使用)
* 目前 Gemini 服务不转发客户端 headers仅提供此方法备用
*/
function filterForGemini(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'connection',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
module.exports = {
cdnHeaders,
filterForOpenAI,
filterForClaude,
filterForGemini
}

View File

@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
}
// Cookie自动授权配置常量
const COOKIE_OAUTH_CONFIG = {
CLAUDE_AI_URL: 'https://claude.ai',
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
}
/**
* 生成随机的 state 参数
* @returns {string} 随机生成的 state (base64url编码)
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
return Object.keys(ext).length > 0 ? ext : null
}
// =============================================================================
// Cookie自动授权相关方法 (基于Clove项目实现)
// =============================================================================
/**
* 构建带Cookie的请求头
* @param {string} sessionKey - sessionKey值
* @returns {object} 请求头对象
*/
function buildCookieHeaders(sessionKey) {
return {
Accept: 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
Cookie: `sessionKey=${sessionKey}`,
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
}
/**
* 使用Cookie获取组织UUID和能力列表
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
*/
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
const headers = buildCookieHeaders(sessionKey)
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
}
logger.debug('🔄 Fetching organization info with Cookie', {
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
if (!response.data || !Array.isArray(response.data)) {
throw new Error('获取组织信息失败:响应格式无效')
}
// 找到具有chat能力且能力最多的组织
let bestOrg = null
let maxCapabilities = []
for (const org of response.data) {
const capabilities = org.capabilities || []
// 必须有chat能力
if (!capabilities.includes('chat')) {
continue
}
// 选择能力最多的组织
if (capabilities.length > maxCapabilities.length) {
bestOrg = org
maxCapabilities = capabilities
}
}
if (!bestOrg || !bestOrg.uuid) {
throw new Error('未找到具有chat能力的组织')
}
logger.success('✅ Found organization', {
uuid: bestOrg.uuid,
capabilities: maxCapabilities
})
return {
organizationUuid: bestOrg.uuid,
capabilities: maxCapabilities
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
throw new Error(`获取组织信息失败HTTP ${status}`)
} else if (error.request) {
throw new Error('获取组织信息失败:网络错误或超时')
}
throw error
}
}
/**
* 使用Cookie自动获取授权code
* @param {string} sessionKey - sessionKey值
* @param {string} organizationUuid - 组织UUID
* @param {string} scope - 授权scope
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
*/
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
// 生成PKCE参数
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// 构建授权URL
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
'{organization_uuid}',
organizationUuid
)
// 构建请求payload
const payload = {
response_type: 'code',
client_id: OAUTH_CONFIG.CLIENT_ID,
organization_uuid: organizationUuid,
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}
const headers = {
...buildCookieHeaders(sessionKey),
'Content-Type': 'application/json'
}
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
}
logger.debug('🔄 Requesting authorization with Cookie', {
url: authorizeUrl,
scope,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.post(authorizeUrl, payload, axiosConfig)
// 从响应中获取redirect_uri
const redirectUri = response.data?.redirect_uri
if (!redirectUri) {
throw new Error('授权响应中未找到redirect_uri')
}
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
// 解析redirect_uri获取authorization code
const url = new URL(redirectUri)
const authorizationCode = url.searchParams.get('code')
const responseState = url.searchParams.get('state')
if (!authorizationCode) {
throw new Error('redirect_uri中未找到授权码')
}
// 构建完整的授权码包含state如果有的话
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
logger.success('✅ Got authorization code via Cookie', {
codeLength: authorizationCode.length,
codePrefix: `${authorizationCode.substring(0, 10)}...`
})
return {
authorizationCode: fullCode,
codeVerifier,
state
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
const errorData = error.response.data
let errorMessage = `HTTP ${status}`
if (errorData) {
if (typeof errorData === 'string') {
errorMessage += `: ${errorData}`
} else if (errorData.error) {
errorMessage += `: ${errorData.error}`
}
}
throw new Error(`授权请求失败:${errorMessage}`)
} else if (error.request) {
throw new Error('授权请求失败:网络错误或超时')
}
throw error
}
}
/**
* 完整的Cookie自动授权流程
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @param {boolean} isSetupToken - 是否为Setup Token模式
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
*/
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
logger.info('🍪 Starting Cookie-based OAuth flow', {
isSetupToken,
hasProxy: !!proxyConfig
})
// 步骤1获取组织信息
logger.debug('Step 1/3: Fetching organization info...')
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
// 步骤2确定scope并获取授权code
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
logger.debug('Step 2/3: Getting authorization code...', { scope })
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
sessionKey,
organizationUuid,
scope,
proxyConfig
)
// 步骤3交换token
logger.debug('Step 3/3: Exchanging token...')
const tokenData = isSetupToken
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
logger.success('✅ Cookie-based OAuth flow completed', {
isSetupToken,
organizationUuid,
hasAccessToken: !!tokenData.accessToken,
hasRefreshToken: !!tokenData.refreshToken
})
return {
claudeAiOauth: tokenData,
organizationUuid,
capabilities
}
}
module.exports = {
OAUTH_CONFIG,
COOKIE_OAUTH_CONFIG,
generateOAuthParams,
generateSetupTokenParams,
exchangeCodeForTokens,
@@ -584,5 +882,10 @@ module.exports = {
generateCodeChallenge,
generateAuthUrl,
generateSetupTokenAuthUrl,
createProxyAgent
createProxyAgent,
// Cookie自动授权相关方法
buildCookieHeaders,
getOrganizationInfo,
authorizeWithCookie,
oauthWithCookie
}

View File

@@ -1157,6 +1157,7 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1351,6 +1352,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1587,6 +1589,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -3060,13 +3063,15 @@
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -3618,6 +3623,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3764,6 +3770,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3789,7 +3796,7 @@
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14",
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",
@@ -4028,6 +4035,7 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4525,6 +4533,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4915,6 +4924,7 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -5115,6 +5125,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",

View File

@@ -1451,6 +1451,26 @@
</p>
</div>
</div>
<!-- 上游错误处理 -->
<div v-if="form.platform === 'claude-console'">
<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
v-model="form.disableAutoProtection"
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"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段 -->
@@ -2029,6 +2049,7 @@
<!-- 步骤2: OAuth授权 -->
<OAuthFlow
v-if="oauthStep === 2 && form.addType === 'oauth'"
ref="oauthFlowRef"
:platform="form.platform"
:proxy="form.proxy"
@back="oauthStep = 1"
@@ -2052,11 +2073,45 @@
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
Claude Setup Token 授权
</h4>
<!-- 授权方式选择 -->
<div class="mb-4">
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式:
</p>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-link mr-1" />手动授权
</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-cookie mr-1" />Cookie 自动授权
</span>
</label>
</div>
</div>
<!-- 手动授权流程 -->
<div v-if="authMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
@@ -2182,6 +2237,113 @@
</div>
</div>
</div>
<!-- Cookie自动授权流程 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
使用 sessionKey 自动完成授权,无需手动打开链接。
</p>
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="space-y-4">
<div>
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-cookie text-blue-500" />sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ parsedSessionKeyCount }} 个
</span>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': cookieAuthError }"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
{{ cookieAuthError }}
</p>
</div>
<!-- 帮助说明 -->
<div>
<button
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i
:class="
showSessionKeyHelp
? 'fas fa-chevron-down mr-1'
: 'fas fa-chevron-right mr-1'
"
/>
如何获取 sessionKey
</button>
<div
v-if="showSessionKeyHelp"
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
>
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
<li>
4. 在左侧选择 <strong>"Cookies"</strong> →
<strong>"https://claude.ai"</strong>
</li>
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
</ol>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以 "sk-ant-" 开头
</p>
</div>
</div>
<!-- 开始授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 授权中... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>提示:</strong>如果您设置了代理Cookie授权也会使用相同的代理配置。
</p>
</div>
</div>
</div>
</div>
</div>
@@ -2196,6 +2358,7 @@
上一步
</button>
<button
v-if="authMethod === 'manual'"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchangeSetupToken || setupTokenExchanging"
type="button"
@@ -2927,6 +3090,26 @@
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
</div>
</div>
<!-- 上游错误处理(编辑模式)-->
<div v-if="form.platform === 'claude-console'">
<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
v-model="form.disableAutoProtection"
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"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
@@ -3538,6 +3721,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
const isEdit = computed(() => !!props.account)
const show = ref(true)
// OAuthFlow 组件引用
const oauthFlowRef = ref(null)
// OAuth步骤
const oauthStep = ref(1)
const loading = ref(false)
@@ -3551,6 +3737,22 @@ const setupTokenAuthCode = ref('')
const setupTokenCopied = ref(false)
const setupTokenSessionId = ref('')
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// Claude Code 统一 User-Agent 信息
const unifiedUserAgent = ref('')
const clearingCache = ref(false)
@@ -3750,6 +3952,7 @@ const form = ref({
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection: props.account?.disableAutoProtection === true,
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
@@ -4193,10 +4396,198 @@ const exchangeSetupTokenCode = async () => {
}
}
// 处理OAuth成功
const handleOAuthSuccess = async (tokenInfo) => {
// =============================================================================
// Cookie自动授权相关方法
// =============================================================================
// Cookie自动授权支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
const isSetupToken = form.value.addType === 'setup-token'
const proxyPayload = buildProxyPayload(form.value.proxy)
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const payload = {
sessionKey: sessionKeys[i],
...(proxyPayload && { proxy: proxyPayload })
}
let result
if (isSetupToken) {
result = await accountsStore.oauthSetupTokenWithCookie(payload)
} else {
result = await accountsStore.oauthWithCookie(payload)
}
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
try {
// 成功后处理OAuth数据传递数组
// cookieAuthLoading 保持 true直到账号创建完成
await handleOAuthSuccess(results)
} finally {
cookieAuthLoading.value = false
}
} else {
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
showSessionKeyHelp.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
// 切换到手动模式时清除Cookie相关状态
if (authMethod.value === 'manual') {
resetCookieAuth()
} else {
// 切换到Cookie模式时清除手动授权状态
setupTokenAuthUrl.value = ''
setupTokenAuthCode.value = ''
setupTokenSessionId.value = ''
}
}
// 构建 Claude 账户数据(辅助函数)
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
const proxyPayload = buildProxyPayload(form.value.proxy)
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
const data = {
name: accountName,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload,
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true
}
}
// 处理 extInfo
if (claudeOauthPayload) {
const extInfoPayload = {}
const extSource = claudeOauthPayload.extInfo
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
if (!extSource) {
if (claudeOauthPayload.organization?.uuid) {
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
}
if (claudeOauthPayload.account?.uuid) {
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
}
}
if (Object.keys(extInfoPayload).length > 0) {
data.extInfo = extInfoPayload
}
}
return data
}
// 处理OAuth成功支持批量
const handleOAuthSuccess = async (tokenInfoOrList) => {
loading.value = true
try {
const currentPlatform = form.value.platform
// Claude 平台支持批量创建
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
const tokenInfoList = tokenInfoOrList
const isBatch = tokenInfoList.length > 1
const baseName = form.value.name
const results = []
const errors = []
for (let i = 0; i < tokenInfoList.length; i++) {
const tokenInfo = tokenInfoList[i]
// 批量时自动命名
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
// 如果启用统一客户端标识,为每个账户生成独立 ID
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
try {
const result = await accountsStore.createClaudeAccount(data)
results.push(result)
} catch (error) {
errors.push({ name: accountName, error: error.message })
}
}
// 处理结果
if (results.length > 0) {
const msg = isBatch
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
: '账户创建成功'
showToast(msg, 'success')
emit('success', results[0]) // 兼容单个创建的返回
}
if (errors.length > 0) {
showToast(`${errors.length} 个账户创建失败`, 'error')
}
return
}
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
// OAuth模式也需要确保生成客户端ID
if (
form.value.platform === 'claude' &&
@@ -4218,8 +4609,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
proxy: proxyPayload
}
const currentPlatform = form.value.platform
if (currentPlatform === 'claude') {
// Claude使用claudeAiOauth字段
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
@@ -4380,6 +4769,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
oauthFlowRef.value?.resetCookieAuth()
}
}
@@ -4665,6 +5056,10 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -4993,6 +5388,8 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -5614,7 +6011,9 @@ watch(
dailyUsage: newAccount.dailyUsage || 0,
quotaResetTime: newAccount.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true
}
// 如果是Claude Console账户加载实时使用情况

View File

@@ -13,6 +13,146 @@
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
<!-- 授权方式选择 -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式
</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
</label>
</div>
</div>
<!-- Cookie自动授权表单 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
使用 claude.ai sessionKey 自动完成 OAuth 授权流程无需手动打开浏览器
</p>
<!-- sessionKey输入 -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-cookie text-blue-500" />
sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ parsedSessionKeyCount }}
</span>
<button
class="text-blue-500 hover:text-blue-600"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i class="fas fa-question-circle" />
</button>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y font-mono text-sm"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
</div>
<!-- 帮助说明 -->
<div
v-if="showSessionKeyHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
</h5>
<ol
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
>
<li>在浏览器中登录 <strong>claude.ai</strong></li>
<li>
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
打开开发者工具
</li>
<li>切换到 <strong>Application</strong>应用标签页</li>
<li>
在左侧找到 <strong>Cookies</strong> <strong>https://claude.ai</strong>
</li>
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
<li>复制其 <strong>Value</strong>列的内容</li>
</ol>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
开头
</p>
</div>
<!-- 错误信息 -->
<div
v-if="cookieAuthError"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1" />
{{ cookieAuthError }}
</p>
</div>
<!-- 授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<!-- 手动授权流程 -->
<div v-else>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
@@ -144,6 +284,7 @@
</div>
</div>
</div>
</div>
<!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'">
@@ -636,7 +777,9 @@
>
上一步
</button>
<!-- Cookie自动授权模式不显示此按钮Claude平台 -->
<button
v-if="!(platform === 'claude' && authMethod === 'cookie')"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchange || exchanging"
type="button"
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
const remainingSeconds = ref(0)
let countdownTimer = null
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// 计算是否可以交换code
const canExchange = computed(() => {
if (props.platform === 'droid') {
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
onBeforeUnmount(() => {
stopCountdown()
})
// Cookie自动授权处理支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
// 构建代理配置
const proxyConfig = props.proxy?.enabled
? {
type: props.proxy.type,
host: props.proxy.host,
port: parseInt(props.proxy.port),
username: props.proxy.username || null,
password: props.proxy.password || null
}
: null
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const result = await accountsStore.oauthWithCookie({
sessionKey: sessionKeys[i],
proxy: proxyConfig
})
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
// emit 后父组件会调用 handleOAuthSuccess 创建账号
// cookieAuthLoading 保持 true成功后表单会关闭失败时父组件会处理
emit('success', results) // 返回数组(单个时也是数组)
// 注意:不在这里设置 cookieAuthLoading = false
// 父组件创建账号完成后表单会关闭/重置
} else {
// 全部授权失败时才恢复按钮状态
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
cookieAuthLoading.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
resetCookieAuth()
authUrl.value = ''
authCode.value = ''
sessionId.value = ''
}
// 暴露方法供父组件调用
defineExpose({
resetCookieAuth
})
</script>

View File

@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// Cookie自动授权 - 普通OAuth
const oauthWithCookie = async (payload) => {
try {
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// Cookie自动授权 - Setup Token
const oauthSetupTokenWithCookie = async (payload) => {
try {
const response = await apiClient.post(
'/admin/claude-accounts/setup-token-with-cookie',
payload
)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 生成Gemini OAuth URL
const generateGeminiAuthUrl = async (proxyConfig) => {
try {
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
exchangeClaudeCode,
generateClaudeSetupTokenUrl,
exchangeClaudeSetupTokenCode,
oauthWithCookie,
oauthSetupTokenWithCookie,
generateGeminiAuthUrl,
exchangeGeminiCode,
generateOpenAIAuthUrl,

View File

@@ -19,12 +19,12 @@
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="accountSortBy"
icon="fa-sort-amount-down"
v-model="accountsSortBy"
:icon="accountsSortOrder === 'asc' ? 'fa-sort-amount-up' : 'fa-sort-amount-down'"
icon-color="text-indigo-500"
:options="sortOptions"
placeholder="选择排序"
@change="sortAccounts()"
@change="handleDropdownSort"
/>
</div>
@@ -1873,8 +1873,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const accountSortBy = ref('name')
const accountsSortBy = ref('')
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key"
@@ -2735,7 +2734,10 @@ const loadClaudeUsage = async () => {
}
}
// 排序账户
// 记录上一次的排序字段,用于判断下拉选择是否是同一字段被再次选择
let lastDropdownSortField = 'name'
// 排序账户(表头点击使用)
const sortAccounts = (field) => {
if (field) {
if (accountsSortBy.value === field) {
@@ -2744,9 +2746,23 @@ const sortAccounts = (field) => {
accountsSortBy.value = field
accountsSortOrder.value = 'asc'
}
// 同步下拉选择器的状态记录
lastDropdownSortField = field
}
}
// 下拉选择器排序处理(支持再次选择同一选项时切换排序方向)
const handleDropdownSort = (field) => {
if (field === lastDropdownSortField) {
// 选择同一字段,切换排序方向
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
// 选择不同字段,重置为升序
accountsSortOrder.value = 'asc'
}
lastDropdownSortField = field
}
// 格式化数字(与原版保持一致)
const formatNumber = (num) => {
if (num === null || num === undefined) return '0'
@@ -3993,20 +4009,20 @@ watch(
}
)
// 监听排序选择变化
watch(accountSortBy, (newVal) => {
const fieldMap = {
name: 'name',
dailyTokens: 'dailyTokens',
dailyRequests: 'dailyRequests',
totalTokens: 'totalTokens',
lastUsed: 'lastUsed'
}
if (fieldMap[newVal]) {
sortAccounts(fieldMap[newVal])
}
})
// 监听排序选择变化 - 已重构为 handleDropdownSort此处注释保留原逻辑参考
// watch(accountSortBy, (newVal) => {
// const fieldMap = {
// name: 'name',
// dailyTokens: 'dailyTokens',
// dailyRequests: 'dailyRequests',
// totalTokens: 'totalTokens',
// lastUsed: 'lastUsed'
// }
//
// if (fieldMap[newVal]) {
// sortAccounts(fieldMap[newVal])
// }
// })
watch(currentPage, () => {
updateSelectAllState()