From 9d1906c0b1050d202c7b1203f09c02c07f40a365 Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Sat, 6 Sep 2025 23:40:10 +0800 Subject: [PATCH] Merge branch 'dev' of https://github.com/Wei-Shaw/claude-relay-service into dev --- .env.example | 3 +- README.md | 93 ++- VERSION | 2 +- config/config.example.js | 3 +- scripts/data-transfer-enhanced.js | 65 +- scripts/manage.js | 2 +- src/routes/admin.js | 647 +++++++++++++++++- src/routes/apiStats.js | 311 +++++++++ src/routes/geminiRoutes.js | 86 ++- src/routes/openaiRoutes.js | 27 +- src/routes/userRoutes.js | 12 +- src/services/apiKeyService.js | 190 ++++- src/services/claudeAccountService.js | 118 +++- src/services/claudeConsoleAccountService.js | 339 ++++++++- src/services/claudeConsoleRelayService.js | 13 + src/services/claudeRelayService.js | 53 +- src/services/geminiAccountService.js | 32 +- src/services/ldapService.js | 133 +++- src/services/openaiAccountService.js | 294 ++++++-- src/services/unifiedClaudeScheduler.js | 253 +++++-- src/services/unifiedOpenAIScheduler.js | 28 +- src/services/userService.js | 79 +++ src/services/webhookService.js | 9 +- src/utils/logger.js | 3 +- src/utils/sessionHelper.js | 54 +- src/utils/webhookNotifier.js | 7 + web/admin-spa/src/assets/styles/global.css | 2 +- .../src/components/accounts/AccountForm.vue | 441 +++++++++--- .../src/components/accounts/ProxyConfig.vue | 10 +- .../apikeys/BatchEditApiKeyModal.vue | 54 +- .../components/apikeys/CreateApiKeyModal.vue | 145 +++- .../components/apikeys/EditApiKeyModal.vue | 103 ++- .../components/apikeys/ExpiryEditModal.vue | 62 +- .../apistats/AggregatedStatsCard.vue | 202 ++++++ .../src/components/apistats/ApiKeyInput.vue | 244 ++++++- .../src/components/apistats/LimitConfig.vue | 120 +++- .../src/components/apistats/StatsOverview.vue | 105 ++- .../src/components/common/AccountSelector.vue | 4 +- .../components/user/UserApiKeysManager.vue | 7 +- web/admin-spa/src/config/api.js | 11 +- web/admin-spa/src/config/apiStats.js | 16 + web/admin-spa/src/stores/apistats.js | 167 +++++ web/admin-spa/src/utils/toast.js | 5 +- web/admin-spa/src/views/AccountsView.vue | 96 ++- web/admin-spa/src/views/ApiKeysView.vue | 591 +++++++++++----- web/admin-spa/src/views/ApiStatsView.vue | 9 +- web/admin-spa/src/views/TutorialView.vue | 236 +++---- .../src/views/UserManagementView.vue | 16 +- 48 files changed, 4687 insertions(+), 815 deletions(-) mode change 100644 => 100755 src/services/webhookService.js create mode 100644 web/admin-spa/src/components/apistats/AggregatedStatsCard.vue diff --git a/.env.example b/.env.example index b69ee64e..62f7fcfb 100644 --- a/.env.example +++ b/.env.example @@ -96,4 +96,5 @@ LDAP_USER_ATTR_LAST_NAME=sn USER_MANAGEMENT_ENABLED=false DEFAULT_USER_ROLE=user USER_SESSION_TIMEOUT=86400000 -MAX_API_KEYS_PER_USER=5 +MAX_API_KEYS_PER_USER=1 +ALLOW_USER_DELETE_API_KEYS=false diff --git a/README.md b/README.md index 53f58c95..30dcfb50 100644 --- a/README.md +++ b/README.md @@ -474,42 +474,101 @@ claude gemini # 或其他 Gemini CLI 命令 ``` -**Codex 设置环境变量:** +**Codex 配置:** -```bash -export OPENAI_BASE_URL="http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 -export OPENAI_API_KEY="后台创建的API密钥" # 使用后台创建的API密钥 +在 `~/.codex/config.toml` 文件中添加以下配置: + +```toml +model_provider = "crs" +model = "gpt-5" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.crs] +name = "crs" +base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 +wire_api = "responses" +``` + +在 `~/.codex/auth.json` 文件中配置API密钥: + +```json +{ + "OPENAI_API_KEY": "你的后台创建的API密钥" +} ``` ### 5. 第三方工具API接入 -本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等): +本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。 -**Claude标准格式:** +#### Cherry Studio 接入示例 + +Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置: + +**1. Claude账号接入:** ``` -# 如果工具支持Claude标准格式,请使用该接口 +# API地址 http://你的服务器:3000/claude/ + +# 模型ID示例 +claude-sonnet-4-20250514 # Claude Sonnet 4 +claude-opus-4-20250514 # Claude Opus 4 ``` -**OpenAI兼容格式:** +配置步骤: +- 供应商类型选择"Anthropic" +- API地址填入:`http://你的服务器:3000/claude/` +- API Key填入:后台创建的API密钥(cr_开头) + +**2. Gemini账号接入:** ``` -# 适用于需要OpenAI格式的第三方工具 -http://你的服务器:3000/openai/claude/v1/ +# API地址 +http://你的服务器:3000/gemini/ + +# 模型ID示例 +gemini-2.5-pro # Gemini 2.5 Pro ``` -**接入示例:** +配置步骤: +- 供应商类型选择"Gemini" +- API地址填入:`http://你的服务器:3000/gemini/` +- API Key填入:后台创建的API密钥(cr_开头) -- **Cherry Studio**: 使用OpenAI格式 `http://你的服务器:3000/openai/claude/v1/` 使用Codex cli API `http://你的服务器:3000/openai/responses` -- **其他支持自定义API的工具**: 根据工具要求选择合适的格式 +**3. Codex接入:** + +``` +# API地址 +http://你的服务器:3000/openai/ + +# 模型ID(固定) +gpt-5 # Codex使用固定模型ID +``` + +配置步骤: +- 供应商类型选择"Openai-Response" +- API地址填入:`http://你的服务器:3000/openai/` +- API Key填入:后台创建的API密钥(cr_开头) +- **重要**:Codex只支持Openai-Response标准 + +#### 其他第三方工具接入 + +**接入要点:** + +- 所有账号类型都使用相同的API密钥(在后台统一创建) +- 根据不同的路由前缀自动识别账号类型 +- `/claude/` - 使用Claude账号池 +- `/gemini/` - 使用Gemini账号池 +- `/openai/` - 使用Codex账号(只支持Openai-Response格式) +- 支持所有标准API端点(messages、models等) **重要说明:** -- 所有格式都支持相同的功能,仅是路径不同 -- `/api/v1/messages` = `/claude/v1/messages` = `/openai/claude/v1/messages` -- 选择适合你使用工具的格式即可 -- 支持所有Claude API端点(messages、models等) +- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex) +- API密钥可以通用,系统会根据路由自动选择账号类型 +- 建议为不同用户创建不同的API密钥便于使用统计 --- diff --git a/VERSION b/VERSION index 59aa7594..48cd0cc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.124 +1.1.128 diff --git a/config/config.example.js b/config/config.example.js index 5b8786b6..433ecd1f 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -175,7 +175,8 @@ const config = { enabled: process.env.USER_MANAGEMENT_ENABLED === 'true', defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user', userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时 - maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 5 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1, + allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys }, // 📢 Webhook通知配置 diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js index 47b3920f..09416fb4 100644 --- a/scripts/data-transfer-enhanced.js +++ b/scripts/data-transfer-enhanced.js @@ -86,6 +86,33 @@ function decryptGeminiData(encryptedData) { } } +// API Key 哈希函数(与apiKeyService保持一致) +function hashApiKey(apiKey) { + if (!apiKey || !config.security.encryptionKey) { + return apiKey + } + + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') +} + +// 检查是否为明文API Key(通过格式判断,不依赖前缀) +function isPlaintextApiKey(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return false + } + + // SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false + if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) { + return false // 已经是哈希值 + } + + // 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等) + return true +} + // 数据加密函数(用于导入) function encryptClaudeData(data) { if (!data || !config.security.encryptionKey) { @@ -651,6 +678,13 @@ Important Notes: - If importing decrypted data, it will be re-encrypted automatically - If importing encrypted data, it will be stored as-is - Sanitized exports cannot be properly imported (missing sensitive data) + - Automatic handling of plaintext API Keys + * Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.) + * Automatically detects plaintext vs hashed API Keys by format + * Plaintext API Keys are automatically hashed during import + * Hash mappings are created correctly for plaintext keys + * Supports custom prefixes and legacy format detection + * No manual conversion needed - just import your backup file Examples: # Export all data with decryption (for migration) @@ -659,7 +693,7 @@ Examples: # Export without decrypting (for backup) node scripts/data-transfer-enhanced.js export --decrypt=false - # Import data (auto-handles encryption) + # Import data (auto-handles encryption and plaintext API keys) node scripts/data-transfer-enhanced.js import --input=backup.json # Import with force overwrite @@ -773,6 +807,26 @@ async function importData() { const apiKeyData = { ...apiKey } delete apiKeyData.usageStats + // 检查并处理API Key哈希 + let plainTextApiKey = null + let hashedApiKey = null + + if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) { + // 如果是明文API Key,保存明文并计算哈希 + plainTextApiKey = apiKeyData.apiKey + hashedApiKey = hashApiKey(plainTextApiKey) + logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`) + } else if (apiKeyData.apiKey) { + // 如果已经是哈希值,直接使用 + hashedApiKey = apiKeyData.apiKey + logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`) + } + + // API Key字段始终存储哈希值 + if (hashedApiKey) { + apiKeyData.apiKey = hashedApiKey + } + // 使用 hset 存储到哈希表 const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(apiKeyData)) { @@ -780,9 +834,12 @@ async function importData() { } await pipeline.exec() - // 更新哈希映射 - if (apiKey.apiKey && !importDataObj.metadata.sanitized) { - await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) + // 更新哈希映射:hash_map的key必须是哈希值 + if (!importDataObj.metadata.sanitized && hashedApiKey) { + await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id) + logger.info( + `📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}` + ) } // 导入使用统计数据 diff --git a/scripts/manage.js b/scripts/manage.js index 6e3f7937..df8a14f0 100644 --- a/scripts/manage.js +++ b/scripts/manage.js @@ -185,7 +185,7 @@ class ServiceManager { restart(daemon = false) { console.log('🔄 重启服务...') - + this.stop() // 等待停止完成 setTimeout(() => { this.start(daemon) diff --git a/src/routes/admin.js b/src/routes/admin.js index a37a0942..c26e613c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -24,6 +24,68 @@ const ProxyHelper = require('../utils/proxyHelper') const router = express.Router() +// 👥 用户管理 + +// 获取所有用户列表(用于API Key分配) +router.get('/users', authenticateAdmin, async (req, res) => { + try { + const userService = require('../services/userService') + + // Extract query parameters for filtering + const { role, isActive } = req.query + const options = { limit: 1000 } + + // Apply role filter if provided + if (role) { + options.role = role + } + + // Apply isActive filter if provided, otherwise default to active users only + if (isActive !== undefined) { + options.isActive = isActive === 'true' + } else { + options.isActive = true // Default to active users for backwards compatibility + } + + const result = await userService.getAllUsers(options) + + // Extract users array from the paginated result + const allUsers = result.users || [] + + // Map to the format needed for the dropdown + const activeUsers = allUsers.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.displayName || user.username, + email: user.email, + role: user.role + })) + + // 添加Admin选项作为第一个 + const usersWithAdmin = [ + { + id: 'admin', + username: 'admin', + displayName: 'Admin', + email: '', + role: 'admin' + }, + ...activeUsers + ] + + return res.json({ + success: true, + data: usersWithAdmin + }) + } catch (error) { + logger.error('❌ Failed to get users list:', error) + return res.status(500).json({ + error: 'Failed to get users list', + message: error.message + }) + } +}) + // 🔑 API Keys 管理 // 调试:获取API Key费用详情 @@ -63,6 +125,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const { timeRange = 'all' } = req.query // all, 7days, monthly const apiKeys = await apiKeyService.getAllApiKeys() + // 获取用户服务来补充owner信息 + const userService = require('../services/userService') + // 根据时间范围计算查询模式 const now = new Date() const searchPatterns = [] @@ -313,6 +378,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { } } + // 为每个API Key添加owner的displayName + for (const apiKey of apiKeys) { + // 如果API Key有关联的用户ID,获取用户信息 + if (apiKey.userId) { + try { + const user = await userService.getUserById(apiKey.userId, false) + if (user) { + apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' + } else { + apiKey.ownerDisplayName = 'Unknown User' + } + } catch (error) { + logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error) + apiKey.ownerDisplayName = 'Unknown User' + } + } else { + // 如果没有userId,使用createdBy字段或默认为Admin + apiKey.ownerDisplayName = + apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' + } + } + return res.json({ success: true, data: apiKeys }) } catch (error) { logger.error('❌ Failed to get API keys:', error) @@ -404,7 +491,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, // 新增:激活后有效天数 + expirationMode // 新增:过期模式 } = req.body // 输入验证 @@ -482,6 +571,31 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'All tags must be non-empty strings' }) } + // 验证激活相关字段 + if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { + return res + .status(400) + .json({ error: 'Expiration mode must be either "fixed" or "activation"' }) + } + + if (expirationMode === 'activation') { + if ( + !activationDays || + !Number.isInteger(Number(activationDays)) || + Number(activationDays) < 1 + ) { + return res + .status(400) + .json({ error: 'Activation days must be a positive integer when using activation mode' }) + } + // 激活模式下不应该设置固定过期时间 + if (expiresAt) { + return res + .status(400) + .json({ error: 'Cannot set fixed expiration date when using activation mode' }) + } + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -503,7 +617,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, + expirationMode }) logger.success(`🔑 Admin created new API key: ${name}`) @@ -537,7 +653,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, + expirationMode } = req.body // 输入验证 @@ -581,7 +699,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, + expirationMode }) // 保留原始 API Key 供返回 @@ -679,6 +799,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.tokenLimit !== undefined) { finalUpdates.tokenLimit = updates.tokenLimit } + if (updates.rateLimitCost !== undefined) { + finalUpdates.rateLimitCost = updates.rateLimitCost + } if (updates.concurrencyLimit !== undefined) { finalUpdates.concurrencyLimit = updates.concurrencyLimit } @@ -800,6 +923,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params const { + name, // 添加名称字段 tokenLimit, concurrencyLimit, rateLimitWindow, @@ -819,12 +943,25 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { expiresAt, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + ownerId // 新增:所有者ID字段 } = req.body // 只允许更新指定字段 const updates = {} + // 处理名称字段 + if (name !== undefined && name !== null && name !== '') { + const trimmedName = name.toString().trim() + if (trimmedName.length === 0) { + return res.status(400).json({ error: 'API Key name cannot be empty' }) + } + if (trimmedName.length > 100) { + return res.status(400).json({ error: 'API Key name must be less than 100 characters' }) + } + updates.name = trimmedName + } + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) @@ -989,6 +1126,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.isActive = isActive } + // 处理所有者变更 + if (ownerId !== undefined) { + const userService = require('../services/userService') + + if (ownerId === 'admin') { + // 分配给Admin + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } else if (ownerId) { + // 分配给用户 + try { + const user = await userService.getUserById(ownerId, false) + if (!user) { + return res.status(400).json({ error: 'Invalid owner: User not found' }) + } + if (!user.isActive) { + return res.status(400).json({ error: 'Cannot assign to inactive user' }) + } + + // 设置新的所有者信息 + updates.userId = ownerId + updates.userUsername = user.username + updates.createdBy = user.username + + // 管理员重新分配时,不检查用户的API Key数量限制 + logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`) + } catch (error) { + logger.error('Error fetching user for owner reassignment:', error) + return res.status(400).json({ error: 'Invalid owner ID' }) + } + } else { + // 清空所有者(分配给Admin) + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } + } + await apiKeyService.updateApiKey(keyId, updates) logger.success(`📝 Admin updated API key: ${keyId}`) @@ -999,6 +1175,85 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) +// 修改API Key过期时间(包括手动激活功能) +router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { expiresAt, activateNow } = req.body + + // 获取当前API Key信息 + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ error: 'API key not found' }) + } + + const updates = {} + + // 如果是激活操作(用于未激活的key) + if (activateNow === true) { + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + const now = new Date() + const activationDays = parseInt(keyData.activationDays || 30) + const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) + + updates.isActivated = 'true' + updates.activatedAt = now.toISOString() + updates.expiresAt = newExpiresAt.toISOString() + + logger.success( + `🔓 API key manually activated by admin: ${keyId} (${keyData.name}), expires at ${newExpiresAt.toISOString()}` + ) + } else { + return res.status(400).json({ + error: 'Cannot activate', + message: 'Key is either already activated or not in activation mode' + }) + } + } + + // 如果提供了新的过期时间(但不是激活操作) + if (expiresAt !== undefined && activateNow !== true) { + // 验证过期时间格式 + if (expiresAt && isNaN(Date.parse(expiresAt))) { + return res.status(400).json({ error: 'Invalid expiration date format' }) + } + + // 如果设置了过期时间,确保key是激活状态 + if (expiresAt) { + updates.expiresAt = new Date(expiresAt).toISOString() + // 如果之前是未激活状态,现在激活它 + if (keyData.isActivated !== 'true') { + updates.isActivated = 'true' + updates.activatedAt = new Date().toISOString() + } + } else { + // 清除过期时间(永不过期) + updates.expiresAt = '' + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'No valid updates provided' }) + } + + // 更新API Key + await apiKeyService.updateApiKey(keyId, updates) + + logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`) + return res.json({ + success: true, + message: 'API key expiration updated successfully', + updates + }) + } catch (error) { + logger.error('❌ Failed to update API key expiration:', error) + return res.status(500).json({ + error: 'Failed to update API key expiration', + message: error.message + }) + } +}) + // 批量删除API Keys(必须在 :keyId 路由之前定义) router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { try { @@ -1125,7 +1380,7 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { deletedAt: key.deletedAt, deletedBy: key.deletedBy, deletedByType: key.deletedByType, - canRestore: false // Deleted keys cannot be restored per requirement + canRestore: true // 已删除的API Key可以恢复 })) logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) @@ -1138,6 +1393,123 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { } }) +// 🔄 恢复已删除的API Key +router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的恢复方法 + const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin') + + if (result.success) { + logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已成功恢复', + apiKey: result.apiKey + }) + } else { + return res.status(400).json({ + success: false, + error: 'Failed to restore API key' + }) + } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + + // 根据错误类型返回适当的响应 + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === 'API key is not deleted') { + return res.status(400).json({ + success: false, + error: '该 API Key 未被删除,无需恢复' + }) + } + + return res.status(500).json({ + success: false, + error: '恢复 API Key 失败', + message: error.message + }) + } +}) + +// 🗑️ 彻底删除API Key(物理删除) +router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的彻底删除方法 + const result = await apiKeyService.permanentDeleteApiKey(keyId) + + if (result.success) { + logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已彻底删除' + }) + } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === '只能彻底删除已经删除的API Key') { + return res.status(400).json({ + success: false, + error: '只能彻底删除已经删除的API Key' + }) + } + + return res.status(500).json({ + success: false, + error: '彻底删除 API Key 失败', + message: error.message + }) + } +}) + +// 🧹 清空所有已删除的API Keys +router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => { + try { + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的清空方法 + const result = await apiKeyService.clearAllDeletedApiKeys() + + logger.success( + `🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}` + ) + + return res.json({ + success: true, + message: `成功清空 ${result.successCount} 个已删除的 API Keys`, + details: { + total: result.total, + successCount: result.successCount, + failedCount: result.failedCount, + errors: result.errors + } + }) + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + return res.status(500).json({ + success: false, + error: '清空已删除的 API Keys 失败', + message: error.message + }) + } +}) + // 👥 账户分组管理 // 创建账户分组 @@ -1642,7 +2014,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { priority, groupId, groupIds, - autoStopOnWarning + autoStopOnWarning, + useUnifiedUserAgent } = req.body if (!name) { @@ -1682,7 +2055,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { accountType: accountType || 'shared', // 默认为共享类型 platform, priority: priority || 50, // 默认优先级为50 - autoStopOnWarning: autoStopOnWarning === true // 默认为false + autoStopOnWarning: autoStopOnWarning === true, // 默认为false + useUnifiedUserAgent: useUnifiedUserAgent === true // 默认为false }) // 如果是分组类型,将账户添加到分组 @@ -2032,7 +2406,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration, proxy, accountType, - groupId + groupId, + dailyQuota, + quotaResetTime } = req.body if (!name || !apiUrl || !apiKey) { @@ -2067,7 +2443,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration: rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, proxy, - accountType: accountType || 'shared' + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00' }) // 如果是分组类型,将账户添加到分组 @@ -2246,6 +2624,56 @@ router.put( } ) +// 获取Claude Console账户的使用统计 +router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get Claude Console account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置Claude Console账户的每日使用量 +router.post( + '/claude-console-accounts/:accountId/reset-usage', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + await claudeConsoleAccountService.resetDailyUsage(accountId) + + logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } + } +) + +// 手动重置所有Claude Console账户的每日使用量 +router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await claudeConsoleAccountService.resetAllDailyUsage() + + logger.success('✅ Admin manually reset daily usage for all Claude Console accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + // ☁️ Bedrock 账户管理 // 获取所有Bedrock账户 @@ -5317,7 +5745,9 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { accountType, groupId, rateLimitDuration, - priority + priority, + needsImmediateRefresh, // 是否需要立即刷新 + requireRefreshSuccess // 是否必须刷新成功才能创建 } = req.body if (!name) { @@ -5326,7 +5756,8 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { message: '账户名称不能为空' }) } - // 创建账户数据 + + // 准备账户数据 const accountData = { name, description: description || '', @@ -5341,7 +5772,83 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { schedulable: true } - // 创建账户 + // 如果需要立即刷新且必须成功(OpenAI 手动模式) + if (needsImmediateRefresh && requireRefreshSuccess) { + // 先创建临时账户以测试刷新 + const tempAccount = await openaiAccountService.createAccount(accountData) + + try { + logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`) + + // 尝试刷新 token(会自动使用账户配置的代理) + await openaiAccountService.refreshAccountToken(tempAccount.id) + + // 刷新成功,获取更新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 没有获取到 ID Token,删除账户 + await openaiAccountService.deleteAccount(tempAccount.id) + throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效') + } + + // 如果是分组类型,添加到分组 + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai') + } + + // 清除敏感信息后返回 + delete refreshedAccount.idToken + delete refreshedAccount.accessToken + delete refreshedAccount.refreshToken + + logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) + + return res.json({ + success: true, + data: refreshedAccount, + message: '账户创建成功,并已获取完整 token 信息' + }) + } catch (refreshError) { + // 刷新失败,删除临时创建的账户 + logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`) + await openaiAccountService.deleteAccount(tempAccount.id) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '账户创建失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + + // 不需要强制刷新的情况(OAuth 模式或其他平台) const createdAccount = await openaiAccountService.createAccount(accountData) // 如果是分组类型,添加到分组 @@ -5349,6 +5856,17 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai') } + // 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`) + await openaiAccountService.refreshAccountToken(createdAccount.id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`) + } + } + logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`) return res.json({ @@ -5370,6 +5888,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params const updates = req.body + const { needsImmediateRefresh, requireRefreshSuccess } = updates // 验证accountType的有效性 if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { @@ -5389,6 +5908,93 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { return res.status(404).json({ error: 'Account not found' }) } + // 如果更新了 Refresh Token,需要验证其有效性 + if (updates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { + // 先更新 token 信息 + const tempUpdateData = {} + if (updates.openaiOauth.refreshToken) { + tempUpdateData.refreshToken = updates.openaiOauth.refreshToken + } + if (updates.openaiOauth.accessToken) { + tempUpdateData.accessToken = updates.openaiOauth.accessToken + } + // 更新代理配置(如果有) + if (updates.proxy !== undefined) { + tempUpdateData.proxy = updates.proxy + } + + // 临时更新账户以测试新的 token + await openaiAccountService.updateAccount(id, tempUpdateData) + + try { + logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`) + + // 尝试刷新 token(会使用账户配置的代理) + await openaiAccountService.refreshAccountToken(id) + + // 获取刷新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 恢复原始 token + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken + }) + + return res.status(400).json({ + success: false, + message: '无法获取 ID Token,请检查 Refresh Token 是否有效', + error: 'Invalid refresh token' + }) + } + + logger.success(`✅ Token 验证成功,继续更新账户信息`) + } catch (refreshError) { + // 刷新失败,恢复原始 token + logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`) + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken, + proxy: currentAccount.proxy + }) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '更新失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + // 处理分组的变更 if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从原分组中移除 @@ -5410,9 +6016,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { // 处理敏感数据加密 if (updates.openaiOauth) { updateData.openaiOauth = updates.openaiOauth - if (updates.openaiOauth.idToken) { - updateData.idToken = updates.openaiOauth.idToken - } + // 编辑时不允许直接输入 ID Token,只能通过刷新获取 if (updates.openaiOauth.accessToken) { updateData.accessToken = updates.openaiOauth.accessToken } @@ -5446,6 +6050,17 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { const updatedAccount = await openaiAccountService.updateAccount(id, updateData) + // 如果需要刷新但不强制成功(非关键更新) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`) + await openaiAccountService.refreshAccountToken(id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`) + } + } + logger.success(`📝 Admin updated OpenAI account: ${id}`) return res.json({ success: true, data: updatedAccount }) } catch (error) { diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 3233b1f4..cac72503 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -407,6 +407,317 @@ router.post('/api/user-stats', async (req, res) => { } }) +// 📊 批量查询统计数据接口 +router.post('/api/batch-stats', async (req, res) => { + try { + const { apiIds } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + // 验证所有 ID 格式 + const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i + const invalidIds = apiIds.filter((id) => !uuidRegex.test(id)) + if (invalidIds.length > 0) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: `Invalid API IDs: ${invalidIds.join(', ')}` + }) + } + + const individualStats = [] + const aggregated = { + totalKeys: apiIds.length, + activeKeys: 0, + usage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + dailyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + monthlyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + } + + // 并行查询所有 API Key 数据(复用单key查询逻辑) + const results = await Promise.allSettled( + apiIds.map(async (apiId) => { + const keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + return { error: 'Not found', apiId } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { error: 'Disabled', apiId } + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { error: 'Expired', apiId } + } + + // 复用单key查询的逻辑:获取使用统计 + const usage = await redis.getUsageStats(apiId) + + // 获取费用统计(与单key查询一致) + const costStats = await redis.getCostStats(apiId) + + return { + apiId, + name: keyData.name, + description: keyData.description || '', + isActive: true, + createdAt: keyData.createdAt, + usage: usage.total || {}, + dailyStats: { + ...usage.daily, + cost: costStats.daily + }, + monthlyStats: { + ...usage.monthly, + cost: costStats.monthly + }, + totalCost: costStats.total + } + }) + ) + + // 处理结果并聚合 + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value && !result.value.error) { + const stats = result.value + aggregated.activeKeys++ + + // 聚合总使用量 + if (stats.usage) { + aggregated.usage.requests += stats.usage.requests || 0 + aggregated.usage.inputTokens += stats.usage.inputTokens || 0 + aggregated.usage.outputTokens += stats.usage.outputTokens || 0 + aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0 + aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0 + aggregated.usage.allTokens += stats.usage.allTokens || 0 + } + + // 聚合总费用 + aggregated.usage.cost += stats.totalCost || 0 + + // 聚合今日使用量 + aggregated.dailyUsage.requests += stats.dailyStats.requests || 0 + aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0 + aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0 + aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0 + aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0 + aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0 + aggregated.dailyUsage.cost += stats.dailyStats.cost || 0 + + // 聚合本月使用量 + aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0 + aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0 + aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0 + aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0 + aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0 + aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0 + aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0 + + // 添加到个体统计 + individualStats.push({ + apiId: stats.apiId, + name: stats.name, + isActive: true, + usage: stats.usage, + dailyUsage: { + ...stats.dailyStats, + formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0) + }, + monthlyUsage: { + ...stats.monthlyStats, + formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0) + } + }) + } + }) + + // 格式化费用显示 + aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost) + aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost) + aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost) + + logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`) + + return res.json({ + success: true, + data: { + aggregated, + individual: individualStats + } + }) + } catch (error) { + logger.error('❌ Failed to process batch stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch statistics' + }) + } +}) + +// 📊 批量模型统计查询接口 +router.post('/api/batch-model-stats', async (req, res) => { + try { + const { apiIds, period = 'daily' } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + const client = redis.getClientSafe() + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const modelUsageMap = new Map() + + // 并行查询所有 API Key 的模型统计 + await Promise.all( + apiIds.map(async (apiId) => { + const pattern = + period === 'daily' + ? `usage:${apiId}:model:daily:*:${today}` + : `usage:${apiId}:model:monthly:*:${currentMonth}` + + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.requests += parseInt(data.requests) || 0 + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.allTokens += parseInt(data.allTokens) || 0 + } + } + }) + ) + + // 转换为数组并计算费用 + const modelStats = [] + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costData = CostCalculator.calculateCost(usageData, model) + + modelStats.push({ + model, + requests: usage.requests, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreateTokens: usage.cacheCreateTokens, + cacheReadTokens: usage.cacheReadTokens, + allTokens: usage.allTokens, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + + // 按总 token 数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process batch model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch model statistics' + }) + } +}) + // 📊 用户模型统计查询接口 - 安全的自查询接口 router.post('/api/user-model-stats', async (req, res) => { try { diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 75f633d0..aafe18e8 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -343,20 +343,22 @@ async function handleLoadCodeAssist(req, res) { const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) - // 根据账户配置决定项目ID: - // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) - // 2. 如果账户没有项目ID -> 传递 null(移除项目ID) - let effectiveProjectId = null + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject + // 3. 都没有 -> 传null + const effectiveProjectId = projectId || cloudaicompanionProject || null - if (projectId) { - // 账户配置了项目ID,强制使用它 - effectiveProjectId = projectId - logger.info('Using account project ID for loadCodeAssist:', effectiveProjectId) - } else { - // 账户没有配置项目ID,确保不传递项目ID - effectiveProjectId = null - logger.info('No project ID in account for loadCodeAssist, removing project parameter') - } + logger.info('📋 loadCodeAssist项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) const response = await geminiAccountService.loadCodeAssist( client, @@ -413,20 +415,22 @@ async function handleOnboardUser(req, res) { const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) - // 根据账户配置决定项目ID: - // 1. 如果账户有项目ID -> 使用账户的项目ID(强制覆盖) - // 2. 如果账户没有项目ID -> 传递 null(移除项目ID) - let effectiveProjectId = null + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject + // 3. 都没有 -> 传null + const effectiveProjectId = projectId || cloudaicompanionProject || null - if (projectId) { - // 账户配置了项目ID,强制使用它 - effectiveProjectId = projectId - logger.info('Using account project ID:', effectiveProjectId) - } else { - // 账户没有配置项目ID,确保不传递项目ID(即使客户端传了也要移除) - effectiveProjectId = null - logger.info('No project ID in account, removing project parameter') - } + logger.info('📋 onboardUser项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) // 如果提供了 tierId,直接调用 onboardUser if (tierId) { @@ -593,11 +597,24 @@ async function handleGenerateContent(req, res) { const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话) + // 3. 都没有 -> 传null + const effectiveProjectId = account.projectId || project || null + + logger.info('📋 项目ID处理逻辑', { + accountProjectId: account.projectId, + requestProjectId: project, + effectiveProjectId, + decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID' + }) + const response = await geminiAccountService.generateContent( client, { model, request: actualRequestData }, user_prompt_id, - account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project + effectiveProjectId, // 使用智能决策的项目ID req.apiKey?.id, // 使用 API Key ID 作为 session ID proxyConfig // 传递代理配置 ) @@ -729,11 +746,24 @@ async function handleStreamGenerateContent(req, res) { const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话) + // 3. 都没有 -> 传null + const effectiveProjectId = account.projectId || project || null + + logger.info('📋 流式请求项目ID处理逻辑', { + accountProjectId: account.projectId, + requestProjectId: project, + effectiveProjectId, + decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID' + }) + const streamResponse = await geminiAccountService.generateContentStream( client, { model, request: actualRequestData }, user_prompt_id, - account.projectId, // 始终使用账户配置的项目ID,忽略请求中的project + effectiveProjectId, // 使用智能决策的项目ID req.apiKey?.id, // 使用 API Key ID 作为 session ID abortController.signal, // 传递中止信号 proxyConfig // 传递代理配置 diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 9efb2981..283ab896 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -3,7 +3,6 @@ const axios = require('axios') const router = express.Router() const logger = require('../utils/logger') const { authenticateApiKey } = require('../middleware/auth') -const claudeAccountService = require('../services/claudeAccountService') const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const openaiAccountService = require('../services/openaiAccountService') const apiKeyService = require('../services/apiKeyService') @@ -35,13 +34,31 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = } // 获取账户详情 - const account = await openaiAccountService.getAccount(result.accountId) + let account = await openaiAccountService.getAccount(result.accountId) if (!account || !account.accessToken) { throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`) } - // 解密 accessToken - const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken) + // 检查 token 是否过期并自动刷新(双重保护) + if (openaiAccountService.isTokenExpired(account)) { + if (account.refreshToken) { + logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`) + try { + await openaiAccountService.refreshAccountToken(result.accountId) + // 重新获取更新后的账户 + account = await openaiAccountService.getAccount(result.accountId) + logger.info(`✅ Token refreshed successfully in route handler`) + } catch (refreshError) { + logger.error(`Failed to refresh token for ${account.name}:`, refreshError) + throw new Error(`Token expired and refresh failed: ${refreshError.message}`) + } + } else { + throw new Error(`Token expired and no refresh token available for account ${account.name}`) + } + } + + // 解密 accessToken(account.accessToken 是加密的) + const accessToken = openaiAccountService.decrypt(account.accessToken) if (!accessToken) { throw new Error('Failed to decrypt OpenAI accessToken') } @@ -161,7 +178,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => { // 配置请求选项 const axiosConfig = { headers, - timeout: 60000, + timeout: 60 * 1000 * 10, validateStatus: () => true } diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 653e3c9e..f4f995c1 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -208,7 +208,8 @@ router.get('/profile', authenticateUser, async (req, res) => { totalUsage: user.totalUsage }, config: { - maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser + maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser, + allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys } }) } catch (error) { @@ -352,6 +353,15 @@ router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { try { const { keyId } = req.params + // 检查是否允许用户删除自己的API Keys + if (!config.userManagement.allowUserDeleteApiKeys) { + return res.status(403).json({ + error: 'Operation not allowed', + message: + 'Users are not allowed to delete their own API keys. Please contact an administrator.' + }) + } + // 检查API Key是否属于当前用户 const existingKey = await apiKeyService.getApiKeyById(keyId) if (!existingKey || existingKey.userId !== req.user.id) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 197f8e78..8ee94337 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -34,7 +34,9 @@ class ApiKeyService { allowedClients = [], dailyCostLimit = 0, weeklyOpusCostLimit = 0, - tags = [] + tags = [], + activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) + expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) } = options // 生成简单的API Key (64字符十六进制) @@ -67,9 +69,13 @@ class ApiKeyService { dailyCostLimit: String(dailyCostLimit || 0), weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), + activationDays: String(activationDays || 0), // 新增:激活后有效天数 + expirationMode: expirationMode || 'fixed', // 新增:过期模式 + isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态 + activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间 createdAt: new Date().toISOString(), lastUsedAt: '', - expiresAt: expiresAt || '', + expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间 createdBy: options.createdBy || 'admin', userId: options.userId || '', userUsername: options.userUsername || '' @@ -105,6 +111,10 @@ class ApiKeyService { dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), tags: JSON.parse(keyData.tags || '[]'), + activationDays: parseInt(keyData.activationDays || 0), + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activatedAt: keyData.activatedAt, createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -133,6 +143,27 @@ class ApiKeyService { return { valid: false, error: 'API key is disabled' } } + // 处理激活逻辑(仅在 activation 模式下) + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + // 首次使用,需要激活 + const now = new Date() + const activationDays = parseInt(keyData.activationDays || 30) // 默认30天 + const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) + + // 更新激活状态和过期时间 + keyData.isActivated = 'true' + keyData.activatedAt = now.toISOString() + keyData.expiresAt = expiresAt.toISOString() + keyData.lastUsedAt = now.toISOString() + + // 保存到Redis + await redis.setApiKey(keyData.id, keyData) + + logger.success( + `🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}` + ) + } + // 检查是否过期 if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { return { valid: false, error: 'API key has expired' } @@ -261,6 +292,10 @@ class ApiKeyService { key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 + key.activationDays = parseInt(key.activationDays || 0) + key.expirationMode = key.expirationMode || 'fixed' + key.isActivated = key.isActivated === 'true' + key.activatedAt = key.activatedAt || null // 获取当前时间窗口的请求次数、Token使用量和费用 if (key.rateLimitWindow > 0) { @@ -362,13 +397,20 @@ class ApiKeyService { 'bedrockAccountId', // 添加 Bedrock 账号ID 'permissions', 'expiresAt', + 'activationDays', // 新增:激活后有效天数 + 'expirationMode', // 新增:过期模式 + 'isActivated', // 新增:是否已激活 + 'activatedAt', // 新增:激活时间 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', 'weeklyOpusCostLimit', - 'tags' + 'tags', + 'userId', // 新增:用户ID(所有者变更) + 'userUsername', // 新增:用户名(所有者变更) + 'createdBy' // 新增:创建者(所有者变更) ] const updatedData = { ...keyData } @@ -377,9 +419,16 @@ class ApiKeyService { if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { // 特殊处理数组字段 updatedData[field] = JSON.stringify(value || []) - } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') { + } else if ( + field === 'enableModelRestriction' || + field === 'enableClientRestriction' || + field === 'isActivated' + ) { // 布尔值转字符串 updatedData[field] = String(value) + } else if (field === 'expiresAt' || field === 'activatedAt') { + // 日期字段保持原样,不要toString() + updatedData[field] = value || '' } else { updatedData[field] = (value !== null && value !== undefined ? value : '').toString() } @@ -434,6 +483,139 @@ class ApiKeyService { } } + // 🔄 恢复已删除的API Key + async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 检查是否确实是已删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('API key is not deleted') + } + + // 准备更新的数据 + const updatedData = { ...keyData } + updatedData.isActive = 'true' + updatedData.restoredAt = new Date().toISOString() + updatedData.restoredBy = restoredBy + updatedData.restoredByType = restoredByType + + // 从更新的数据中移除删除相关的字段 + delete updatedData.isDeleted + delete updatedData.deletedAt + delete updatedData.deletedBy + delete updatedData.deletedByType + + // 保存更新后的数据 + await redis.setApiKey(keyId, updatedData) + + // 使用Redis的hdel命令删除不需要的字段 + const keyName = `apikey:${keyId}` + await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType') + + // 重新建立哈希映射(恢复API Key的使用能力) + if (keyData.apiKey) { + await redis.setApiKeyHash(keyData.apiKey, { + id: keyId, + name: keyData.name, + isActive: 'true' + }) + } + + logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) + + return { success: true, apiKey: updatedData } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + throw error + } + } + + // 🗑️ 彻底删除API Key(物理删除) + async permanentDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 确保只能彻底删除已经软删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('只能彻底删除已经删除的API Key') + } + + // 删除所有相关的使用统计数据 + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] + + // 删除每日统计 + await redis.client.del(`usage:daily:${today}:${keyId}`) + await redis.client.del(`usage:daily:${yesterday}:${keyId}`) + + // 删除月度统计 + const currentMonth = today.substring(0, 7) + await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`) + + // 删除所有相关的统计键(通过模式匹配) + const usageKeys = await redis.client.keys(`usage:*:${keyId}*`) + if (usageKeys.length > 0) { + await redis.client.del(...usageKeys) + } + + // 删除API Key本身 + await redis.deleteApiKey(keyId) + + logger.success(`🗑️ Permanently deleted API key: ${keyId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + throw error + } + } + + // 🧹 清空所有已删除的API Keys + async clearAllDeletedApiKeys() { + try { + const allKeys = await this.getAllApiKeys(true) + const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true') + + let successCount = 0 + let failedCount = 0 + const errors = [] + + for (const key of deletedKeys) { + try { + await this.permanentDeleteApiKey(key.id) + successCount++ + } catch (error) { + failedCount++ + errors.push({ + keyId: key.id, + keyName: key.name, + error: error.message + }) + } + } + + logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`) + + return { + success: true, + total: deletedKeys.length, + successCount, + failedCount, + errors + } + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + throw error + } + } + // 📊 记录使用情况(支持缓存token和账户级别统计) async recordUsage( keyId, diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 08606550..86e595e5 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1695,9 +1695,31 @@ class ClaudeAccountService { } } - // 🚫 标记账户为未授权状态(401错误) - async markAccountUnauthorized(accountId, sessionHash = null) { + // 🚫 通用的账户错误标记方法 + async markAccountError(accountId, errorType, sessionHash = null) { + const ERROR_CONFIG = { + unauthorized: { + status: 'unauthorized', + errorMessage: 'Account unauthorized (401 errors detected)', + timestampField: 'unauthorizedAt', + errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED', + logMessage: 'unauthorized' + }, + blocked: { + status: 'blocked', + errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)', + timestampField: 'blockedAt', + errorCode: 'CLAUDE_OAUTH_BLOCKED', + logMessage: 'blocked' + } + } + try { + const errorConfig = ERROR_CONFIG[errorType] + if (!errorConfig) { + throw new Error(`Unsupported error type: ${errorType}`) + } + const accountData = await redis.getClaudeAccount(accountId) if (!accountData || Object.keys(accountData).length === 0) { throw new Error('Account not found') @@ -1705,10 +1727,10 @@ class ClaudeAccountService { // 更新账户状态 const updatedAccountData = { ...accountData } - updatedAccountData.status = 'unauthorized' + updatedAccountData.status = errorConfig.status updatedAccountData.schedulable = 'false' // 设置为不可调度 - updatedAccountData.errorMessage = 'Account unauthorized (401 errors detected)' - updatedAccountData.unauthorizedAt = new Date().toISOString() + updatedAccountData.errorMessage = errorConfig.errorMessage + updatedAccountData[errorConfig.timestampField] = new Date().toISOString() // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) @@ -1720,7 +1742,7 @@ class ClaudeAccountService { } logger.warn( - `⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling` + `⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling` ) // 发送Webhook通知 @@ -1730,9 +1752,10 @@ class ClaudeAccountService { accountId, accountName: accountData.name, platform: 'claude-oauth', - status: 'unauthorized', - errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED', - reason: 'Account unauthorized (401 errors detected)' + status: errorConfig.status, + errorCode: errorConfig.errorCode, + reason: errorConfig.errorMessage, + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send webhook notification:', webhookError) @@ -1740,11 +1763,21 @@ class ClaudeAccountService { return { success: true } } catch (error) { - logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error) + logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error) throw error } } + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'unauthorized', sessionHash) + } + + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'blocked', sessionHash) + } + // 🔄 重置账户所有异常状态 async resetAccountStatus(accountId) { try { @@ -1769,6 +1802,7 @@ class ClaudeAccountService { // 清除错误相关字段 delete updatedAccountData.errorMessage delete updatedAccountData.unauthorizedAt + delete updatedAccountData.blockedAt delete updatedAccountData.rateLimitedAt delete updatedAccountData.rateLimitStatus delete updatedAccountData.rateLimitEndAt @@ -1779,6 +1813,20 @@ class ClaudeAccountService { // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + const fieldsToDelete = [ + 'errorMessage', + 'unauthorizedAt', + 'blockedAt', + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'tempErrorAt', + 'sessionWindowStart', + 'sessionWindowEnd' + ] + await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete) + // 清除401错误计数 const errorKey = `claude_account:${accountId}:401_errors` await redis.client.del(errorKey) @@ -1830,6 +1878,10 @@ class ClaudeAccountService { delete account.errorMessage delete account.tempErrorAt await redis.setClaudeAccount(account.id, account) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + await redis.client.hdel(`claude:account:${account.id}`, 'errorMessage', 'tempErrorAt') + // 同时清除500错误计数 await this.clearInternalErrors(account.id) cleanedCount++ @@ -1917,6 +1969,52 @@ class ClaudeAccountService { // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) + // 设置 5 分钟后自动恢复(一次性定时器) + setTimeout( + async () => { + try { + const account = await redis.getClaudeAccount(accountId) + if (account && account.status === 'temp_error' && account.tempErrorAt) { + // 验证是否确实过了 5 分钟(防止重复定时器) + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSince = (now - tempErrorAt) / (1000 * 60) + + if (minutesSince >= 5) { + // 恢复账户 + account.status = 'active' + account.schedulable = 'true' + delete account.errorMessage + delete account.tempErrorAt + + await redis.setClaudeAccount(accountId, account) + + // 显式删除 Redis 字段 + await redis.client.hdel( + `claude:account:${accountId}`, + 'errorMessage', + 'tempErrorAt' + ) + + // 清除 500 错误计数 + await this.clearInternalErrors(accountId) + + logger.success( + `✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})` + ) + } else { + logger.debug( + `⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})` + ) + } + } + } catch (error) { + logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error) + } + }, + 6 * 60 * 1000 + ) // 6 分钟后执行,确保已过 5 分钟 + // 如果有sessionHash,删除粘性会话映射 if (sessionHash) { await redis.client.del(`sticky_session:${sessionHash}`) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 28be976d..34c9a5c7 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -50,7 +50,9 @@ class ClaudeConsoleAccountService { proxy = null, isActive = true, accountType = 'shared', // 'dedicated' or 'shared' - schedulable = true // 是否可被调度 + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00' // 额度重置时间(HH:mm格式) } = options // 验证必填字段 @@ -85,7 +87,14 @@ class ClaudeConsoleAccountService { rateLimitedAt: '', rateLimitStatus: '', // 调度控制 - schedulable: schedulable.toString() + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '' // 因额度停用的时间 } const client = redis.getClientSafe() @@ -116,7 +125,12 @@ class ClaudeConsoleAccountService { proxy, accountType, status: 'active', - createdAt: accountData.createdAt + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null } } @@ -148,12 +162,18 @@ class ClaudeConsoleAccountService { isActive: accountData.isActive === 'true', proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, accountType: accountData.accountType || 'shared', - status: accountData.status, - errorMessage: accountData.errorMessage, createdAt: accountData.createdAt, lastUsedAt: accountData.lastUsedAt, - rateLimitStatus: rateLimitInfo, - schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度 + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null }) } } @@ -267,6 +287,23 @@ class ClaudeConsoleAccountService { updatedData.schedulable = updates.schedulable.toString() } + // 额度管理相关字段 + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + if (updates.dailyUsage !== undefined) { + updatedData.dailyUsage = updates.dailyUsage.toString() + } + if (updates.lastResetDate !== undefined) { + updatedData.lastResetDate = updates.lastResetDate + } + if (updates.quotaStoppedAt !== undefined) { + updatedData.quotaStoppedAt = updates.quotaStoppedAt + } + // 处理账户类型变更 if (updates.accountType && updates.accountType !== existingAccount.accountType) { updatedData.accountType = updates.accountType @@ -361,7 +398,16 @@ class ClaudeConsoleAccountService { const updates = { rateLimitedAt: new Date().toISOString(), - rateLimitStatus: 'limited' + rateLimitStatus: 'limited', + isActive: 'false', // 禁用账户 + errorMessage: `Rate limited at ${new Date().toISOString()}` + } + + // 只有当前状态不是quota_exceeded时才设置为rate_limited + // 避免覆盖更重要的配额超限状态 + const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status') + if (currentStatus !== 'quota_exceeded') { + updates.status = 'rate_limited' } await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) @@ -376,7 +422,7 @@ class ClaudeConsoleAccountService { platform: 'claude-console', status: 'error', errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', - reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, + reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { @@ -397,14 +443,40 @@ class ClaudeConsoleAccountService { async removeAccountRateLimit(accountId) { try { const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` - await client.hdel( - `${this.ACCOUNT_KEY_PREFIX}${accountId}`, - 'rateLimitedAt', - 'rateLimitStatus' + // 获取账户当前状态和额度信息 + const [currentStatus, quotaStoppedAt] = await client.hmget( + accountKey, + 'status', + 'quotaStoppedAt' ) - logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + if (currentStatus === 'rate_limited') { + if (quotaStoppedAt) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) + } else { + // 没有额度限制,完全恢复 + await client.hset(accountKey, { + isActive: 'true', + status: 'active', + errorMessage: '' + }) + logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`) + } + } else { + logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + } + return { success: true } } catch (error) { logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) @@ -454,6 +526,64 @@ class ClaudeConsoleAccountService { } } + // 🔍 检查账号是否因额度超限而被停用(懒惰检查) + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果没有设置额度限制,不会超额 + const dailyQuota = parseFloat(account.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + return false + } + + // 如果账户没有被额度停用,检查当前使用情况 + if (!account.quotaStoppedAt) { + return false + } + + // 检查是否应该重置额度(到了新的重置时间点) + if (this._shouldResetQuota(account)) { + await this.resetDailyUsage(accountId) + return false + } + + // 仍在额度超限状态 + return true + } catch (error) { + logger.error( + `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 判断是否应该重置账户额度 + _shouldResetQuota(account) { + // 与 Redis 统计一致:按配置时区判断“今天”与时间点 + const tzNow = redis.getDateInTimezone(new Date()) + const today = redis.getDateStringInTimezone(tzNow) + + // 如果已经是今天重置过的,不需要重置 + if (account.lastResetDate === today) { + return false + } + + // 检查是否到了重置时间点(按配置时区的小时/分钟) + const resetTime = account.quotaResetTime || '00:00' + const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n)) + + const currentHour = tzNow.getUTCHours() + const currentMinute = tzNow.getUTCMinutes() + + // 如果当前时间已过重置时间且不是同一天重置的,应该重置 + return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute) + } + // 🚫 标记账号为未授权状态(401错误) async markAccountUnauthorized(accountId) { try { @@ -820,6 +950,187 @@ class ClaudeConsoleAccountService { // 返回映射后的模型,如果不存在则返回原模型 return modelMapping[requestedModel] || requestedModel } + + // 💰 检查账户使用额度(基于实时统计数据) + async checkQuotaUsage(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 解析额度配置,确保数值有效 + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + // 没有设置有效额度,无需检查 + return + } + + // 检查是否已经因额度停用(避免重复操作) + if (!accountData.isActive && accountData.quotaStoppedAt) { + return + } + + // 检查是否超过额度限制 + if (currentDailyCost >= dailyQuota) { + // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // double-check locking pattern - 检查quotaStoppedAt而不是status + const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt') + if (existingQuotaStop) { + return // 已经被其他进程处理 + } + + // 超过额度,停用账户 + const updates = { + isActive: false, + quotaStoppedAt: new Date().toISOString(), + errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + } + + // 只有当前状态是active时才改为quota_exceeded + // 如果是rate_limited等其他状态,保持原状态不变 + const currentStatus = await client.hget(accountKey, 'status') + if (currentStatus === 'active') { + updates.status = 'quota_exceeded' + } + + await this.updateAccount(accountId, updates) + + logger.warn( + `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'quota_exceeded', + errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for quota exceeded:', webhookError) + } + } + + logger.debug( + `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}` + ) + } catch (error) { + logger.error('Failed to check quota usage:', error) + } + } + + // 🔄 重置账户每日使用量(恢复因额度停用的账户) + async resetDailyUsage(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + return + } + + const today = redis.getDateStringInTimezone() + const updates = { + lastResetDate: today + } + + // 如果账户是因为超额被停用的,恢复账户 + // 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了) + if ( + accountData.quotaStoppedAt && + accountData.isActive === false && + (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited') + ) { + updates.isActive = true + updates.status = 'active' + updates.errorMessage = '' + updates.quotaStoppedAt = '' + + // 如果是rate_limited状态,也清除限流相关字段 + if (accountData.status === 'rate_limited') { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + } + + logger.info( + `✅ Restored account ${accountId} after daily reset (was ${accountData.status})` + ) + } + + await this.updateAccount(accountId, updates) + + logger.debug(`🔄 Reset daily usage for account ${accountId}`) + } catch (error) { + logger.error('Failed to reset daily usage:', error) + } + } + + // 🔄 重置所有账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + // 与统计一致使用配置时区日期 + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + // 只重置需要重置的账户 + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`) + } catch (error) { + logger.error('Failed to reset all daily usage:', error) + } + } + + // 📊 获取账户使用统计(基于实时数据) + async getAccountUsageStats(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + + return { + dailyQuota, + dailyUsage: currentDailyCost, // 使用实时计算的费用 + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + // 额外返回完整的使用统计 + fullUsageStats: usageStats + } + } catch (error) { + logger.error('Failed to get account usage stats:', error) + return null + } + } } module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 27920a47..9785bdd0 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -181,6 +181,11 @@ class ClaudeConsoleRelayService { await claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (response.status === 429) { logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) + // 收到429先检查是否因为超过了手动配置的每日额度 + await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + await claudeConsoleAccountService.markAccountRateLimited(accountId) } else if (response.status === 529) { logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`) @@ -377,6 +382,10 @@ class ClaudeConsoleRelayService { claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) } else if (response.status === 529) { claudeConsoleAccountService.markAccountOverloaded(accountId) } @@ -589,6 +598,10 @@ class ClaudeConsoleRelayService { claudeConsoleAccountService.markAccountUnauthorized(accountId) } else if (error.response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) } else if (error.response.status === 529) { claudeConsoleAccountService.markAccountOverloaded(accountId) } diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index a25a2a8d..f7705624 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -198,6 +198,13 @@ class ClaudeRelayService { ) } } + // 检查是否为403状态码(禁止访问) + else if (response.statusCode === 403) { + logger.error( + `🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } // 检查是否为5xx状态码 else if (response.statusCode >= 500 && response.statusCode < 600) { logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) @@ -664,7 +671,10 @@ class ClaudeRelayService { } // 使用统一 User-Agent 或客户端提供的,最后使用默认值 - if (!options.headers['User-Agent'] && !options.headers['user-agent']) { + if ( + (!options.headers['User-Agent'] && !options.headers['user-agent']) || + account.useUnifiedUserAgent === 'true' + ) { const userAgent = unifiedUA || clientHeaders?.['user-agent'] || @@ -673,8 +683,9 @@ class ClaudeRelayService { options.headers['User-Agent'] = userAgent } - logger.info(`🔗 指纹是这个: ${options.headers['User-Agent']}`) - logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`) + logger.info( + `🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}` + ) // 使用自定义的 betaHeader 或默认值 const betaHeader = @@ -930,7 +941,10 @@ class ClaudeRelayService { } // 使用统一 User-Agent 或客户端提供的,最后使用默认值 - if (!options.headers['User-Agent'] && !options.headers['user-agent']) { + if ( + (!options.headers['User-Agent'] && !options.headers['user-agent']) || + account.useUnifiedUserAgent === 'true' + ) { const userAgent = unifiedUA || clientHeaders?.['user-agent'] || @@ -939,6 +953,9 @@ class ClaudeRelayService { options.headers['User-Agent'] = userAgent } + logger.info( + `🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}` + ) // 使用自定义的 betaHeader 或默认值 const betaHeader = requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader @@ -953,8 +970,32 @@ class ClaudeRelayService { if (res.statusCode !== 200) { // 将错误处理逻辑封装在一个异步函数中 const handleErrorResponse = async () => { - // 增加对5xx错误的处理 - if (res.statusCode >= 500 && res.statusCode < 600) { + if (res.statusCode === 401) { + logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`) + + await this.recordUnauthorizedError(accountId) + + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 1) { + logger.error( + `❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` + ) + await unifiedClaudeScheduler.markAccountUnauthorized( + accountId, + accountType, + sessionHash + ) + } + } else if (res.statusCode === 403) { + logger.error( + `🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } else if (res.statusCode >= 500 && res.statusCode < 600) { logger.warn( `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` ) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index bd10f455..60c404f1 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1022,15 +1022,23 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const clientMetadata = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - duetProject: projectId + pluginType: 'GEMINI' + } + + // 只有当projectId存在时才添加duetProject + if (projectId) { + clientMetadata.duetProject = projectId } const request = { - cloudaicompanionProject: projectId, metadata: clientMetadata } + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + request.cloudaicompanionProject = projectId + } + const axiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, method: 'POST', @@ -1096,10 +1104,14 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi const onboardReq = { tierId, - cloudaicompanionProject: projectId, metadata: clientMetadata } + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + onboardReq.cloudaicompanionProject = projectId + } + // 创建基础axios配置 const baseAxiosConfig = { url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, @@ -1278,7 +1290,6 @@ async function generateContent( // 按照 gemini-cli 的转换格式构造请求 const request = { model: requestData.model, - project: projectId, user_prompt_id: userPromptId, request: { ...requestData.request, @@ -1286,6 +1297,11 @@ async function generateContent( } } + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + logger.info('🤖 generateContent API调用开始', { model: requestData.model, userPromptId, @@ -1340,7 +1356,6 @@ async function generateContentStream( // 按照 gemini-cli 的转换格式构造请求 const request = { model: requestData.model, - project: projectId, user_prompt_id: userPromptId, request: { ...requestData.request, @@ -1348,6 +1363,11 @@ async function generateContentStream( } } + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + logger.info('🌊 streamGenerateContent API调用开始', { model: requestData.model, userPromptId, diff --git a/src/services/ldapService.js b/src/services/ldapService.js index 75b4e704..86fdb88d 100644 --- a/src/services/ldapService.js +++ b/src/services/ldapService.js @@ -97,6 +97,38 @@ class LdapService { return null } + // 🌐 从DN中提取域名,用于Windows AD UPN格式认证 + extractDomainFromDN(dnString) { + try { + if (!dnString || typeof dnString !== 'string') { + return null + } + + // 提取所有DC组件:DC=test,DC=demo,DC=com + const dcMatches = dnString.match(/DC=([^,]+)/gi) + if (!dcMatches || dcMatches.length === 0) { + return null + } + + // 提取DC值并连接成域名 + const domainParts = dcMatches.map((match) => { + const value = match.replace(/DC=/i, '').trim() + return value + }) + + if (domainParts.length > 0) { + const domain = domainParts.join('.') + logger.debug(`🌐 从DN提取域名: ${domain}`) + return domain + } + + return null + } catch (error) { + logger.debug('⚠️ 域名提取失败:', error.message) + return null + } + } + // 🔗 创建LDAP客户端连接 createClient() { try { @@ -336,6 +368,79 @@ class LdapService { }) } + // 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式 + async tryWindowsADAuthentication(username, password) { + if (!username || !password) { + return false + } + + // 从searchBase提取域名 + const domain = this.extractDomainFromDN(this.config.server.searchBase) + + const adFormats = [] + + if (domain) { + // UPN格式(Windows AD标准) + adFormats.push(`${username}@${domain}`) + + // 如果域名有多个部分,也尝试简化版本 + const domainParts = domain.split('.') + if (domainParts.length > 1) { + adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分 + } + + // 域\用户名格式 + const firstDomainPart = domainParts[0] + if (firstDomainPart) { + adFormats.push(`${firstDomainPart}\\${username}`) + adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`) + } + } + + // 纯用户名(最后尝试) + adFormats.push(username) + + logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`) + + for (const format of adFormats) { + try { + logger.info(`🔍 尝试格式: ${format}`) + const result = await this.tryDirectBind(format, password) + if (result) { + logger.info(`✅ Windows AD认证成功: ${format}`) + return true + } + logger.debug(`❌ 认证失败: ${format}`) + } catch (error) { + logger.debug(`认证异常 ${format}:`, error.message) + } + } + + logger.info(`🚫 所有Windows AD格式认证都失败了`) + return false + } + + // 🔐 直接尝试绑定认证的辅助方法 + async tryDirectBind(identifier, password) { + return new Promise((resolve, reject) => { + const authClient = this.createClient() + + authClient.bind(identifier, password, (err) => { + authClient.unbind() + + if (err) { + if (err.name === 'InvalidCredentialsError') { + resolve(false) + } else { + reject(err) + } + } else { + resolve(true) + } + }) + }) + } + // 📝 提取用户信息 extractUserInfo(ldapEntry, username) { try { @@ -478,10 +583,32 @@ class LdapService { return { success: false, message: 'Authentication service error' } } - // 4. 验证用户密码 - const isPasswordValid = await this.authenticateUser(userDN, password) + // 4. 验证用户密码 - 支持传统LDAP和Windows AD + let isPasswordValid = false + + // 首先尝试传统的DN认证(保持原有LDAP逻辑) + try { + isPasswordValid = await this.authenticateUser(userDN, password) + if (isPasswordValid) { + logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`) + } + } catch (error) { + logger.debug( + `DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}` + ) + } + + // 如果DN认证失败,尝试Windows AD多格式认证 if (!isPasswordValid) { - logger.info(`🚫 Invalid password for user: ${sanitizedUsername}`) + logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`) + isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password) + if (isPasswordValid) { + logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`) + } + } + + if (!isPasswordValid) { + logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`) return { success: false, message: 'Invalid username or password' } } diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index e60a8b3a..eb13ac1a 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -14,7 +14,7 @@ const { logRefreshSkipped } = require('../utils/tokenRefreshLogger') const LRUCache = require('../utils/lruCache') -// const tokenRefreshService = require('./tokenRefreshService') +const tokenRefreshService = require('./tokenRefreshService') // 加密相关常量 const ALGORITHM = 'aes-256-cbc' @@ -57,7 +57,17 @@ function encrypt(text) { // 解密函数 function decrypt(text) { - if (!text) { + if (!text || text === '') { + return '' + } + + // 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本) + if (text.length < 33 || text.charAt(32) !== ':') { + logger.warn('Invalid encrypted text format, returning empty string', { + textLength: text ? text.length : 0, + char32: text && text.length > 32 ? text.charAt(32) : 'N/A', + first50: text ? text.substring(0, 50) : 'N/A' + }) return '' } @@ -135,6 +145,7 @@ async function refreshAccessToken(refreshToken, proxy = null) { const proxyAgent = ProxyHelper.createProxyAgent(proxy) if (proxyAgent) { requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent logger.info( `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` ) @@ -143,6 +154,7 @@ async function refreshAccessToken(refreshToken, proxy = null) { } // 发送请求 + logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent) const response = await axios(requestOptions) if (response.status === 200 && response.data) { @@ -164,22 +176,73 @@ async function refreshAccessToken(refreshToken, proxy = null) { } catch (error) { if (error.response) { // 服务器响应了错误状态码 + const errorData = error.response.data || {} logger.error('OpenAI token refresh failed:', { status: error.response.status, - data: error.response.data, + data: errorData, headers: error.response.headers }) - throw new Error( - `Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}` - ) + + // 构建详细的错误信息 + let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` + + if (error.response.status === 400) { + if (errorData.error === 'invalid_grant') { + errorMessage = 'Refresh Token 无效或已过期,请重新授权' + } else if (errorData.error === 'invalid_request') { + errorMessage = `请求参数错误:${errorData.error_description || errorData.error}` + } else { + errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}` + } + } else if (error.response.status === 401) { + errorMessage = '认证失败:Refresh Token 无效' + } else if (error.response.status === 403) { + errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用' + } else if (error.response.status === 429) { + errorMessage = '请求过于频繁,请稍后重试' + } else if (error.response.status >= 500) { + errorMessage = 'OpenAI 服务器内部错误,请稍后重试' + } else if (errorData.error_description) { + errorMessage = errorData.error_description + } else if (errorData.error) { + errorMessage = errorData.error + } else if (errorData.message) { + errorMessage = errorData.message + } + + const fullError = new Error(errorMessage) + fullError.status = error.response.status + fullError.details = errorData + throw fullError } else if (error.request) { // 请求已发出但没有收到响应 logger.error('OpenAI token refresh no response:', error.message) - throw new Error(`Token refresh failed: No response from server - ${error.message}`) + + let errorMessage = '无法连接到 OpenAI 服务器' + if (proxy) { + errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})` + } + if (error.code === 'ECONNREFUSED') { + errorMessage += ' - 连接被拒绝' + } else if (error.code === 'ETIMEDOUT') { + errorMessage += ' - 连接超时' + } else if (error.code === 'ENOTFOUND') { + errorMessage += ' - 无法解析域名' + } else if (error.code === 'EPROTO') { + errorMessage += ' - 协议错误(可能是代理配置问题)' + } else if (error.message) { + errorMessage += ` - ${error.message}` + } + + const fullError = new Error(errorMessage) + fullError.code = error.code + throw fullError } else { // 设置请求时发生错误 logger.error('OpenAI token refresh error:', error.message) - throw new Error(`Token refresh failed: ${error.message}`) + const fullError = new Error(`请求设置错误: ${error.message}`) + fullError.originalError = error + throw fullError } } } @@ -192,34 +255,71 @@ function isTokenExpired(account) { return new Date(account.expiresAt) <= new Date() } -// 刷新账户的 access token +// 刷新账户的 access token(带分布式锁) async function refreshAccountToken(accountId) { - const account = await getAccount(accountId) - if (!account) { - throw new Error('Account not found') - } - - const accountName = account.name || accountId - logRefreshStart(accountId, accountName, 'openai') - - // 检查是否有 refresh token - const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null - if (!refreshToken) { - logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') - throw new Error('No refresh token available') - } - - // 获取代理配置 - let proxy = null - if (account.proxy) { - try { - proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy - } catch (e) { - logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) - } - } + let lockAcquired = false + let account = null + let accountName = accountId try { + account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + accountName = account.name || accountId + + // 检查是否有 refresh token + // account.refreshToken 在 getAccount 中已经被解密了,直接使用即可 + const refreshToken = account.refreshToken || null + + if (!refreshToken) { + logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') + throw new Error('No refresh token available') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})` + ) + logRefreshSkipped(accountId, accountName, 'openai', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedAccount = await getAccount(accountId) + if (updatedAccount && !isTokenExpired(updatedAccount)) { + return { + access_token: decrypt(updatedAccount.accessToken), + id_token: updatedAccount.idToken, + refresh_token: updatedAccount.refreshToken, + expires_in: 3600, + expiry_date: new Date(updatedAccount.expiresAt).getTime() + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 获取锁成功,开始刷新 + logRefreshStart(accountId, accountName, 'openai') + logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`) + + // 获取代理配置 + let proxy = null + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) + } + } + const newTokens = await refreshAccessToken(refreshToken, proxy) if (!newTokens) { throw new Error('Failed to refresh token') @@ -231,9 +331,51 @@ async function refreshAccountToken(accountId) { expiresAt: new Date(newTokens.expiry_date).toISOString() } - // 如果有新的 ID token,也更新它 + // 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要) if (newTokens.id_token) { updates.idToken = encrypt(newTokens.id_token) + + // 如果之前没有 ID Token,尝试解析并更新用户信息 + if (!account.idToken || account.idToken === '') { + try { + const idTokenParts = newTokens.id_token.split('.') + if (idTokenParts.length === 3) { + const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString()) + const authClaims = payload['https://api.openai.com/auth'] || {} + + // 更新账户信息 - 使用正确的字段名 + // OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段 + if (authClaims.chatgpt_account_id) { + updates.accountId = authClaims.chatgpt_account_id + } + if (authClaims.chatgpt_user_id) { + updates.chatgptUserId = authClaims.chatgpt_user_id + } else if (authClaims.user_id) { + // 有些情况下可能只有user_id字段 + updates.chatgptUserId = authClaims.user_id + } + if (authClaims.organizations?.[0]?.id) { + updates.organizationId = authClaims.organizations[0].id + } + if (authClaims.organizations?.[0]?.role) { + updates.organizationRole = authClaims.organizations[0].role + } + if (authClaims.organizations?.[0]?.title) { + updates.organizationTitle = authClaims.organizations[0].title + } + if (payload.email) { + updates.email = encrypt(payload.email) + } + if (payload.email_verified !== undefined) { + updates.emailVerified = payload.email_verified + } + + logger.info(`Updated user info from ID Token for account ${accountId}`) + } + } catch (e) { + logger.warn(`Failed to parse ID Token for account ${accountId}:`, e) + } + } } // 如果返回了新的 refresh token,更新它 @@ -248,8 +390,34 @@ async function refreshAccountToken(accountId) { logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date) return newTokens } catch (error) { - logRefreshError(accountId, accountName, 'openai', error.message) + logRefreshError(accountId, account?.name || accountName, 'openai', error.message) + + // 发送 Webhook 通知(如果启用) + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account?.name || accountName, + platform: 'openai', + status: 'error', + errorCode: 'OPENAI_TOKEN_REFRESH_FAILED', + reason: `Token refresh failed: ${error.message}`, + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure` + ) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + throw error + } finally { + // 确保释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'openai') + logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`) + } } } @@ -270,6 +438,10 @@ async function createAccount(accountData) { // 处理账户信息 const accountInfo = accountData.accountInfo || {} + // 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符) + const isEmailEncrypted = + accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' + const account = { id: accountId, name: accountData.name, @@ -282,19 +454,25 @@ async function createAccount(accountData) { ? accountData.rateLimitDuration : 60, // OAuth相关字段(加密存储) - idToken: encrypt(oauthData.idToken || ''), - accessToken: encrypt(oauthData.accessToken || ''), - refreshToken: encrypt(oauthData.refreshToken || ''), + // ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取 + idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', + accessToken: + oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '', + refreshToken: + oauthData.refreshToken && oauthData.refreshToken.trim() + ? encrypt(oauthData.refreshToken) + : '', openaiOauth: encrypt(JSON.stringify(oauthData)), - // 账户信息字段 + // 账户信息字段 - 确保所有字段都被保存,即使是空字符串 accountId: accountInfo.accountId || '', chatgptUserId: accountInfo.chatgptUserId || '', organizationId: accountInfo.organizationId || '', organizationRole: accountInfo.organizationRole || '', organizationTitle: accountInfo.organizationTitle || '', planType: accountInfo.planType || '', - email: encrypt(accountInfo.email || ''), - emailVerified: accountInfo.emailVerified || false, + // 邮箱字段:检查是否已经加密,避免双重加密 + email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), + emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', // 过期时间 expiresAt: oauthData.expires_in ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() @@ -339,9 +517,10 @@ async function getAccount(accountId) { if (accountData.idToken) { accountData.idToken = decrypt(accountData.idToken) } - if (accountData.accessToken) { - accountData.accessToken = decrypt(accountData.accessToken) - } + // 注意:accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密 + // if (accountData.accessToken) { + // accountData.accessToken = decrypt(accountData.accessToken) + // } if (accountData.refreshToken) { accountData.refreshToken = decrypt(accountData.refreshToken) } @@ -391,7 +570,7 @@ async function updateAccount(accountId, updates) { if (updates.accessToken) { updates.accessToken = encrypt(updates.accessToken) } - if (updates.refreshToken) { + if (updates.refreshToken && updates.refreshToken.trim()) { updates.refreshToken = encrypt(updates.refreshToken) } if (updates.email) { @@ -476,6 +655,9 @@ async function getAllAccounts() { accountData.email = decrypt(accountData.email) } + // 先保存 refreshToken 是否存在的标记 + const hasRefreshTokenFlag = !!accountData.refreshToken + // 屏蔽敏感信息(token等不应该返回给前端) delete accountData.idToken delete accountData.accessToken @@ -512,7 +694,7 @@ async function getAllAccounts() { scopes: accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], // 添加 hasRefreshToken 标记 - hasRefreshToken: !!accountData.refreshToken, + hasRefreshToken: hasRefreshTokenFlag, // 添加限流状态信息(统一格式) rateLimitStatus: rateLimitInfo ? { @@ -640,6 +822,26 @@ async function setAccountRateLimited(accountId, isLimited) { await updateAccount(accountId, updates) logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`) + + // 如果被限流,发送 Webhook 通知 + if (isLimited) { + try { + const account = await getAccount(accountId) + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'blocked', + errorCode: 'OPENAI_RATE_LIMITED', + reason: 'Account rate limited (429 error). Estimated reset in 1 hour', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + } } // 切换账户调度状态 diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index c83676a2..c373d1f5 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -20,6 +20,77 @@ class UnifiedClaudeScheduler { return schedulable !== false && schedulable !== 'false' } + // 🔍 检查账户是否支持请求的模型 + _isModelSupportedByAccount(account, accountType, requestedModel, context = '') { + if (!requestedModel) { + return true // 没有指定模型时,默认支持 + } + + // Claude OAuth 账户的 Opus 模型检查 + if (accountType === 'claude-official') { + if (requestedModel.toLowerCase().includes('opus')) { + if (account.subscriptionInfo) { + try { + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + logger.info( + `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + logger.debug( + `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` + ) + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + } + } + + // Claude Console 账户的模型支持检查 + if (accountType === 'claude-console' && account.supportedModels) { + // 兼容旧格式(数组)和新格式(对象) + if (Array.isArray(account.supportedModels)) { + // 旧格式:数组 + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } else if (typeof account.supportedModels === 'object') { + // 新格式:映射表 + if ( + Object.keys(account.supportedModels).length > 0 && + !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } + } + + return true + } + // 🎯 统一调度Claude账号(官方和Console) async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { try { @@ -102,7 +173,8 @@ class UnifiedClaudeScheduler { // 验证映射的账户是否仍然可用 const isAvailable = await this._isAccountAvailable( mappedAccount.accountId, - mappedAccount.accountType + mappedAccount.accountType, + requestedModel ) if (isAvailable) { logger.info( @@ -209,10 +281,25 @@ class UnifiedClaudeScheduler { boundConsoleAccount.isActive === true && boundConsoleAccount.status === 'active' ) { + // 主动触发一次额度检查 + try { + await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id) + } catch (e) { + logger.warn( + `Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}` + ) + // 继续使用该账号 + } + + // 检查限流状态和额度状态 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( boundConsoleAccount.id ) - if (!isRateLimited) { + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + boundConsoleAccount.id + ) + + if (!isRateLimited && !isQuotaExceeded) { logger.info( `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` ) @@ -269,33 +356,9 @@ class UnifiedClaudeScheduler { ) { // 检查是否可调度 - // 检查模型支持(如果请求的是 Opus 模型) - if (requestedModel && requestedModel.toLowerCase().includes('opus')) { - // 检查账号的订阅信息 - if (account.subscriptionInfo) { - try { - const info = - typeof account.subscriptionInfo === 'string' - ? JSON.parse(account.subscriptionInfo) - : account.subscriptionInfo - - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`) - continue // Claude Pro 不支持 Opus - } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - logger.info( - `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model` - ) - continue // 明确标记为 Pro 或 Free 的账号不支持 - } - } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) - logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`) - } - } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) { + continue } // 检查是否被限流 @@ -330,37 +393,26 @@ class UnifiedClaudeScheduler { ) { // 检查是否可调度 - // 检查模型支持(如果有请求的模型) - if (requestedModel && account.supportedModels) { - // 兼容旧格式(数组)和新格式(对象) - if (Array.isArray(account.supportedModels)) { - // 旧格式:数组 - if ( - account.supportedModels.length > 0 && - !account.supportedModels.includes(requestedModel) - ) { - logger.info( - `🚫 Claude Console account ${account.name} does not support model ${requestedModel}` - ) - continue - } - } else if (typeof account.supportedModels === 'object') { - // 新格式:映射表 - if ( - Object.keys(account.supportedModels).length > 0 && - !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel) - ) { - logger.info( - `🚫 Claude Console account ${account.name} does not support model ${requestedModel}` - ) - continue - } - } + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) { + continue + } + + // 主动触发一次额度检查,确保状态即时生效 + try { + await claudeConsoleAccountService.checkQuotaUsage(account.id) + } catch (e) { + logger.warn( + `Failed to check quota for Claude Console account ${account.name}: ${e.message}` + ) + // 继续处理该账号 } // 检查是否被限流 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) - if (!isRateLimited) { + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id) + + if (!isRateLimited && !isQuotaExceeded) { availableAccounts.push({ ...account, accountId: account.id, @@ -372,7 +424,12 @@ class UnifiedClaudeScheduler { `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` ) } else { - logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + if (isRateLimited) { + logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 Claude Console account ${account.name} quota exceeded`) + } } } else { logger.info( @@ -439,7 +496,7 @@ class UnifiedClaudeScheduler { } // 🔍 检查账户是否可用 - async _isAccountAvailable(accountId, accountType) { + async _isAccountAvailable(accountId, accountType, requestedModel = null) { try { if (accountType === 'claude-official') { const account = await redis.getClaudeAccount(accountId) @@ -456,6 +513,19 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Account ${accountId} is not schedulable`) return false } + + // 检查模型兼容性 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-official', + requestedModel, + 'in session check' + ) + ) { + return false + } + return !(await claudeAccountService.isAccountRateLimited(accountId)) } else if (accountType === 'claude-console') { const account = await claudeConsoleAccountService.getAccount(accountId) @@ -475,10 +545,32 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) return false } + // 检查模型支持 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-console', + requestedModel, + 'in session check' + ) + ) { + return false + } + // 检查是否超额 + try { + await claudeConsoleAccountService.checkQuotaUsage(accountId) + } catch (e) { + logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`) + // 继续处理 + } + // 检查是否被限流 if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { return false } + if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) { + return false + } // 检查是否未授权(401错误) if (account.status === 'unauthorized') { return false @@ -636,6 +728,32 @@ class UnifiedClaudeScheduler { } } + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountBlocked(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`) + } else { + logger.info( + `ℹ️ Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error) + throw error + } + } + // 🚫 标记Claude Console账户为封锁状态(模型不支持) async blockConsoleAccount(accountId, reason) { try { @@ -667,7 +785,8 @@ class UnifiedClaudeScheduler { if (memberIds.includes(mappedAccount.accountId)) { const isAvailable = await this._isAccountAvailable( mappedAccount.accountId, - mappedAccount.accountType + mappedAccount.accountType, + requestedModel ) if (isAvailable) { logger.info( @@ -730,19 +849,9 @@ class UnifiedClaudeScheduler { : account.status === 'active' if (isActive && status && this._isSchedulable(account.schedulable)) { - // 检查模型支持(Console账户) - if ( - accountType === 'claude-console' && - requestedModel && - account.supportedModels && - account.supportedModels.length > 0 - ) { - if (!account.supportedModels.includes(requestedModel)) { - logger.info( - `🚫 Account ${account.name} in group does not support model ${requestedModel}` - ) - continue - } + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) { + continue } // 检查是否被限流 diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index 153f75b0..85404543 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -167,7 +167,7 @@ class UnifiedOpenAIScheduler { // 获取所有OpenAI账户(共享池) const openaiAccounts = await openaiAccountService.getAllAccounts() - for (const account of openaiAccounts) { + for (let account of openaiAccounts) { if ( account.isActive && account.status !== 'error' && @@ -176,13 +176,27 @@ class UnifiedOpenAIScheduler { ) { // 检查是否可调度 - // 检查token是否过期 + // 检查token是否过期并自动刷新 const isExpired = openaiAccountService.isTokenExpired(account) - if (isExpired && !account.refreshToken) { - logger.warn( - `⚠️ OpenAI account ${account.name} token expired and no refresh token available` - ) - continue + if (isExpired) { + if (!account.refreshToken) { + logger.warn( + `⚠️ OpenAI account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 自动刷新过期的 token + try { + logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`) + await openaiAccountService.refreshAccountToken(account.id) + // 重新获取更新后的账户信息 + account = await openaiAccountService.getAccount(account.id) + logger.info(`✅ Token refreshed successfully for ${account.name}`) + } catch (refreshError) { + logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message) + continue // 刷新失败,跳过此账户 + } } // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) diff --git a/src/services/userService.js b/src/services/userService.js index 601d6419..00f0665f 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -75,6 +75,11 @@ class UserService { await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) await redis.set(`${this.usernamePrefix}${username}`, user.id) + // 如果是新用户,尝试转移匹配的API Keys + if (isNewUser) { + await this.transferMatchingApiKeys(user) + } + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) return user } catch (error) { @@ -509,6 +514,80 @@ class UserService { throw error } } + + // 🔄 转移匹配的API Keys给新用户 + async transferMatchingApiKeys(user) { + try { + const apiKeyService = require('./apiKeyService') + const { displayName, username, email } = user + + // 获取所有API Keys + const allApiKeys = await apiKeyService.getAllApiKeys() + + // 找到没有用户ID的API Keys(即由Admin创建的) + const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '') + + if (unownedApiKeys.length === 0) { + logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`) + return + } + + // 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值) + const matchStrings = new Set() + if (displayName) { + matchStrings.add(displayName.toLowerCase().trim()) + } + if (username) { + matchStrings.add(username.toLowerCase().trim()) + } + if (email) { + matchStrings.add(email.toLowerCase().trim()) + } + + const matchingKeys = [] + + // 查找名称匹配的API Keys(只进行完全匹配) + for (const apiKey of unownedApiKeys) { + const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : '' + + // 检查API Key名称是否与用户信息完全匹配 + for (const matchString of matchStrings) { + if (keyName === matchString) { + matchingKeys.push(apiKey) + break // 找到匹配后跳出内层循环 + } + } + } + + // 转移匹配的API Keys + let transferredCount = 0 + for (const apiKey of matchingKeys) { + try { + await apiKeyService.updateApiKey(apiKey.id, { + userId: user.id, + userUsername: user.username, + createdBy: user.username + }) + + transferredCount++ + logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`) + } catch (error) { + logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error) + } + } + + if (transferredCount > 0) { + logger.success( + `🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})` + ) + } else if (matchingKeys.length === 0) { + logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`) + } + } catch (error) { + logger.error('❌ Error transferring matching API keys:', error) + // Don't throw error to prevent blocking user creation + } + } } module.exports = new UserService() diff --git a/src/services/webhookService.js b/src/services/webhookService.js old mode 100644 new mode 100755 index c026ead6..d791ce68 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -3,6 +3,7 @@ const crypto = require('crypto') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') const { getISOStringWithTimezone } = require('../utils/dateHelper') +const appConfig = require('../../config/config') class WebhookService { constructor() { @@ -15,6 +16,7 @@ class WebhookService { custom: this.sendToCustom.bind(this), bark: this.sendToBark.bind(this) } + this.timezone = appConfig.system.timezone || 'Asia/Shanghai' } /** @@ -309,11 +311,10 @@ class WebhookService { formatMessageForWechatWork(type, data) { const title = this.getNotificationTitle(type) const details = this.formatNotificationDetails(data) - return ( `## ${title}\n\n` + `> **服务**: Claude Relay Service\n` + - `> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + `> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` ) } @@ -325,7 +326,7 @@ class WebhookService { return ( `#### 服务: Claude Relay Service\n` + - `#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}` + `#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` ) } @@ -450,7 +451,7 @@ class WebhookService { // 添加服务标识和时间戳 lines.push(`\n服务: Claude Relay Service`) - lines.push(`时间: ${new Date().toLocaleString('zh-CN')}`) + lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`) return lines.join('\n') } diff --git a/src/utils/logger.js b/src/utils/logger.js index ac4cd618..30093f05 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,6 +1,7 @@ const winston = require('winston') const DailyRotateFile = require('winston-daily-rotate-file') const config = require('../../config/config') +const { formatDateWithTimezone } = require('../utils/dateHelper') const path = require('path') const fs = require('fs') const os = require('os') @@ -95,7 +96,7 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { // 📝 增强的日志格式 const createLogFormat = (colorize = false) => { const formats = [ - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), winston.format.errors({ stack: true }) // 移除 winston.format.metadata() 来避免自动包装 ] diff --git a/src/utils/sessionHelper.js b/src/utils/sessionHelper.js index c8313eaf..ab715e94 100644 --- a/src/utils/sessionHelper.js +++ b/src/utils/sessionHelper.js @@ -4,7 +4,7 @@ const logger = require('./logger') class SessionHelper { /** * 生成会话哈希,用于sticky会话保持 - * 基于Anthropic的prompt caching机制,优先使用cacheable内容 + * 基于Anthropic的prompt caching机制,优先使用metadata中的session ID * @param {Object} requestBody - 请求体 * @returns {string|null} - 32字符的会话哈希,如果无法生成则返回null */ @@ -13,11 +13,24 @@ class SessionHelper { return null } + // 1. 最高优先级:使用metadata中的session ID(直接使用,无需hash) + if (requestBody.metadata && requestBody.metadata.user_id) { + // 提取 session_xxx 部分 + const userIdString = requestBody.metadata.user_id + const sessionMatch = userIdString.match(/session_([a-f0-9-]{36})/) + if (sessionMatch && sessionMatch[1]) { + const sessionId = sessionMatch[1] + // 直接返回session ID + logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`) + return sessionId + } + } + let cacheableContent = '' const system = requestBody.system || '' const messages = requestBody.messages || [] - // 1. 优先提取带有cache_control: {"type": "ephemeral"}的内容 + // 2. 提取带有cache_control: {"type": "ephemeral"}的内容 // 检查system中的cacheable内容 if (Array.isArray(system)) { for (const part of system) { @@ -30,13 +43,13 @@ class SessionHelper { // 检查messages中的cacheable内容 for (const msg of messages) { const content = msg.content || '' + let hasCacheControl = false + if (Array.isArray(content)) { for (const part of content) { if (part && part.cache_control && part.cache_control.type === 'ephemeral') { - if (part.type === 'text') { - cacheableContent += part.text || '' - } - // 其他类型(如image)不参与hash计算 + hasCacheControl = true + break } } } else if ( @@ -44,12 +57,31 @@ class SessionHelper { msg.cache_control && msg.cache_control.type === 'ephemeral' ) { - // 罕见情况,但需要检查 - cacheableContent += content + hasCacheControl = true + } + + if (hasCacheControl) { + for (const message of messages) { + let messageText = '' + if (typeof message.content === 'string') { + messageText = message.content + } else if (Array.isArray(message.content)) { + messageText = message.content + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') + } + + if (messageText) { + cacheableContent += messageText + break + } + } + break } } - // 2. 如果有cacheable内容,直接使用 + // 3. 如果有cacheable内容,直接使用 if (cacheableContent) { const hash = crypto .createHash('sha256') @@ -60,7 +92,7 @@ class SessionHelper { return hash } - // 3. Fallback: 使用system内容 + // 4. Fallback: 使用system内容 if (system) { let systemText = '' if (typeof system === 'string') { @@ -76,7 +108,7 @@ class SessionHelper { } } - // 4. 最后fallback: 使用第一条消息内容 + // 5. 最后fallback: 使用第一条消息内容 if (messages.length > 0) { const firstMessage = messages[0] let firstMessageText = '' diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index e5002c29..34a4976d 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -68,6 +68,7 @@ class WebhookNotifier { const errorCodes = { 'claude-oauth': { unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', + blocked: 'CLAUDE_OAUTH_BLOCKED', error: 'CLAUDE_OAUTH_ERROR', disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED' }, @@ -80,6 +81,12 @@ class WebhookNotifier { error: 'GEMINI_ERROR', unauthorized: 'GEMINI_UNAUTHORIZED', disabled: 'GEMINI_MANUALLY_DISABLED' + }, + openai: { + error: 'OPENAI_ERROR', + unauthorized: 'OPENAI_UNAUTHORIZED', + blocked: 'OPENAI_RATE_LIMITED', + disabled: 'OPENAI_MANUALLY_DISABLED' } } diff --git a/web/admin-spa/src/assets/styles/global.css b/web/admin-spa/src/assets/styles/global.css index a97e9bf4..3c921320 100644 --- a/web/admin-spa/src/assets/styles/global.css +++ b/web/admin-spa/src/assets/styles/global.css @@ -17,7 +17,7 @@ --bg-gradient-mid: #764ba2; --bg-gradient-end: #f093fb; --input-bg: rgba(255, 255, 255, 0.9); - --input-border: rgba(255, 255, 255, 0.3); + --input-border: rgba(209, 213, 219, 0.8); --modal-bg: rgba(0, 0, 0, 0.4); --table-bg: rgba(255, 255, 255, 0.95); --table-hover: rgba(102, 126, 234, 0.05); diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 19182c8b..d47a4ba5 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -176,7 +176,7 @@ > @@ -300,7 +300,7 @@ > @@ -351,7 +351,7 @@ > @@ -434,7 +434,7 @@ > @@ -463,7 +463,7 @@ > @@ -481,7 +481,7 @@ > @@ -516,7 +516,7 @@ > + +
+ 设置每日使用额度,0 表示不限制 +
++ 每日自动重置额度的时间 +
+- {{ errors.idToken }} -
- ID Token 是 OpenAI OAuth 认证返回的 JWT token,包含用户信息和组织信息 + + Access Token 可选填。如果不提供,系统会通过 Refresh Token 自动获取。
+ {{ errors.refreshToken }} +
++ + 系统将使用 Refresh Token 自动获取 Access Token 和用户信息 +
++ 设置每日使用额度,0 表示不限制 +
+每日自动重置额度的时间
+