mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user