diff --git a/VERSION b/VERSION index 3e8f33bb..5b7cfb35 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.169 +1.1.172 diff --git a/src/app.js b/src/app.js index 13d4d331..42e8ce26 100644 --- a/src/app.js +++ b/src/app.js @@ -556,6 +556,62 @@ class Application { logger.info( `🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)` ) + + // 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题) + // 每分钟主动清理所有过期的并发项,不依赖请求触发 + setInterval(async () => { + try { + const keys = await redis.keys('concurrency:*') + if (keys.length === 0) { + return + } + + const now = Date.now() + let totalCleaned = 0 + + // 使用 Lua 脚本批量清理所有过期项 + for (const key of keys) { + try { + const cleaned = await redis.client.eval( + ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + -- 清理过期项 + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + -- 获取剩余计数 + local count = redis.call('ZCARD', key) + + -- 如果计数为0,删除键 + if count <= 0 then + redis.call('DEL', key) + return 1 + end + + return 0 + `, + 1, + key, + now + ) + if (cleaned === 1) { + totalCleaned++ + } + } catch (error) { + logger.error(`❌ Failed to clean concurrency key ${key}:`, error) + } + } + + if (totalCleaned > 0) { + logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) + } + } catch (error) { + logger.error('❌ Concurrency cleanup task failed:', error) + } + }, 60000) // 每分钟执行一次 + + logger.info('🔢 Concurrency cleanup task started (running every 1 minute)') } setupGracefulShutdown() { @@ -583,6 +639,21 @@ class Application { logger.error('❌ Error stopping rate limit cleanup service:', error) } + // 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏) + try { + logger.info('🔢 Cleaning up all concurrency counters...') + const keys = await redis.keys('concurrency:*') + if (keys.length > 0) { + await redis.client.del(...keys) + logger.info(`✅ Cleaned ${keys.length} concurrency keys`) + } else { + logger.info('✅ No concurrency keys to clean') + } + } catch (error) { + logger.error('❌ Error cleaning up concurrency counters:', error) + // 不阻止退出流程 + } + try { await redis.disconnect() logger.info('👋 Redis disconnected') diff --git a/src/routes/admin.js b/src/routes/admin.js index b0aad407..bf61bd26 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2266,7 +2266,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { autoStopOnWarning, useUnifiedUserAgent, useUnifiedClientId, - unifiedClientId + unifiedClientId, + expiresAt } = req.body if (!name) { @@ -2309,7 +2310,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { autoStopOnWarning: autoStopOnWarning === true, // 默认为false useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false useUnifiedClientId: useUnifiedClientId === true, // 默认为false - unifiedClientId: unifiedClientId || '' // 统一的客户端标识 + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + expiresAt: expiresAt || null // 账户订阅到期时间 }) // 如果是分组类型,将账户添加到分组 @@ -2396,7 +2398,14 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => } } - await claudeAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + await claudeAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Claude account: ${accountId}`) return res.json({ success: true, message: 'Claude account updated successfully' }) @@ -2786,7 +2795,14 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, } } - await claudeConsoleAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Claude Console account: ${accountId}`) return res.json({ success: true, message: 'Claude Console account updated successfully' }) @@ -3196,7 +3212,14 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { } } - await ccrAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + await ccrAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated CCR account: ${accountId}`) return res.json({ success: true, message: 'CCR account updated successfully' }) @@ -3557,7 +3580,14 @@ router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) = }) } - const result = await bedrockAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates) if (!result.success) { return res @@ -3882,6 +3912,8 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { return { ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, groupInfos, usage: { daily: usageStats.daily, @@ -3899,6 +3931,8 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, groupInfos, usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, @@ -4023,7 +4057,14 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => } } - const updatedAccount = await geminiAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Gemini account: ${accountId}`) return res.json({ success: true, data: updatedAccount }) @@ -7530,6 +7571,13 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { : currentAccount.emailVerified } + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt (订阅过期时间) + // 注意:这里不影响上面 OAuth token 的 expiresAt 字段 + if ('expiresAt' in updates && !updates.openaiOauth?.expires_in) { + updateData.subscriptionExpiresAt = updates.expiresAt + delete updateData.expiresAt + } + const updatedAccount = await openaiAccountService.updateAccount(id, updateData) // 如果需要刷新但不强制成功(非关键更新) @@ -7916,7 +7964,14 @@ router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => const { id } = req.params const updates = req.body - const account = await azureOpenaiAccountService.updateAccount(id, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates) res.json({ success: true, @@ -8289,7 +8344,14 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) updates.priority = priority.toString() } - const result = await openaiResponsesAccountService.updateAccount(id, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates) if (!result.success) { return res.status(400).json(result) @@ -8655,6 +8717,9 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { return { ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + // OAuth token 的原始 expiresAt 保留在内部使用 + expiresAt: account.subscriptionExpiresAt || null, schedulable: account.schedulable === 'true', boundApiKeysCount, groupInfos, @@ -8668,6 +8733,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) return { ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, boundApiKeysCount: 0, groupInfos: [], usage: { @@ -8782,7 +8849,14 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { updates.accountType = targetAccountType } - const account = await droidAccountService.updateAccount(id, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + const account = await droidAccountService.updateAccount(id, mappedUpdates) try { if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { diff --git a/src/routes/api.js b/src/routes/api.js index 7d700c9d..f784cae6 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -10,30 +10,10 @@ const { authenticateApiKey } = require('../middleware/auth') const logger = require('../utils/logger') const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') const sessionHelper = require('../utils/sessionHelper') -const openaiToClaude = require('../services/openaiToClaude') -const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const router = express.Router() -// 🔍 检测模型对应的后端服务 -function detectBackendFromModel(modelName) { - if (!modelName) { - return 'claude' - } - if (modelName.startsWith('claude-')) { - return 'claude' - } - if (modelName.startsWith('gpt-')) { - return 'openai' - } - if (modelName.startsWith('gemini-')) { - return 'gemini' - } - return 'claude' // 默认使用 Claude -} - -const { updateRateLimitCounters } = require('../utils/rateLimitHelper') - function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { if (!rateLimitInfo) { return Promise.resolve({ totalTokens: 0, totalCost: 0 }) @@ -742,20 +722,25 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) // 🚀 Claude API messages 端点 - /claude/v1/messages (别名) router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) -// 📋 模型列表端点 - OpenAI 兼容,返回所有支持的模型 +// 📋 模型列表端点 - Claude Code 客户端需要 router.get('/v1/models', authenticateApiKey, async (req, res) => { try { - // 返回支持的模型列表(Claude + OpenAI + Gemini) + // 返回支持的模型列表 const models = [ - // Claude 模型 { - id: 'claude-sonnet-4-5-20250929', + id: 'claude-3-5-sonnet-20241022', object: 'model', created: 1669599635, owned_by: 'anthropic' }, { - id: 'claude-opus-4-1-20250805', + id: 'claude-3-5-haiku-20241022', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-3-opus-20240229', object: 'model', created: 1669599635, owned_by: 'anthropic' @@ -765,92 +750,6 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { object: 'model', created: 1669599635, owned_by: 'anthropic' - }, - { - id: 'claude-opus-4-20250514', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - }, - { - id: 'claude-3-7-sonnet-20250219', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - }, - { - id: 'claude-3-5-sonnet-20241022', - object: 'model', - created: 1729036800, - owned_by: 'anthropic' - }, - { - id: 'claude-3-5-haiku-20241022', - object: 'model', - created: 1729036800, - owned_by: 'anthropic' - }, - { - id: 'claude-3-haiku-20240307', - object: 'model', - created: 1709251200, - owned_by: 'anthropic' - }, - { - id: 'claude-3-opus-20240229', - object: 'model', - created: 1736726400, - owned_by: 'anthropic' - }, - // OpenAI 模型 - { - id: 'gpt-4o', - object: 'model', - created: 1715367600, - owned_by: 'openai' - }, - { - id: 'gpt-4o-mini', - object: 'model', - created: 1721088000, - owned_by: 'openai' - }, - { - id: 'gpt-4-turbo', - object: 'model', - created: 1712102400, - owned_by: 'openai' - }, - { - id: 'gpt-4', - object: 'model', - created: 1687132800, - owned_by: 'openai' - }, - { - id: 'gpt-3.5-turbo', - object: 'model', - created: 1677649200, - owned_by: 'openai' - }, - // Gemini 模型 - { - id: 'gemini-1.5-pro', - object: 'model', - created: 1707868800, - owned_by: 'google' - }, - { - id: 'gemini-1.5-flash', - object: 'model', - created: 1715990400, - owned_by: 'google' - }, - { - id: 'gemini-2.0-flash-exp', - object: 'model', - created: 1733011200, - owned_by: 'google' } ] @@ -1083,385 +982,5 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => } }) -// 🔍 验证 OpenAI chat/completions 请求参数 -function validateChatCompletionRequest(body) { - if (!body || !body.messages || !Array.isArray(body.messages)) { - return { - valid: false, - error: { - message: 'Missing or invalid field: messages (must be an array)', - type: 'invalid_request_error', - code: 'invalid_request' - } - } - } - - if (body.messages.length === 0) { - return { - valid: false, - error: { - message: 'Messages array cannot be empty', - type: 'invalid_request_error', - code: 'invalid_request' - } - } - } - - return { valid: true } -} - -// 🌊 处理 Claude 流式请求 -async function handleClaudeStreamRequest( - claudeRequest, - apiKeyData, - req, - res, - accountId, - requestedModel -) { - logger.info(`🌊 Processing OpenAI stream request for model: ${requestedModel}`) - - // 设置 SSE 响应头 - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Connection', 'keep-alive') - res.setHeader('X-Accel-Buffering', 'no') - - // 创建中止控制器 - const abortController = new AbortController() - - // 处理客户端断开 - req.on('close', () => { - if (abortController && !abortController.signal.aborted) { - logger.info('🔌 Client disconnected, aborting Claude request') - abortController.abort() - } - }) - - // 获取该账号存储的 Claude Code headers - const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) - - // 使用转换后的响应流 - await claudeRelayService.relayStreamRequestWithUsageCapture( - claudeRequest, - apiKeyData, - res, - claudeCodeHeaders, - (usage) => { - // 记录使用统计 - if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { - const model = usage.model || claudeRequest.model - - apiKeyService - .recordUsageWithDetails(apiKeyData.id, usage, model, accountId) - .catch((error) => { - logger.error('❌ Failed to record usage:', error) - }) - } - }, - // 流转换器:将 Claude SSE 转换为 OpenAI SSE - (() => { - const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` - return (chunk) => openaiToClaude.convertStreamChunk(chunk, requestedModel, sessionId) - })(), - { - betaHeader: - 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' - } - ) - - return { abortController } -} - -// 📄 处理 Claude 非流式请求 -async function handleClaudeNonStreamRequest( - claudeRequest, - apiKeyData, - req, - res, - accountId, - requestedModel -) { - logger.info(`📄 Processing OpenAI non-stream request for model: ${requestedModel}`) - - // 获取该账号存储的 Claude Code headers - const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) - - // 发送请求到 Claude - const claudeResponse = await claudeRelayService.relayRequest( - claudeRequest, - apiKeyData, - req, - res, - claudeCodeHeaders, - { betaHeader: 'oauth-2025-04-20' } - ) - - // 解析 Claude 响应 - let claudeData - try { - claudeData = JSON.parse(claudeResponse.body) - } catch (error) { - logger.error('❌ Failed to parse Claude response:', error) - return { - error: { - status: 502, - data: { - error: { - message: 'Invalid response from Claude API', - type: 'api_error', - code: 'invalid_response' - } - } - } - } - } - - // 处理错误响应 - if (claudeResponse.statusCode >= 400) { - return { - error: { - status: claudeResponse.statusCode, - data: { - error: { - message: claudeData.error?.message || 'Claude API error', - type: claudeData.error?.type || 'api_error', - code: claudeData.error?.code || 'unknown_error' - } - } - } - } - } - - // 转换为 OpenAI 格式 - const openaiResponse = openaiToClaude.convertResponse(claudeData, requestedModel) - - // 记录使用统计 - if (claudeData.usage) { - const { usage } = claudeData - apiKeyService - .recordUsageWithDetails(apiKeyData.id, usage, claudeRequest.model, accountId) - .catch((error) => { - logger.error('❌ Failed to record usage:', error) - }) - } - - return { success: true, data: openaiResponse } -} - -// 🤖 处理 Claude 后端 -async function handleClaudeBackend(req, res, apiKeyData, requestedModel) { - // 转换 OpenAI 请求为 Claude 格式 - const claudeRequest = openaiToClaude.convertRequest(req.body) - - // 检查模型限制 - if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { - if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) { - return res.status(403).json({ - error: { - message: `Model ${requestedModel} is not allowed for this API key`, - type: 'invalid_request_error', - code: 'model_not_allowed' - } - }) - } - } - - // 生成会话哈希用于 sticky 会话 - const sessionHash = sessionHelper.generateSessionHash(claudeRequest) - - // 选择可用的 Claude 账户 - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( - apiKeyData, - sessionHash, - claudeRequest.model - ) - const { accountId } = accountSelection - - // 处理流式或非流式请求 - if (claudeRequest.stream) { - const { abortController } = await handleClaudeStreamRequest( - claudeRequest, - apiKeyData, - req, - res, - accountId, - requestedModel - ) - return { abortController } - } else { - const result = await handleClaudeNonStreamRequest( - claudeRequest, - apiKeyData, - req, - res, - accountId, - requestedModel - ) - - if (result.error) { - return res.status(result.error.status).json(result.error.data) - } - - return res.json(result.data) - } -} - -// 🔧 处理 OpenAI 后端(未实现) -async function handleOpenAIBackend(req, res, _apiKeyData, _requestedModel) { - return res.status(501).json({ - error: { - message: 'OpenAI backend not yet implemented for this endpoint', - type: 'not_implemented', - code: 'not_implemented' - } - }) -} - -// 💎 处理 Gemini 后端(未实现) -async function handleGeminiBackend(req, res, _apiKeyData, _requestedModel) { - return res.status(501).json({ - error: { - message: 'Gemini backend not yet implemented for this endpoint', - type: 'not_implemented', - code: 'not_implemented' - } - }) -} - -// 🗺️ 后端处理策略映射 -const backendHandlers = { - claude: handleClaudeBackend, - openai: handleOpenAIBackend, - gemini: handleGeminiBackend -} - -// 🚀 OpenAI 兼容的 chat/completions 处理器(智能路由) -async function handleChatCompletions(req, res) { - const startTime = Date.now() - let abortController = null - - try { - const apiKeyData = req.apiKey - - // 验证必需参数 - const validation = validateChatCompletionRequest(req.body) - if (!validation.valid) { - return res.status(400).json({ error: validation.error }) - } - - // 检测模型对应的后端 - const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022' - const backend = detectBackendFromModel(requestedModel) - - logger.debug( - `📥 Received OpenAI format request for model: ${requestedModel}, backend: ${backend}` - ) - - // 使用策略模式处理不同后端 - const handler = backendHandlers[backend] - if (!handler) { - return res.status(500).json({ - error: { - message: `Unsupported backend: ${backend}`, - type: 'server_error', - code: 'unsupported_backend' - } - }) - } - - // 调用对应的后端处理器 - const result = await handler(req, res, apiKeyData, requestedModel) - - // 保存 abort controller(用于清理) - if (result && result.abortController) { - ;({ abortController } = result) - } - - const duration = Date.now() - startTime - logger.info(`✅ OpenAI chat/completions request completed in ${duration}ms`) - return undefined - } catch (error) { - logger.error('❌ OpenAI chat/completions error:', error) - - const status = error.status || 500 - if (!res.headersSent) { - res.status(status).json({ - error: { - message: error.message || 'Internal server error', - type: 'server_error', - code: 'internal_error' - } - }) - } - return undefined - } finally { - // 清理资源 - if (abortController) { - abortController = null - } - } -} - -// 🔧 OpenAI 兼容的 completions 处理器(传统格式,转换为 chat 格式) -async function handleCompletions(req, res) { - try { - // 验证必需参数 - if (!req.body.prompt) { - return res.status(400).json({ - error: { - message: 'Prompt is required', - type: 'invalid_request_error', - code: 'invalid_request' - } - }) - } - - // 将传统 completions 格式转换为 chat 格式 - const chatRequest = { - model: req.body.model || 'claude-3-5-sonnet-20241022', - messages: [ - { - role: 'user', - content: req.body.prompt - } - ], - max_tokens: req.body.max_tokens, - temperature: req.body.temperature, - top_p: req.body.top_p, - stream: req.body.stream, - stop: req.body.stop, - n: req.body.n || 1, - presence_penalty: req.body.presence_penalty, - frequency_penalty: req.body.frequency_penalty, - logit_bias: req.body.logit_bias, - user: req.body.user - } - - // 使用 chat/completions 处理器 - req.body = chatRequest - await handleChatCompletions(req, res) - return undefined - } catch (error) { - logger.error('❌ OpenAI completions error:', error) - if (!res.headersSent) { - res.status(500).json({ - error: { - message: 'Failed to process completion request', - type: 'server_error', - code: 'internal_error' - } - }) - } - return undefined - } -} - -// 📋 OpenAI 兼容的 chat/completions 端点 -router.post('/v1/chat/completions', authenticateApiKey, handleChatCompletions) - -// 🔧 OpenAI 兼容的 completions 端点(传统格式) -router.post('/v1/completions', authenticateApiKey, handleCompletions) - module.exports = router module.exports.handleMessagesRequest = handleMessagesRequest diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index f931345e..365275bf 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -73,7 +73,8 @@ class ClaudeAccountService { autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度 useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent useUnifiedClientId = false, // 是否使用统一的客户端标识 - unifiedClientId = '' // 统一的客户端标识 + unifiedClientId = '', // 统一的客户端标识 + expiresAt = null // 账户订阅到期时间 } = options const accountId = uuidv4() @@ -113,7 +114,9 @@ class ClaudeAccountService { ? JSON.stringify(subscriptionInfo) : claudeAiOauth.subscriptionInfo ? JSON.stringify(claudeAiOauth.subscriptionInfo) - : '' + : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' } } else { // 兼容旧格式 @@ -141,7 +144,9 @@ class ClaudeAccountService { autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent // 手动设置的订阅信息 - subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' } } @@ -486,7 +491,7 @@ class ClaudeAccountService { createdAt: account.createdAt, lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, - expiresAt: account.expiresAt, + expiresAt: account.subscriptionExpiresAt || null, // 账户订阅到期时间 // 添加 scopes 字段用于判断认证方式 // 处理空字符串的情况,避免返回 [''] scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [], @@ -528,10 +533,7 @@ class ClaudeAccountService { useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识 // 添加停止原因 - stoppedReason: account.stoppedReason || null, - // 添加 Opus 限流信息 - opusRateLimitedAt: account.opusRateLimitedAt || null, - opusRateLimitEndAt: account.opusRateLimitEndAt || null + stoppedReason: account.stoppedReason || null } }) ) @@ -621,7 +623,8 @@ class ClaudeAccountService { 'autoStopOnWarning', 'useUnifiedUserAgent', 'useUnifiedClientId', - 'unifiedClientId' + 'unifiedClientId', + 'subscriptionExpiresAt' ] const updatedData = { ...accountData } let shouldClearAutoStopFields = false @@ -640,6 +643,9 @@ class ClaudeAccountService { } else if (field === 'subscriptionInfo') { // 处理订阅信息更新 updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) + } else if (field === 'subscriptionExpiresAt') { + // 处理订阅到期时间,允许 null 值(永不过期) + updatedData[field] = value ? value.toString() : '' } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { @@ -653,7 +659,7 @@ class ClaudeAccountService { updatedData.lastRefreshAt = new Date().toISOString() } } else { - updatedData[field] = value.toString() + updatedData[field] = value !== null && value !== undefined ? value.toString() : '' } } } @@ -772,6 +778,29 @@ class ClaudeAccountService { } } + /** + * 检查账户是否未过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - 如果未设置过期时间或未过期返回 true + */ + isAccountNotExpired(account) { + if (!account.subscriptionExpiresAt) { + return true // 未设置过期时间,视为永不过期 + } + + const expiryDate = new Date(account.subscriptionExpiresAt) + const now = new Date() + + if (expiryDate <= now) { + logger.debug( + `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + return false + } + + return true + } + // 🎯 智能选择可用账户(支持sticky会话和模型过滤) async selectAvailableAccount(sessionHash = null, modelName = null) { try { @@ -781,7 +810,8 @@ class ClaudeAccountService { (account) => account.isActive === 'true' && account.status !== 'error' && - account.schedulable !== 'false' + account.schedulable !== 'false' && + this.isAccountNotExpired(account) ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 @@ -876,7 +906,8 @@ class ClaudeAccountService { boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && - boundAccount.schedulable !== 'false' + boundAccount.schedulable !== 'false' && + this.isAccountNotExpired(boundAccount) ) { logger.info( `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` @@ -897,7 +928,8 @@ class ClaudeAccountService { account.isActive === 'true' && account.status !== 'error' && account.schedulable !== 'false' && - (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this.isAccountNotExpired(account) ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 6b97c881..9d1279bf 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -453,7 +453,9 @@ class ClaudeConsoleRelayService { let buffer = '' let finalUsageReported = false - const collectedUsageData = {} + const collectedUsageData = { + model: body.model || account?.defaultModel || null + } // 处理流数据 response.data.on('data', (chunk) => { @@ -517,14 +519,58 @@ class ClaudeConsoleRelayService { } } - if ( - data.type === 'message_delta' && - data.usage && - data.usage.output_tokens !== undefined - ) { - collectedUsageData.output_tokens = data.usage.output_tokens || 0 + if (data.type === 'message_delta' && data.usage) { + // 提取所有usage字段,message_delta可能包含完整的usage信息 + if (data.usage.output_tokens !== undefined) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0 + } - if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) { + // 提取input_tokens(如果存在) + if (data.usage.input_tokens !== undefined) { + collectedUsageData.input_tokens = data.usage.input_tokens || 0 + } + + // 提取cache相关的tokens + if (data.usage.cache_creation_input_tokens !== undefined) { + collectedUsageData.cache_creation_input_tokens = + data.usage.cache_creation_input_tokens || 0 + } + if (data.usage.cache_read_input_tokens !== undefined) { + collectedUsageData.cache_read_input_tokens = + data.usage.cache_read_input_tokens || 0 + } + + // 检查是否有详细的 cache_creation 对象 + if ( + data.usage.cache_creation && + typeof data.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.info( + '📊 [Console] Collected usage data from message_delta:', + JSON.stringify(collectedUsageData) + ) + + // 如果已经收集到了完整数据,触发回调 + if ( + collectedUsageData.input_tokens !== undefined && + collectedUsageData.output_tokens !== undefined && + !finalUsageReported + ) { + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + '🎯 [Console] Complete usage data collected:', + JSON.stringify(collectedUsageData) + ) usageCallback({ ...collectedUsageData, accountId }) finalUsageReported = true } @@ -569,6 +615,41 @@ class ClaudeConsoleRelayService { } } + // 🔧 兜底逻辑:确保所有未保存的usage数据都不会丢失 + if (!finalUsageReported) { + if ( + collectedUsageData.input_tokens !== undefined || + collectedUsageData.output_tokens !== undefined + ) { + // 补全缺失的字段 + if (collectedUsageData.input_tokens === undefined) { + collectedUsageData.input_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing input_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + if (collectedUsageData.output_tokens === undefined) { + collectedUsageData.output_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing output_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + // 确保有 model 字段 + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + `📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}` + ) + usageCallback({ ...collectedUsageData, accountId }) + finalUsageReported = true + } else { + logger.warn( + '⚠️ [Console] Stream completed but no usage data was captured! This indicates a problem with SSE parsing or API response format.' + ) + } + } + // 确保流正确结束 if (!responseStream.destroyed) { responseStream.end() diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 604aa10f..1072b869 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -10,16 +10,6 @@ const logger = require('../utils/logger') const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' -const MODEL_REASONING_CONFIG = { - 'claude-opus-4-1-20250805': 'off', - 'claude-sonnet-4-20250514': 'medium', - 'claude-sonnet-4-5-20250929': 'high', - 'gpt-5-2025-08-07': 'high', - 'gpt-5-codex': 'off' -} - -const VALID_REASONING_LEVELS = new Set(['low', 'medium', 'high']) - /** * Droid API 转发服务 */ @@ -35,16 +25,7 @@ class DroidRelayService { this.userAgent = 'factory-cli/0.19.4' this.systemPrompt = SYSTEM_PROMPT - this.modelReasoningMap = new Map() this.API_KEY_STICKY_PREFIX = 'droid_api_key' - - Object.entries(MODEL_REASONING_CONFIG).forEach(([modelId, level]) => { - if (!modelId) { - return - } - const normalized = typeof level === 'string' ? level.toLowerCase() : '' - this.modelReasoningMap.set(modelId, normalized) - }) } _normalizeEndpointType(endpointType) { @@ -82,7 +63,6 @@ class DroidRelayService { logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) } normalizedBody.model = mappedModel - normalizedBody.__forceDisableThinking = true } } @@ -901,9 +881,7 @@ class DroidRelayService { headers['x-api-key'] = 'placeholder' headers['x-api-provider'] = 'anthropic' - // 处理 anthropic-beta 头 - const reasoningLevel = this._getReasoningLevel(requestBody) - if (reasoningLevel) { + if (this._isThinkingRequested(requestBody)) { headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14' } } @@ -940,6 +918,36 @@ class DroidRelayService { return false } + /** + * 判断请求是否启用 Anthropic 推理模式 + */ + _isThinkingRequested(requestBody) { + const thinking = requestBody && typeof requestBody === 'object' ? requestBody.thinking : null + if (!thinking) { + return false + } + + if (thinking === true) { + return true + } + + if (typeof thinking === 'string') { + return thinking.trim().toLowerCase() === 'enabled' + } + + if (typeof thinking === 'object') { + if (thinking.enabled === true) { + return true + } + + if (typeof thinking.type === 'string') { + return thinking.type.trim().toLowerCase() === 'enabled' + } + } + + return false + } + /** * 处理请求体(注入 system prompt 等) */ @@ -950,17 +958,6 @@ class DroidRelayService { const hasStreamField = requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream') - const shouldDisableThinking = - endpointType === 'anthropic' && processedBody.__forceDisableThinking === true - - if ('__forceDisableThinking' in processedBody) { - delete processedBody.__forceDisableThinking - } - - if (requestBody && '__forceDisableThinking' in requestBody) { - delete requestBody.__forceDisableThinking - } - if (processedBody && Object.prototype.hasOwnProperty.call(processedBody, 'metadata')) { delete processedBody.metadata } @@ -975,7 +972,7 @@ class DroidRelayService { processedBody.stream = true } - // Anthropic 端点:处理 thinking 字段 + // Anthropic 端点:仅注入系统提示 if (endpointType === 'anthropic') { if (this.systemPrompt) { const promptBlock = { type: 'text', text: this.systemPrompt } @@ -990,30 +987,9 @@ class DroidRelayService { processedBody.system = [promptBlock] } } - - const reasoningLevel = shouldDisableThinking ? null : this._getReasoningLevel(requestBody) - if (reasoningLevel) { - const budgetTokens = { - low: 4096, - medium: 12288, - high: 24576 - } - processedBody.thinking = { - type: 'enabled', - budget_tokens: budgetTokens[reasoningLevel] - } - } else { - delete processedBody.thinking - } - - if (shouldDisableThinking) { - if ('thinking' in processedBody) { - delete processedBody.thinking - } - } } - // OpenAI 端点:处理 reasoning 字段 + // OpenAI 端点:仅前置系统提示 if (endpointType === 'openai') { if (this.systemPrompt) { if (processedBody.instructions) { @@ -1024,41 +1000,21 @@ class DroidRelayService { processedBody.instructions = this.systemPrompt } } + } - const reasoningLevel = this._getReasoningLevel(requestBody) - if (reasoningLevel) { - processedBody.reasoning = { - effort: reasoningLevel, - summary: 'auto' - } - } else { - delete processedBody.reasoning - } + // 处理 temperature 和 top_p 参数 + const hasValidTemperature = + processedBody.temperature !== undefined && processedBody.temperature !== null + const hasValidTopP = processedBody.top_p !== undefined && processedBody.top_p !== null + + if (hasValidTemperature && hasValidTopP) { + // 仅允许 temperature 或 top_p 其一,同时优先保留 temperature + delete processedBody.top_p } return processedBody } - /** - * 获取推理级别(如果在 requestBody 中配置) - */ - _getReasoningLevel(requestBody) { - if (!requestBody || !requestBody.model) { - return null - } - - const configured = this.modelReasoningMap.get(requestBody.model) - if (!configured) { - return null - } - - if (!VALID_REASONING_LEVELS.has(configured)) { - return null - } - - return configured - } - /** * 处理非流式响应 */ diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js index 1f335f0e..10c8ae24 100644 --- a/src/services/openaiToClaude.js +++ b/src/services/openaiToClaude.js @@ -31,25 +31,10 @@ class OpenAIToClaudeConverter { stream: openaiRequest.stream || false } - // 定义 Claude Code 的默认系统提示词 + // Claude Code 必需的系统消息 const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." - // 如果 OpenAI 请求中包含系统消息,提取并检查 - const systemMessage = this._extractSystemMessage(openaiRequest.messages) - if (systemMessage && systemMessage.includes('You are currently in Xcode')) { - // Xcode 系统提示词 - claudeRequest.system = systemMessage - logger.info( - `🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` - ) - logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) - } else { - // 使用 Claude Code 默认系统提示词 - claudeRequest.system = claudeCodeSystemMessage - logger.debug( - `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` - ) - } + claudeRequest.system = claudeCodeSystemMessage // 处理停止序列 if (openaiRequest.stop) { diff --git a/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js index d8922bd2..aff09fbf 100644 --- a/src/validators/clients/codexCliValidator.js +++ b/src/validators/clients/codexCliValidator.js @@ -42,7 +42,7 @@ class CodexCliValidator { // Codex CLI 的 UA 格式: // - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10) // - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal - const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i + const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d\.]+/i const uaMatch = userAgent.match(codexCliPattern) if (!uaMatch) { @@ -53,8 +53,7 @@ class CodexCliValidator { // 2. 对于特定路径,进行额外的严格验证 // 对于 /openai 和 /azure 路径需要完整验证 const strictValidationPaths = ['/openai', '/azure'] - const needsStrictValidation = - req.path && strictValidationPaths.some((path) => req.path.startsWith(path)) + const needsStrictValidation = req.path && strictValidationPaths.some(path => req.path.startsWith(path)) if (!needsStrictValidation) { // 其他路径,只要 User-Agent 匹配就认为是 Codex CLI @@ -125,12 +124,8 @@ class CodexCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) { - return -1 - } - if (part1 > part2) { - return 1 - } + if (part1 < part2) return -1 + if (part1 > part2) return 1 } return 0 diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js index 0d438384..ea8e60e7 100644 --- a/src/validators/clients/geminiCliValidator.js +++ b/src/validators/clients/geminiCliValidator.js @@ -53,11 +53,9 @@ class GeminiCliValidator { // 2. 对于 /gemini 路径,检查是否包含 generateContent if (path.includes('generateContent')) { // 包含 generateContent 的路径需要验证 User-Agent - const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i + const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i if (!geminiCliPattern.test(userAgent)) { - logger.debug( - `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}` - ) + logger.debug(`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`) return false } } @@ -84,12 +82,8 @@ class GeminiCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) { - return -1 - } - if (part1 > part2) { - return 1 - } + if (part1 < part2) return -1 + if (part1 > part2) return 1 } return 0 diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue new file mode 100644 index 00000000..046c3332 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 4199d0f0..67ab4390 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -641,6 +641,49 @@

+ +
+ +
+ +
+ +
+

+ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +

+

+ + 账户永不过期 +

+
+

+ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +

+
+
+ +
+ +
+ +
+ +
+

+ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +

+

+ + 账户永不过期 +

+
+

+ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +

+
+
-
+
+
+ + +
-
@@ -190,8 +200,33 @@ const getDisplayedApiKey = () => { } } -// 复制配置信息(环境变量格式) -const copyApiKey = async () => { +const droidEndpoint = computed(() => { + return getBaseUrlPrefix() + '/droid/claude' +}) + +// 通用复制工具,包含降级处理 +const copyTextWithFallback = async (text, successMessage) => { + try { + await navigator.clipboard.writeText(text) + showToast(successMessage, 'success') + } catch (error) { + const textArea = document.createElement('textarea') + textArea.value = text + document.body.appendChild(textArea) + textArea.select() + try { + document.execCommand('copy') + showToast(successMessage, 'success') + } catch (fallbackError) { + showToast('复制失败,请手动复制', 'error') + } finally { + document.body.removeChild(textArea) + } + } +} + +// 复制完整配置(包含提示信息) +const copyFullConfig = async () => { const key = props.apiKey.apiKey || props.apiKey.key || '' if (!key) { showToast('API Key 不存在', 'error') @@ -200,27 +235,22 @@ const copyApiKey = async () => { // 构建环境变量配置格式 const configText = `ANTHROPIC_BASE_URL="${currentBaseUrl.value}" -ANTHROPIC_AUTH_TOKEN="${key}"` +ANTHROPIC_AUTH_TOKEN="${key}" - try { - await navigator.clipboard.writeText(configText) - showToast('配置信息已复制到剪贴板', 'success') - } catch (error) { - // console.error('Failed to copy:', error) - // 降级方案:创建一个临时文本区域 - const textArea = document.createElement('textarea') - textArea.value = configText - document.body.appendChild(textArea) - textArea.select() - try { - document.execCommand('copy') - showToast('配置信息已复制到剪贴板', 'success') - } catch (fallbackError) { - showToast('复制失败,请手动复制', 'error') - } finally { - document.body.removeChild(textArea) - } +# 提示:如需调用 /droid/claude 端点(已在后台添加 Droid 账号),请将 ANTHROPIC_BASE_URL 改为 "${droidEndpoint.value}" 或根据实际环境调整。` + + await copyTextWithFallback(configText, '配置信息已复制到剪贴板') +} + +// 仅复制密钥 +const copyKeyOnly = async () => { + const key = props.apiKey.apiKey || props.apiKey.key || '' + if (!key) { + showToast('API Key 不存在', 'error') + return } + + await copyTextWithFallback(key, 'API Key 已复制') } // 关闭弹窗(带确认) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 3e308c16..dad086e2 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -206,6 +206,21 @@ /> + + 到期时间 + + +
+ +
+ + + + + 已过期 + + + + {{ formatExpireDate(account.expiresAt) }} + + + {{ formatExpireDate(account.expiresAt) }} + + + + + + 永不过期 + +
+
({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }}) - - - Opus限流 - - ({{ formatOpusLimitEndTime(account.opusRateLimitEndAt) }}) - - 不可调度 @@ -1663,6 +1711,15 @@ :summary="accountUsageSummary" @close="closeAccountUsageModal" /> + + +
@@ -1674,6 +1731,7 @@ import { useConfirm } from '@/composables/useConfirm' import AccountForm from '@/components/accounts/AccountForm.vue' import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue' import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' +import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue' @@ -1730,6 +1788,10 @@ const supportedUsagePlatforms = [ 'droid' ] +// 过期时间编辑弹窗状态 +const editingExpiryAccount = ref(null) +const expiryEditModalRef = ref(null) + // 缓存状态标志 const apiKeysLoaded = ref(false) const groupsLoaded = ref(false) @@ -2651,54 +2713,6 @@ const formatRateLimitTime = (minutes) => { } } -// 检查账户是否处于 Opus 限流状态 -const isOpusRateLimited = (account) => { - if (!account.opusRateLimitEndAt) { - return false - } - const endTime = new Date(account.opusRateLimitEndAt) - const now = new Date() - return endTime > now -} - -// 格式化 Opus 限流结束时间 -const formatOpusLimitEndTime = (endTimeStr) => { - if (!endTimeStr) return '' - - const endTime = new Date(endTimeStr) - const now = new Date() - - // 如果已经过期,返回"已解除" - if (endTime <= now) { - return '已解除' - } - - // 计算剩余时间(毫秒) - const remainingMs = endTime - now - const remainingMinutes = Math.floor(remainingMs / (1000 * 60)) - - // 计算天数、小时和分钟 - const days = Math.floor(remainingMinutes / 1440) - const remainingAfterDays = remainingMinutes % 1440 - const hours = Math.floor(remainingAfterDays / 60) - const mins = remainingAfterDays % 60 - - // 格式化显示 - const parts = [] - if (days > 0) { - parts.push(`${days}天`) - } - if (hours > 0) { - parts.push(`${hours}小时`) - } - if (mins > 0 && days === 0) { - // 只有在天数为0时才显示分钟 - parts.push(`${mins}分钟`) - } - - return parts.join('') -} - // 打开创建账户模态框 const openCreateAccountModal = () => { newAccountPlatform.value = null // 重置选择的平台 @@ -3676,6 +3690,105 @@ watch(paginatedAccounts, () => { watch(accounts, () => { cleanupSelectedAccounts() }) +// 到期时间相关方法 +const formatExpireDate = (dateString) => { + if (!dateString) return '' + const date = new Date(dateString) + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) +} + +const isExpired = (expiresAt) => { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +const isExpiringSoon = (expiresAt) => { + if (!expiresAt) return false + const now = new Date() + const expireDate = new Date(expiresAt) + const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24) + return daysUntilExpire > 0 && daysUntilExpire <= 7 +} + +// 开始编辑账户过期时间 +const startEditAccountExpiry = (account) => { + editingExpiryAccount.value = account +} + +// 关闭账户过期时间编辑 +const closeAccountExpiryEdit = () => { + editingExpiryAccount.value = null +} + +// 根据账户平台解析更新端点 +const resolveAccountUpdateEndpoint = (account) => { + switch (account.platform) { + case 'claude': + return `/admin/claude-accounts/${account.id}` + case 'claude-console': + return `/admin/claude-console-accounts/${account.id}` + case 'bedrock': + return `/admin/bedrock-accounts/${account.id}` + case 'openai': + return `/admin/openai-accounts/${account.id}` + case 'azure_openai': + return `/admin/azure-openai-accounts/${account.id}` + case 'openai-responses': + return `/admin/openai-responses-accounts/${account.id}` + case 'ccr': + return `/admin/ccr-accounts/${account.id}` + case 'gemini': + return `/admin/gemini-accounts/${account.id}` + case 'droid': + return `/admin/droid-accounts/${account.id}` + default: + throw new Error(`Unsupported platform: ${account.platform}`) + } +} + +// 保存账户过期时间 +const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => { + try { + // 找到对应的账户以获取平台信息 + const account = accounts.value.find((acc) => acc.id === accountId) + if (!account) { + showToast('账户不存在', 'error') + if (expiryEditModalRef.value) { + expiryEditModalRef.value.resetSaving() + } + return + } + + // 根据平台动态选择端点 + const endpoint = resolveAccountUpdateEndpoint(account) + const data = await apiClient.put(endpoint, { + expiresAt: expiresAt || null + }) + + if (data.success) { + showToast('账户到期时间已更新', 'success') + // 更新本地数据 + account.expiresAt = expiresAt || null + closeAccountExpiryEdit() + } else { + showToast(data.message || '更新失败', 'error') + // 重置保存状态 + if (expiryEditModalRef.value) { + expiryEditModalRef.value.resetSaving() + } + } + } catch (error) { + showToast(error.message || '更新失败', 'error') + // 重置保存状态 + if (expiryEditModalRef.value) { + expiryEditModalRef.value.resetSaving() + } + } +} onMounted(() => { // 首次加载时强制刷新所有数据 @@ -3685,7 +3798,6 @@ onMounted(() => {