refacto: 重构admin.js

This commit is contained in:
shaw
2025-11-27 22:16:45 +08:00
parent 851809a132
commit fd2b8a0114
19 changed files with 10161 additions and 9917 deletions

View File

@@ -0,0 +1,805 @@
/**
* Admin Routes - OpenAI 账户管理
* 处理 OpenAI 账户的 CRUD 操作和 OAuth 授权流程
*/
const express = require('express')
const crypto = require('crypto')
const axios = require('axios')
const openaiAccountService = require('../../services/openaiAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const ProxyHelper = require('../../utils/proxyHelper')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// OpenAI OAuth 配置
const OPENAI_CONFIG = {
BASE_URL: 'https://auth.openai.com',
CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
REDIRECT_URI: 'http://localhost:1455/auth/callback',
SCOPE: 'openid profile email offline_access'
}
/**
* 生成 PKCE 参数
* @returns {Object} 包含 codeVerifier 和 codeChallenge 的对象
*/
function generateOpenAIPKCE() {
const codeVerifier = crypto.randomBytes(64).toString('hex')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
return {
codeVerifier,
codeChallenge
}
}
// 生成 OpenAI OAuth 授权 URL
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body
// 生成 PKCE 参数
const pkce = generateOpenAIPKCE()
// 生成随机 state
const state = crypto.randomBytes(32).toString('hex')
// 创建会话 ID
const sessionId = crypto.randomUUID()
// 将 PKCE 参数和代理配置存储到 Redis
await redis.setOAuthSession(sessionId, {
codeVerifier: pkce.codeVerifier,
codeChallenge: pkce.codeChallenge,
state,
proxy: proxy || null,
platform: 'openai',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString()
})
// 构建授权 URL 参数
const params = new URLSearchParams({
response_type: 'code',
client_id: OPENAI_CONFIG.CLIENT_ID,
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
scope: OPENAI_CONFIG.SCOPE,
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state,
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true'
})
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
logger.success('🔗 Generated OpenAI OAuth authorization URL')
return res.json({
success: true,
data: {
authUrl,
sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 OpenAI 账户',
'3. 同意应用权限',
'4. 复制浏览器地址栏中的完整 URL包含 code 参数)',
'5. 在添加账户表单中粘贴完整的回调 URL'
]
}
})
} catch (error) {
logger.error('生成 OpenAI OAuth URL 失败:', error)
return res.status(500).json({
success: false,
message: '生成授权链接失败',
error: error.message
})
}
})
// 交换 OpenAI 授权码
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId } = req.body
if (!code || !sessionId) {
return res.status(400).json({
success: false,
message: '缺少必要参数'
})
}
// 从 Redis 获取会话数据
const sessionData = await redis.getOAuthSession(sessionId)
if (!sessionData) {
return res.status(400).json({
success: false,
message: '会话已过期或无效'
})
}
// 准备 token 交换请求
const tokenData = {
grant_type: 'authorization_code',
code: code.trim(),
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
client_id: OPENAI_CONFIG.CLIENT_ID,
code_verifier: sessionData.codeVerifier
}
logger.info('Exchanging OpenAI authorization code:', {
sessionId,
codeLength: code.length,
hasCodeVerifier: !!sessionData.codeVerifier
})
// 配置代理(如果有)
const axiosConfig = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
if (proxyAgent) {
axiosConfig.httpAgent = proxyAgent
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
}
// 交换 authorization code 获取 tokens
const tokenResponse = await axios.post(
`${OPENAI_CONFIG.BASE_URL}/oauth/token`,
new URLSearchParams(tokenData).toString(),
axiosConfig
)
const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data
// 解析 ID token 获取用户信息
const idTokenParts = id_token.split('.')
if (idTokenParts.length !== 3) {
throw new Error('Invalid ID token format')
}
// 解码 JWT payload
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString())
// 获取 OpenAI 特定的声明
const authClaims = payload['https://api.openai.com/auth'] || {}
const accountId = authClaims.chatgpt_account_id || ''
const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || ''
const planType = authClaims.chatgpt_plan_type || ''
// 获取组织信息
const organizations = authClaims.organizations || []
const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {}
const organizationId = defaultOrg.id || ''
const organizationRole = defaultOrg.role || ''
const organizationTitle = defaultOrg.title || ''
// 清理 Redis 会话
await redis.deleteOAuthSession(sessionId)
logger.success('✅ OpenAI OAuth token exchange successful')
return res.json({
success: true,
data: {
tokens: {
idToken: id_token,
accessToken: access_token,
refreshToken: refresh_token,
expires_in
},
accountInfo: {
accountId,
chatgptUserId,
organizationId,
organizationRole,
organizationTitle,
planType,
email: payload.email || '',
name: payload.name || '',
emailVerified: payload.email_verified || false,
organizations
}
}
})
} catch (error) {
logger.error('OpenAI OAuth token exchange failed:', error)
return res.status(500).json({
success: false,
message: '交换授权码失败',
error: error.message
})
}
})
// 获取所有 OpenAI 账户
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await openaiAccountService.getAllAccounts()
// 缓存账户所属分组,避免重复查询
const accountGroupCache = new Map()
const fetchAccountGroups = async (accountId) => {
if (!accountGroupCache.has(accountId)) {
const groups = await accountGroupService.getAccountGroups(accountId)
accountGroupCache.set(accountId, groups || [])
}
return accountGroupCache.get(accountId)
}
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'openai') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await fetchAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
monthly: usageStats.monthly
}
}
} catch (error) {
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
}
})
)
logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`)
return res.json({
success: true,
data: accountsWithStats
})
} catch (error) {
logger.error('获取 OpenAI 账户列表失败:', error)
return res.status(500).json({
success: false,
message: '获取账户列表失败',
error: error.message
})
}
})
// 创建 OpenAI 账户
router.post('/', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
openaiOauth,
accountInfo,
proxy,
accountType,
groupId,
rateLimitDuration,
priority,
needsImmediateRefresh, // 是否需要立即刷新
requireRefreshSuccess // 是否必须刷新成功才能创建
} = req.body
if (!name) {
return res.status(400).json({
success: false,
message: '账户名称不能为空'
})
}
// 准备账户数据
const accountData = {
name,
description: description || '',
accountType: accountType || 'shared',
priority: priority || 50,
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
openaiOauth: openaiOauth || {},
accountInfo: accountInfo || {},
proxy: proxy || null,
isActive: true,
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)
// 如果是分组类型,添加到分组
if (accountType === 'group' && groupId) {
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({
success: true,
data: createdAccount
})
} catch (error) {
logger.error('创建 OpenAI 账户失败:', error)
return res.status(500).json({
success: false,
message: '创建账户失败',
error: error.message
})
}
})
// 更新 OpenAI 账户
router.put('/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'OpenAI', id)
const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates
// 验证accountType的有效性
if (
mappedUpdates.accountType &&
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await openaiAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 如果更新了 Refresh Token需要验证其有效性
if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) {
// 先更新 token 信息
const tempUpdateData = {}
if (mappedUpdates.openaiOauth.refreshToken) {
tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
}
if (mappedUpdates.openaiOauth.accessToken) {
tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken
}
// 更新代理配置(如果有)
if (mappedUpdates.proxy !== undefined) {
tempUpdateData.proxy = mappedUpdates.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 (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从原分组中移除
if (currentAccount.accountType === 'group') {
const oldGroup = await accountGroupService.getAccountGroup(id)
if (oldGroup) {
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
}
}
// 如果新类型是分组,添加到新分组
if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) {
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
}
}
// 准备更新数据
const updateData = { ...mappedUpdates }
// 处理敏感数据加密
if (mappedUpdates.openaiOauth) {
updateData.openaiOauth = mappedUpdates.openaiOauth
// 编辑时不允许直接输入 ID Token只能通过刷新获取
if (mappedUpdates.openaiOauth.accessToken) {
updateData.accessToken = mappedUpdates.openaiOauth.accessToken
}
if (mappedUpdates.openaiOauth.refreshToken) {
updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
}
if (mappedUpdates.openaiOauth.expires_in) {
updateData.expiresAt = new Date(
Date.now() + mappedUpdates.openaiOauth.expires_in * 1000
).toISOString()
}
}
// 更新账户信息
if (mappedUpdates.accountInfo) {
updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId
updateData.chatgptUserId =
mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
updateData.organizationId =
mappedUpdates.accountInfo.organizationId || currentAccount.organizationId
updateData.organizationRole =
mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole
updateData.organizationTitle =
mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle
updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType
updateData.email = mappedUpdates.accountInfo.email || currentAccount.email
updateData.emailVerified =
mappedUpdates.accountInfo.emailVerified !== undefined
? mappedUpdates.accountInfo.emailVerified
: currentAccount.emailVerified
}
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) {
logger.error('❌ Failed to update OpenAI account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
}
})
// 删除 OpenAI 账户
router.delete('/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await openaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: '账户不存在'
})
}
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai')
// 如果账户在分组中,从分组中移除
if (account.accountType === 'group') {
const group = await accountGroupService.getAccountGroup(id)
if (group) {
await accountGroupService.removeAccountFromGroup(id, group.id)
}
}
await openaiAccountService.deleteAccount(id)
let message = 'OpenAI账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(
`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys`
)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('删除 OpenAI 账户失败:', error)
return res.status(500).json({
success: false,
message: '删除账户失败',
error: error.message
})
}
})
// 切换 OpenAI 账户状态
router.put('/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await redis.getOpenAiAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: '账户不存在'
})
}
// 切换启用状态
account.enabled = !account.enabled
account.updatedAt = new Date().toISOString()
// TODO: 更新方法
// await redis.updateOpenAiAccount(id, account)
logger.success(
`${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
)
return res.json({
success: true,
data: account
})
} catch (error) {
logger.error('切换 OpenAI 账户状态失败:', error)
return res.status(500).json({
success: false,
message: '切换账户状态失败',
error: error.message
})
}
})
// 重置 OpenAI 账户状态(清除所有异常状态)
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await openaiAccountService.resetAccountStatus(accountId)
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset OpenAI account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
// 切换 OpenAI 账户调度状态
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await openaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await redis.getOpenAiAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'OpenAI Account',
platform: 'openai',
status: 'disabled',
errorCode: 'OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({
success: result.success,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
})
module.exports = router