diff --git a/src/models/redis.js b/src/models/redis.js index 0f5a57ec..d1d2757b 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -817,6 +817,30 @@ class RedisClient { const key = `claude:account:${accountId}` return await this.client.del(key) } + async setOpenAiAccount(accountId, accountData) { + const key = `openai:account:${accountId}` + await this.client.hset(key, accountData) + } + async getOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + return await this.client.hgetall(key) + } + async deleteOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + return await this.client.del(key) + } + + async getAllOpenAIAccounts() { + const keys = await this.client.keys('openai:account:*') + const accounts = [] + for (const key of keys) { + const accountData = await this.client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: key.replace('claude:account:', ''), ...accountData }) + } + } + return accounts + } // 🔐 会话管理(用于管理员登录等) async setSession(sessionId, sessionData, ttl = 86400) { diff --git a/src/routes/admin.js b/src/routes/admin.js index 676f419a..ac11ab29 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -13,9 +13,11 @@ const CostCalculator = require('../utils/costCalculator') const pricingService = require('../services/pricingService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const axios = require('axios') +const crypto = require('crypto') const fs = require('fs') const path = require('path') const config = require('../../config/config') +const { v4: uuidv4 } = require('uuid') const router = express.Router() @@ -4298,4 +4300,399 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => { } }) +// 🤖 OpenAI 账户管理 + +// 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 参数 +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('/openai-accounts/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('/openai-accounts/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' + } + } + + if (sessionData.proxy) { + const { type, host, port, username, password } = sessionData.proxy + if (type === 'http' || type === 'https') { + axiosConfig.proxy = { + host, + port: parseInt(port), + auth: username && password ? { username, password } : undefined + } + } + } + + // 交换 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('/openai-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await redis.getAllOpenAIAccounts() + + logger.info(`获取 OpenAI 账户列表: ${accounts.length} 个账户`) + + return res.json({ + success: true, + data: accounts + }) + } catch (error) { + logger.error('获取 OpenAI 账户列表失败:', error) + return res.status(500).json({ + success: false, + message: '获取账户列表失败', + error: error.message + }) + } +}) + +// 创建 OpenAI 账户 +router.post('/openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + openaiOauth, + accountInfo, + proxy, + accountType, + groupId, + dedicatedApiKeys, + rateLimitDuration, + priority + } = req.body + + if (!name) { + return res.status(400).json({ + success: false, + message: '账户名称不能为空' + }) + } + const id = uuidv4() + // 创建账户数据 + const accountData = { + id, + name, + description: description || '', + platform: 'openai', + accountType: accountType || 'shared', + groupId: groupId || null, + dedicatedApiKeys: dedicatedApiKeys || [], + priority: priority || 50, + rateLimitDuration: rateLimitDuration || 60, + enabled: true, + idToken: claudeAccountService._encryptSensitiveData(openaiOauth.idToken), + accessToken: claudeAccountService._encryptSensitiveData(openaiOauth.accessToken), + refreshToken: claudeAccountService._encryptSensitiveData(openaiOauth.refreshToken), + accountId: accountInfo?.accountId || '', + expiresAt: (Math.floor(Date.now() / 1000) + openaiOauth.expires_in) * 1000, + chatgptUserId: accountInfo?.chatgptUserId || '', + organizationId: accountInfo?.organizationId || '', + organizationRole: accountInfo?.organizationRole || '', + organizationTitle: accountInfo?.organizationTitle || '', + planType: accountInfo?.planType || '', + email: claudeAccountService._encryptSensitiveData(accountInfo?.email || ''), + emailVerified: accountInfo?.emailVerified || false, + isActive: true, + status: 'active', + lastRefresh: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + // 存储代理配置(如果提供) + if (proxy?.enabled) { + accountData.proxy = { + type: proxy.type, + host: proxy.host, + port: proxy.port, + username: proxy.username || null, + password: proxy.password || null + } + } + + // 保存到 Redis + const accountId = await redis.setOpenAiAccount(id, accountData) + + logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${accountId})`) + + return res.json({ + success: true, + data: { + id: accountId, + ...accountData + } + }) + } catch (error) { + logger.error('创建 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '创建账户失败', + error: error.message + }) + } +}) + +// 更新 OpenAI 账户 +router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => + //TODO: + res.json({ + success: true + }) +) + +// 删除 OpenAI 账户 +router.delete('/openai-accounts/:id', 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: '账户不存在' + }) + } + + await redis.deleteOpenAiAccount(id) + + logger.success(`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id})`) + + return res.json({ + success: true, + message: '账户删除成功' + }) + } catch (error) { + logger.error('删除 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '删除账户失败', + error: error.message + }) + } +}) + +// 切换 OpenAI 账户状态 +router.put('/openai-accounts/: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 + }) + } +}) + module.exports = router diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 20cdca13..87416f79 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -319,7 +319,6 @@ async function handleLoadCodeAssist(req, res) { requestedModel ) const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId) - logger.info(`accessToken: ${accessToken}`) const { metadata, cloudaicompanionProject } = req.body diff --git a/web/admin-spa/index.html b/web/admin-spa/index.html index 789db5a2..27732008 100644 --- a/web/admin-spa/index.html +++ b/web/admin-spa/index.html @@ -1,26 +1,29 @@ + Claude Relay Service - 管理后台 - + - + - + +
+ \ No newline at end of file diff --git a/web/admin-spa/src/assets/styles/components.css b/web/admin-spa/src/assets/styles/components.css index bb6368ab..36532d32 100644 --- a/web/admin-spa/src/assets/styles/components.css +++ b/web/admin-spa/src/assets/styles/components.css @@ -481,3 +481,10 @@ 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } + +.fa-openai { + width: 16px; + height: 16px; + background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNjQwIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIHY3LjAuMCBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIENvcHlyaWdodCAyMDI1IEZvbnRpY29ucywgSW5jLi0tPjxwYXRoIGQ9Ik0yNjAuNCAyNDkuOHYtNDguNmMwLTQuMSAxLjUtNy4yIDUuMS05LjJsOTcuOC01Ni4zYzEzLjMtNy43IDI5LjItMTEuMyA0NS42LTExLjMgNjEuNCAwIDEwMC40IDQ3LjYgMTAwLjQgOTguMyAwIDMuNiAwIDcuNy0uNSAxMS44bC0xMDEuNS01OS40Yy02LjEtMy42LTEyLjMtMy42LTE4LjQgMGwtMTI4LjUgNzQuN3ptMjI4LjMgMTg5LjRWMzIzYzAtNy4yLTMuMS0xMi4zLTkuMi0xNS45TDM1MSAyMzIuNGw0Mi0yNC4xYzMuNi0yIDYuNy0yIDEwLjIgMGw5Ny44IDU2LjRjMjguMiAxNi40IDQ3LjEgNTEuMiA0Ny4xIDg1IDAgMzguOS0yMyA3NC44LTU5LjQgODkuNnpNMjMwLjIgMzM2LjhsLTQyLTI0LjZjLTMuNi0yLTUuMS01LjEtNS4xLTkuMlYxOTAuNGMwLTU0LjggNDItOTYuMyA5OC44LTk2LjMgMjEuNSAwIDQxLjUgNy4yIDU4LjQgMjBsLTEwMC45IDU4LjRjLTYuMSAzLjYtOS4yIDguNy05LjIgMTUuOXYxNDguNXptOTAuNCA1Mi4ybC02MC4yLTMzLjh2LTcxLjdsNjAuMi0zMy44IDYwLjIgMzMuOHY3MS43TDMyMC42IDM4OXptMzguNyAxNTUuN2MtMjEuNSAwLTQxLjUtNy4yLTU4LjQtMjBsMTAwLjktNTguNGM2LjEtMy42IDkuMi04LjcgOS4yLTE1LjlWMzAxLjlsNDIuNSAyNC42YzMuNiAyIDUuMSA1LjEgNS4xIDkuMnYxMTIuNmMwIDU0LjgtNDIuNSA5Ni4zLTk5LjMgOTYuM3pNMjM3LjggNDMwLjVsLTk3LjctNTYuM0MxMTEuOSAzNTcuOCA5MyAzMjMgOTMgMjg5LjJjMC0zOS40IDIzLjYtNzQuOCA1OS45LTg5LjZ2MTE2LjdjMCA3LjIgMy4xIDEyLjMgOS4yIDE1LjlsMTI4IDc0LjItNDIgMjQuMWMtMy42IDItNi43IDItMTAuMiAwem0tNS42IDg0Yy01Ny45IDAtMTAwLjQtNDMuNS0xMDAuNC05Ny4zIDAtNC4xLjUtOC4yIDEtMTIuM2wxMDAuOSA1OC40YzYuMSAzLjYgMTIuMyAzLjYgMTguNCAwbDEyOC41LTc0LjJ2NDguNmMwIDQuMS0xLjUgNy4yLTUuMSA5LjJsLTk3LjggNTYuM2MtMTMuMyA3LjctMjkuMiAxMS4zLTQ1LjYgMTEuM3ptMTI3IDYwLjljNjIgMCAxMTMuNy00NCAxMjUuNC0xMDIuNCA1Ny4zLTE0LjkgOTQuMi02OC42IDk0LjItMTIzLjQgMC0zNS44LTE1LjQtNzAuNy00My05NS43IDIuNi0xMC44IDQuMS0yMS41IDQuMS0zMi4zIDAtNzMuMi01OS40LTEyOC0xMjgtMTI4LTEzLjggMC0yNy4xIDItNDAuNCA2LjctMjMtMjIuNS01NC44LTM2LjktODkuNi0zNi45LTYyIDAtMTEzLjcgNDQtMTI1LjQgMTAyLjQtNTcuMyAxNC44LTk0LjIgNjguNi05NC4yIDEyMy40IDAgMzUuOCAxNS40IDcwLjcgNDMgOTUuNy0yLjYgMTAuOC00LjEgMjEuNS00LjEgMzIuMyAwIDczLjIgNTkuNCAxMjggMTI4IDEyOCAxMy44IDAgMjcuMS0yIDQwLjQtNi43IDIzIDIyLjUgNTQuOCAzNi45IDg5LjYgMzYuOXoiLz48L3N2Zz4=) + no-repeat center/100%; +} diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 68906886..52146ceb 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -77,6 +77,10 @@ Gemini +