feat: Codex账号管理优化与API Key激活机制

 新功能
- 支持通过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刷新失败告警
This commit is contained in:
shaw
2025-09-06 17:39:05 +08:00
parent 0e746b1056
commit d2f3f6866c
19 changed files with 1231 additions and 463 deletions

View File

@@ -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) {

View File

@@ -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}`)
}
}
// 解密 accessTokenaccount.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
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}
// 切换账户调度状态

View File

@@ -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且不为空时才检查

View File

@@ -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'
}
}