From d2f3f6866c944d3b75a29cccdb39d4e9605e712f Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 6 Sep 2025 17:39:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Codex=E8=B4=A6=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=BC=98=E5=8C=96=E4=B8=8EAPI=20Key=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 新功能 - 支持通过refreshToken新增Codex账号,创建时立即验证token有效性 - API Key新增首次使用自动激活机制,支持activation模式设置有效期 - 前端账号表单增加token验证功能,确保账号创建成功 🐛 修复 - 修复Codex token刷新失败问题,增加分布式锁防止并发刷新 - 优化token刷新错误处理,提供更详细的错误信息和建议 - 修复OpenAI账号token过期检测和自动刷新逻辑 📝 文档更新 - 更新README中Codex使用说明,改为config.toml配置方式 - 优化Cherry Studio等第三方工具接入文档 - 添加详细的配置示例和账号类型说明 🎨 界面优化 - 改进账号创建表单UI,支持手动和OAuth两种模式 - 优化API Key过期时间编辑弹窗,支持激活操作 - 调整教程页面布局,提升移动端响应式体验 💡 代码改进 - 重构token刷新服务,增强错误处理和重试机制 - 优化代理配置处理,确保OAuth请求正确使用代理 - 改进webhook通知,增加token刷新失败告警 --- README.md | 95 ++++-- src/routes/admin.js | 319 +++++++++++++++++- src/routes/openaiRoutes.js | 27 +- src/services/apiKeyService.js | 52 ++- src/services/openaiAccountService.js | 294 +++++++++++++--- src/services/unifiedOpenAIScheduler.js | 28 +- src/utils/webhookNotifier.js | 6 + web/admin-spa/src/assets/styles/global.css | 2 +- .../src/components/accounts/AccountForm.vue | 281 +++++++++------ .../src/components/accounts/ProxyConfig.vue | 10 +- .../apikeys/BatchEditApiKeyModal.vue | 22 +- .../components/apikeys/CreateApiKeyModal.vue | 145 ++++++-- .../components/apikeys/EditApiKeyModal.vue | 20 +- .../components/apikeys/ExpiryEditModal.vue | 62 +++- .../src/components/common/AccountSelector.vue | 4 +- web/admin-spa/src/config/api.js | 11 +- web/admin-spa/src/utils/toast.js | 5 +- web/admin-spa/src/views/ApiKeysView.vue | 33 +- web/admin-spa/src/views/TutorialView.vue | 278 +++++---------- 19 files changed, 1231 insertions(+), 463 deletions(-) diff --git a/README.md b/README.md index 25b026dc..30dcfb50 100644 --- a/README.md +++ b/README.md @@ -474,50 +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 额外配置:** - -需要在 `~/.codex/config.toml` 文件中添加以下配置来禁用响应存储: +在 `~/.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/src/routes/admin.js b/src/routes/admin.js index 40d35aee..c26e613c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -491,7 +491,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, // 新增:激活后有效天数 + expirationMode // 新增:过期模式 } = req.body // 输入验证 @@ -569,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, @@ -590,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}`) @@ -624,7 +653,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, + expirationMode } = req.body // 输入验证 @@ -668,7 +699,9 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { allowedClients, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + activationDays, + expirationMode }) // 保留原始 API Key 供返回 @@ -1142,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 { @@ -5633,7 +5745,9 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { accountType, groupId, rateLimitDuration, - priority + priority, + needsImmediateRefresh, // 是否需要立即刷新 + requireRefreshSuccess // 是否必须刷新成功才能创建 } = req.body if (!name) { @@ -5642,7 +5756,8 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { message: '账户名称不能为空' }) } - // 创建账户数据 + + // 准备账户数据 const accountData = { name, description: description || '', @@ -5657,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) // 如果是分组类型,添加到分组 @@ -5665,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({ @@ -5686,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)) { @@ -5705,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) { // 如果之前是分组类型,需要从原分组中移除 @@ -5726,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 } @@ -5762,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/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/services/apiKeyService.js b/src/services/apiKeyService.js index 60e0e2d2..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,6 +397,10 @@ class ApiKeyService { 'bedrockAccountId', // 添加 Bedrock 账号ID 'permissions', 'expiresAt', + 'activationDays', // 新增:激活后有效天数 + 'expirationMode', // 新增:过期模式 + 'isActivated', // 新增:是否已激活 + 'activatedAt', // 新增:激活时间 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', @@ -380,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() } 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/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/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index dccbb878..34a4976d 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -81,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 0d4c3226..d47a4ba5 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -176,7 +176,7 @@ >