Merge branch 'main' into um-5

This commit is contained in:
Feng Yue
2025-08-25 17:19:24 +08:00
74 changed files with 9466 additions and 1901 deletions

View File

@@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes')
const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook')
// Import middleware
@@ -243,6 +244,7 @@ class Application {
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', openaiRoutes)
this.app.use('/azure', azureOpenaiRoutes)
this.app.use('/admin/webhook', webhookRoutes)
// 🏠 根路径重定向到新版管理界面

View File

@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
const bedrockAccountService = require('../services/bedrockAccountService')
const geminiAccountService = require('../services/geminiAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
const accountGroupService = require('../services/accountGroupService')
const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth')
@@ -13,13 +14,13 @@ const oauthHelper = require('../utils/oauthHelper')
const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const webhookNotifier = require('../utils/webhookNotifier')
const axios = require('axios')
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const config = require('../../config/config')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const router = express.Router()
@@ -621,6 +622,170 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
}
})
// 批量编辑API Keys
router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
try {
const { keyIds, updates } = req.body
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'keyIds must be a non-empty array'
})
}
if (!updates || typeof updates !== 'object') {
return res.status(400).json({
error: 'Invalid input',
message: 'updates must be an object'
})
}
logger.info(
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
)
logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`)
const results = {
successCount: 0,
failedCount: 0,
errors: []
}
// 处理每个API Key
for (const keyId of keyIds) {
try {
// 获取当前API Key信息
const currentKey = await redis.getApiKey(keyId)
if (!currentKey || Object.keys(currentKey).length === 0) {
results.failedCount++
results.errors.push(`API key ${keyId} not found`)
continue
}
// 构建最终更新数据
const finalUpdates = {}
// 处理普通字段
if (updates.name) {
finalUpdates.name = updates.name
}
if (updates.tokenLimit !== undefined) {
finalUpdates.tokenLimit = updates.tokenLimit
}
if (updates.concurrencyLimit !== undefined) {
finalUpdates.concurrencyLimit = updates.concurrencyLimit
}
if (updates.rateLimitWindow !== undefined) {
finalUpdates.rateLimitWindow = updates.rateLimitWindow
}
if (updates.rateLimitRequests !== undefined) {
finalUpdates.rateLimitRequests = updates.rateLimitRequests
}
if (updates.dailyCostLimit !== undefined) {
finalUpdates.dailyCostLimit = updates.dailyCostLimit
}
if (updates.permissions !== undefined) {
finalUpdates.permissions = updates.permissions
}
if (updates.isActive !== undefined) {
finalUpdates.isActive = updates.isActive
}
if (updates.monthlyLimit !== undefined) {
finalUpdates.monthlyLimit = updates.monthlyLimit
}
if (updates.priority !== undefined) {
finalUpdates.priority = updates.priority
}
if (updates.enabled !== undefined) {
finalUpdates.enabled = updates.enabled
}
// 处理账户绑定
if (updates.claudeAccountId !== undefined) {
finalUpdates.claudeAccountId = updates.claudeAccountId
}
if (updates.claudeConsoleAccountId !== undefined) {
finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId
}
if (updates.geminiAccountId !== undefined) {
finalUpdates.geminiAccountId = updates.geminiAccountId
}
if (updates.openaiAccountId !== undefined) {
finalUpdates.openaiAccountId = updates.openaiAccountId
}
if (updates.bedrockAccountId !== undefined) {
finalUpdates.bedrockAccountId = updates.bedrockAccountId
}
// 处理标签操作
if (updates.tags !== undefined) {
if (updates.tagOperation) {
const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : []
const operationTags = updates.tags
switch (updates.tagOperation) {
case 'replace': {
finalUpdates.tags = operationTags
break
}
case 'add': {
const newTags = [...currentTags]
operationTags.forEach((tag) => {
if (!newTags.includes(tag)) {
newTags.push(tag)
}
})
finalUpdates.tags = newTags
break
}
case 'remove': {
finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag))
break
}
}
} else {
// 如果没有指定操作类型,默认为替换
finalUpdates.tags = updates.tags
}
}
// 执行更新
await apiKeyService.updateApiKey(keyId, finalUpdates)
results.successCount++
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
} catch (error) {
results.failedCount++
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
logger.error(`❌ Batch edit failed for key ${keyId}:`, error)
}
}
// 记录批量编辑结果
if (results.successCount > 0) {
logger.success(
`🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed`
)
} else {
logger.warn(
`⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed`
)
}
return res.json({
success: true,
message: `批量编辑完成`,
data: results
})
} catch (error) {
logger.error('❌ Failed to batch edit API keys:', error)
return res.status(500).json({
error: 'Batch edit failed',
message: error.message
})
}
})
// 更新API Key
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
@@ -799,7 +964,105 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
})
// 删除API Key
// 批量删除API Keys必须在 :keyId 路由之前定义)
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
try {
const { keyIds } = req.body
// 调试信息
logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`)
logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`)
// 参数验证
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
logger.warn(
`🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}`
)
return res.status(400).json({
error: 'Invalid request',
message: 'keyIds 必须是一个非空数组'
})
}
if (keyIds.length > 100) {
return res.status(400).json({
error: 'Too many keys',
message: '每次最多只能删除100个API Keys'
})
}
// 验证keyIds格式
const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string')
if (invalidKeys.length > 0) {
return res.status(400).json({
error: 'Invalid key IDs',
message: '包含无效的API Key ID'
})
}
logger.info(
`🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}`
)
const results = {
successCount: 0,
failedCount: 0,
errors: []
}
// 逐个删除,记录成功和失败情况
for (const keyId of keyIds) {
try {
// 检查API Key是否存在
const apiKey = await redis.getApiKey(keyId)
if (!apiKey || Object.keys(apiKey).length === 0) {
results.failedCount++
results.errors.push({ keyId, error: 'API Key 不存在' })
continue
}
// 执行删除
await apiKeyService.deleteApiKey(keyId)
results.successCount++
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
} catch (error) {
results.failedCount++
results.errors.push({
keyId,
error: error.message || '删除失败'
})
logger.error(`❌ Batch delete failed for key ${keyId}:`, error)
}
}
// 记录批量删除结果
if (results.successCount > 0) {
logger.success(
`🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed`
)
} else {
logger.warn(
`⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed`
)
}
return res.json({
success: true,
message: `批量删除完成`,
data: results
})
} catch (error) {
logger.error('❌ Failed to batch delete API keys:', error)
return res.status(500).json({
error: 'Batch delete failed',
message: error.message
})
}
})
// 删除单个API Key必须在批量删除路由之后定义
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
@@ -1268,6 +1531,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth,
proxy,
accountType,
platform = 'claude',
priority,
groupId
} = req.body
@@ -1305,6 +1569,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth,
proxy,
accountType: accountType || 'shared', // 默认为共享类型
platform,
priority: priority || 50 // 默认优先级为50
})
@@ -1498,6 +1763,19 @@ router.put(
const newSchedulable = !account.schedulable
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || account.claudeAiOauth?.email || 'Claude Account',
platform: 'claude-oauth',
status: 'disabled',
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
)
@@ -1768,6 +2046,19 @@ router.put(
const newSchedulable = !account.schedulable
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'disabled',
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
)
@@ -2042,6 +2333,19 @@ router.put(
.json({ error: 'Failed to toggle schedulable status', message: updateResult.error })
}
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: accountResult.data.id,
accountName: accountResult.data.name || 'Bedrock Account',
platform: 'bedrock',
status: 'disabled',
errorCode: 'BEDROCK_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
)
@@ -2079,7 +2383,7 @@ router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req,
// 生成 Gemini OAuth 授权 URL
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { state } = req.body
const { state, proxy } = req.body // 接收代理配置
// 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode'
@@ -2093,13 +2397,14 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req,
redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri)
// 创建 OAuth 会话,包含 codeVerifier
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState
await redis.setOAuthSession(sessionId, {
state: authState,
type: 'gemini',
redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
createdAt: new Date().toISOString()
})
@@ -2143,7 +2448,7 @@ router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req,
// 交换 Gemini 授权码
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId } = req.body
const { code, sessionId, proxy: requestProxy } = req.body
if (!code) {
return res.status(400).json({ error: 'Authorization code is required' })
@@ -2151,21 +2456,40 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
let redirectUri = 'https://codeassist.google.com/authcode'
let codeVerifier = null
let proxyConfig = null
// 如果提供了 sessionId从 OAuth 会话中获取信息
if (sessionId) {
const sessionData = await redis.getOAuthSession(sessionId)
if (sessionData) {
const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData
const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
} = sessionData
redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
logger.info(
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}`
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
)
}
}
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier)
// 如果请求体中直接提供了代理配置,优先使用它
if (requestProxy) {
proxyConfig = requestProxy
logger.info(
`Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}`
)
}
const tokens = await geminiAccountService.exchangeCodeForTokens(
code,
redirectUri,
codeVerifier,
proxyConfig // 传递代理配置
)
// 清理 OAuth 会话
if (sessionId) {
@@ -2393,6 +2717,19 @@ router.put(
const updatedAccount = await geminiAccountService.getAccount(accountId)
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
// 如果账号被禁用发送webhook通知
if (!actualSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.accountName || 'Gemini Account',
platform: 'gemini',
status: 'disabled',
errorCode: 'GEMINI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}`
)
@@ -4575,19 +4912,10 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
}
}
if (sessionData.proxy) {
const { type, host, port, username, password } = sessionData.proxy
if (type === 'socks5') {
// SOCKS5 代理
const auth = username && password ? `${username}:${password}@` : ''
const socksUrl = `socks5://${auth}${host}:${port}`
axiosConfig.httpsAgent = new SocksProxyAgent(socksUrl)
} else if (type === 'http' || type === 'https') {
// HTTP/HTTPS 代理
const auth = username && password ? `${username}:${password}@` : ''
const proxyUrl = `${type}://${auth}${host}:${port}`
axiosConfig.httpsAgent = new HttpsProxyAgent(proxyUrl)
}
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
}
// 交换 authorization code 获取 tokens
@@ -4963,6 +5291,23 @@ router.put(
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,
@@ -4979,4 +5324,308 @@ router.put(
}
)
// 🌐 Azure OpenAI 账户管理
// 获取所有 Azure OpenAI 账户
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await azureOpenaiAccountService.getAllAccounts()
res.json({
success: true,
data: accounts
})
} catch (error) {
logger.error('Failed to fetch Azure OpenAI accounts:', error)
res.status(500).json({
success: false,
message: 'Failed to fetch accounts',
error: error.message
})
}
})
// 创建 Azure OpenAI 账户
router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
accountType,
azureEndpoint,
apiVersion,
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
priority,
isActive,
schedulable
} = req.body
// 验证必填字段
if (!name) {
return res.status(400).json({
success: false,
message: 'Account name is required'
})
}
if (!azureEndpoint) {
return res.status(400).json({
success: false,
message: 'Azure endpoint is required'
})
}
if (!apiKey) {
return res.status(400).json({
success: false,
message: 'API key is required'
})
}
if (!deploymentName) {
return res.status(400).json({
success: false,
message: 'Deployment name is required'
})
}
// 验证 Azure endpoint 格式
if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) {
return res.status(400).json({
success: false,
message:
'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com'
})
}
// 测试连接
try {
const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${apiVersion || '2024-02-01'}`
await axios.get(testUrl, {
headers: {
'api-key': apiKey
},
timeout: 5000
})
} catch (testError) {
if (testError.response?.status === 404) {
logger.warn('Azure OpenAI deployment not found, but continuing with account creation')
} else if (testError.response?.status === 401) {
return res.status(400).json({
success: false,
message: 'Invalid API key or unauthorized access'
})
}
}
const account = await azureOpenaiAccountService.createAccount({
name,
description,
accountType: accountType || 'shared',
azureEndpoint,
apiVersion: apiVersion || '2024-02-01',
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
priority: priority || 50,
isActive: isActive !== false,
schedulable: schedulable !== false
})
res.json({
success: true,
data: account,
message: 'Azure OpenAI account created successfully'
})
} catch (error) {
logger.error('Failed to create Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to create account',
error: error.message
})
}
})
// 更新 Azure OpenAI 账户
router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
const account = await azureOpenaiAccountService.updateAccount(id, updates)
res.json({
success: true,
data: account,
message: 'Azure OpenAI account updated successfully'
})
} catch (error) {
logger.error('Failed to update Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to update account',
error: error.message
})
}
})
// 删除 Azure OpenAI 账户
router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await azureOpenaiAccountService.deleteAccount(id)
res.json({
success: true,
message: 'Azure OpenAI account deleted successfully'
})
} catch (error) {
logger.error('Failed to delete Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to delete account',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户状态
router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await azureOpenaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
const newStatus = account.isActive === 'true' ? 'false' : 'true'
await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus })
res.json({
success: true,
message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`,
isActive: newStatus === 'true'
})
} catch (error) {
logger.error('Failed to toggle Azure OpenAI account status:', error)
res.status(500).json({
success: false,
message: 'Failed to toggle account status',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户调度状态
router.put(
'/azure-openai-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await azureOpenaiAccountService.getAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Azure OpenAI Account',
platform: 'azure-openai',
status: 'disabled',
errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({
success: true,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 Azure OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
}
)
// 健康检查单个 Azure OpenAI 账户
router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const healthResult = await azureOpenaiAccountService.healthCheckAccount(id)
res.json({
success: true,
data: healthResult
})
} catch (error) {
logger.error('Failed to perform health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform health check',
error: error.message
})
}
})
// 批量健康检查所有 Azure OpenAI 账户
router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => {
try {
const healthResults = await azureOpenaiAccountService.performHealthChecks()
res.json({
success: true,
data: healthResults
})
} catch (error) {
logger.error('Failed to perform batch health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform batch health check',
error: error.message
})
}
})
// 迁移 API Keys 以支持 Azure OpenAI
router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
try {
const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport()
res.json({
success: true,
message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support`
})
} catch (error) {
logger.error('Failed to migrate API keys:', error)
res.status(500).json({
success: false,
message: 'Failed to migrate API keys',
error: error.message
})
}
})
module.exports = router

View File

@@ -671,4 +671,103 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
}
})
// 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
try {
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
}
})
}
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 选择可用的Claude账户
const requestedModel = req.body.model
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
let response
if (accountType === 'claude-official') {
// 使用官方Claude账号转发count_tokens请求
response = await claudeRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
} else if (accountType === 'claude-console') {
// 使用Console Claude账号转发count_tokens请求
response = await claudeConsoleRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
accountId,
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
} else {
// Bedrock不支持count_tokens
return res.status(501).json({
error: {
type: 'not_supported',
message: 'Token counting is not supported for Bedrock accounts'
}
})
}
// 直接返回响应不记录token使用量
res.status(response.statusCode)
// 设置响应头
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
Object.keys(response.headers).forEach((key) => {
if (!skipHeaders.includes(key.toLowerCase())) {
res.setHeader(key, response.headers[key])
}
})
// 尝试解析并返回JSON响应
try {
const jsonData = JSON.parse(response.body)
res.json(jsonData)
} catch (parseError) {
res.send(response.body)
}
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
} catch (error) {
logger.error('❌ Token count error:', error)
res.status(500).json({
error: {
type: 'server_error',
message: 'Failed to count tokens'
}
})
}
})
module.exports = router

View File

@@ -0,0 +1,318 @@
const express = require('express')
const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
const azureOpenaiRelayService = require('../services/azureOpenaiRelayService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
// 支持的模型列表 - 基于真实的 Azure OpenAI 模型
const ALLOWED_MODELS = {
CHAT_MODELS: [
'gpt-4',
'gpt-4-turbo',
'gpt-4o',
'gpt-4o-mini',
'gpt-35-turbo',
'gpt-35-turbo-16k'
],
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
}
const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS]
// Azure OpenAI 稳定 API 版本
// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用
// 原子使用统计报告器
class AtomicUsageReporter {
constructor() {
this.reportedUsage = new Set()
this.pendingReports = new Map()
}
async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId) {
if (this.reportedUsage.has(requestId)) {
logger.debug(`Usage already reported for request: ${requestId}`)
return false
}
// 防止并发重复报告
if (this.pendingReports.has(requestId)) {
return this.pendingReports.get(requestId)
}
const reportPromise = this._performReport(
requestId,
usageData,
apiKeyId,
modelToRecord,
accountId
)
this.pendingReports.set(requestId, reportPromise)
try {
const result = await reportPromise
this.reportedUsage.add(requestId)
return result
} finally {
this.pendingReports.delete(requestId)
// 清理过期的已报告记录
setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理
}
}
async _performReport(requestId, usageData, apiKeyId, modelToRecord, accountId) {
try {
const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0
const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0
const cacheCreateTokens =
usageData.prompt_tokens_details?.cache_creation_tokens ||
usageData.input_tokens_details?.cache_creation_tokens ||
0
const cacheReadTokens =
usageData.prompt_tokens_details?.cached_tokens ||
usageData.input_tokens_details?.cached_tokens ||
0
await apiKeyService.recordUsage(
apiKeyId,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
accountId
)
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
try {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
if (accountId) {
await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens)
}
} catch (acctErr) {
logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`)
}
logger.info(
`📊 Azure OpenAI Usage recorded for ${requestId}: ` +
`model=${modelToRecord}, ` +
`input=${inputTokens}, output=${outputTokens}, ` +
`cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}`
)
return true
} catch (error) {
logger.error('Failed to report Azure OpenAI usage:', error)
return false
}
}
}
const usageReporter = new AtomicUsageReporter()
// 健康检查
router.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
service: 'azure-openai-relay',
timestamp: new Date().toISOString()
})
})
// 获取可用模型列表(兼容 OpenAI API
router.get('/models', authenticateApiKey, async (req, res) => {
try {
const models = ALL_ALLOWED_MODELS.map((model) => ({
id: `azure/${model}`,
object: 'model',
created: Date.now(),
owned_by: 'azure-openai'
}))
res.json({
object: 'list',
data: models
})
} catch (error) {
logger.error('Error fetching Azure OpenAI models:', error)
res.status(500).json({ error: { message: 'Failed to fetch models' } })
}
})
// 处理聊天完成请求
router.post('/chat/completions', authenticateApiKey, async (req, res) => {
const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
const sessionId = req.sessionId || req.headers['x-session-id'] || null
logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, {
apiKeyId: req.apiKey?.id,
sessionId,
model: req.body.model,
stream: req.body.stream || false,
messages: req.body.messages?.length || 0
})
try {
// 获取绑定的 Azure OpenAI 账户
let account = null
if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
}
}
// 如果没有绑定账户或账户不可用,选择一个可用账户
if (!account || account.isActive !== 'true') {
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
}
// 发送请求到 Azure OpenAI
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
account,
requestBody: req.body,
headers: req.headers,
isStream: req.body.stream || false,
endpoint: 'chat/completions'
})
// 处理流式响应
if (req.body.stream) {
await azureOpenaiRelayService.handleStreamResponse(response, res, {
onEnd: async ({ usageData, actualModel }) => {
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
},
onError: (error) => {
logger.error(`Stream error for request ${requestId}:`, error)
}
})
} else {
// 处理非流式响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response,
res
)
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
}
} catch (error) {
logger.error(`Azure OpenAI request failed ${requestId}:`, error)
if (!res.headersSent) {
const statusCode = error.response?.status || 500
const errorMessage =
error.response?.data?.error?.message || error.message || 'Internal server error'
res.status(statusCode).json({
error: {
message: errorMessage,
type: 'azure_openai_error',
code: error.code || 'unknown'
}
})
}
}
})
// 处理嵌入请求
router.post('/embeddings', authenticateApiKey, async (req, res) => {
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
const sessionId = req.sessionId || req.headers['x-session-id'] || null
logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, {
apiKeyId: req.apiKey?.id,
sessionId,
model: req.body.model,
input: Array.isArray(req.body.input) ? req.body.input.length : 1
})
try {
// 获取绑定的 Azure OpenAI 账户
let account = null
if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
}
}
// 如果没有绑定账户或账户不可用,选择一个可用账户
if (!account || account.isActive !== 'true') {
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
}
// 发送请求到 Azure OpenAI
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
account,
requestBody: req.body,
headers: req.headers,
isStream: false,
endpoint: 'embeddings'
})
// 处理响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response,
res
)
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(requestId, usageData, req.apiKey.id, modelToRecord, account.id)
}
} catch (error) {
logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error)
if (!res.headersSent) {
const statusCode = error.response?.status || 500
const errorMessage =
error.response?.data?.error?.message || error.message || 'Internal server error'
res.status(statusCode).json({
error: {
message: errorMessage,
type: 'azure_openai_error',
code: error.code || 'unknown'
}
})
}
}
})
// 获取使用统计
router.get('/usage', authenticateApiKey, async (req, res) => {
try {
const { start_date, end_date } = req.query
const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date)
res.json({
object: 'usage',
data: usage
})
} catch (error) {
logger.error('Error fetching Azure OpenAI usage:', error)
res.status(500).json({ error: { message: 'Failed to fetch usage data' } })
}
})
module.exports = router

View File

@@ -541,12 +541,24 @@ async function handleGenerateContent(req, res) {
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id // 使用 API Key ID 作为 session ID
req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
// 记录使用统计
@@ -573,7 +585,16 @@ async function handleGenerateContent(req, res) {
res.json(response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in generateContent endpoint (${version})`, { error: error.message })
// 打印详细的错误信息
logger.error(`Error in generateContent endpoint (${version})`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestMethod: error.config?.method,
stack: error.stack
})
res.status(500).json({
error: {
message: error.message || 'Internal server error',
@@ -654,13 +675,25 @@ async function handleStreamGenerateContent(req, res) {
})
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal // 传递中止信号
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
// 设置 SSE 响应头
@@ -756,7 +789,16 @@ async function handleStreamGenerateContent(req, res) {
})
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message })
// 打印详细的错误信息
logger.error(`Error in streamGenerateContent endpoint (${version})`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestMethod: error.config?.method,
stack: error.stack
})
if (!res.headersSent) {
res.status(500).json({

View File

@@ -8,30 +8,11 @@ const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
// 创建代理 Agent
// 创建代理 Agent(使用统一的代理工具)
function createProxyAgent(proxy) {
if (!proxy) {
return null
}
try {
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(proxyUrl)
}
} catch (error) {
logger.warn('Failed to create proxy agent:', error)
}
return null
return ProxyHelper.createProxyAgent(proxy)
}
// 使用统一调度器选择 OpenAI 账户
@@ -80,7 +61,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
accessToken,
accountId: result.accountId,
accountName: account.name,
proxy
proxy,
account
}
} catch (error) {
logger.error('Failed to get OpenAI auth token:', error)
@@ -146,11 +128,13 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
}
// 使用调度器选择账户
const { accessToken, accountId, proxy } = await getOpenAIAuthToken(
apiKeyData,
sessionId,
requestedModel
)
const {
accessToken,
accountId,
accountName: _accountName,
proxy,
account
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
const incoming = req.headers || {}
@@ -165,7 +149,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 覆盖或新增必要头部
headers['authorization'] = `Bearer ${accessToken}`
headers['chatgpt-account-id'] = accountId
headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId
headers['host'] = 'chatgpt.com'
headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
headers['content-type'] = 'application/json'
@@ -184,7 +168,9 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 如果有代理,添加代理配置
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info('Using proxy for OpenAI request')
logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`)
} else {
logger.debug('🌐 No proxy configured for OpenAI request')
}
// 根据 stream 参数决定请求类型

View File

@@ -1,18 +1,125 @@
const express = require('express')
const router = express.Router()
const logger = require('../utils/logger')
const webhookNotifier = require('../utils/webhookNotifier')
const webhookService = require('../services/webhookService')
const webhookConfigService = require('../services/webhookConfigService')
const { authenticateAdmin } = require('../middleware/auth')
// 获取webhook配置
router.get('/config', authenticateAdmin, async (req, res) => {
try {
const config = await webhookConfigService.getConfig()
res.json({
success: true,
config
})
} catch (error) {
logger.error('获取webhook配置失败:', error)
res.status(500).json({
error: 'Internal server error',
message: '获取webhook配置失败'
})
}
})
// 保存webhook配置
router.post('/config', authenticateAdmin, async (req, res) => {
try {
const config = await webhookConfigService.saveConfig(req.body)
res.json({
success: true,
message: 'Webhook配置已保存',
config
})
} catch (error) {
logger.error('保存webhook配置失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '保存webhook配置失败'
})
}
})
// 添加webhook平台
router.post('/platforms', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.addPlatform(req.body)
res.json({
success: true,
message: 'Webhook平台已添加',
platform
})
} catch (error) {
logger.error('添加webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '添加webhook平台失败'
})
}
})
// 更新webhook平台
router.put('/platforms/:id', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.updatePlatform(req.params.id, req.body)
res.json({
success: true,
message: 'Webhook平台已更新',
platform
})
} catch (error) {
logger.error('更新webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '更新webhook平台失败'
})
}
})
// 删除webhook平台
router.delete('/platforms/:id', authenticateAdmin, async (req, res) => {
try {
await webhookConfigService.deletePlatform(req.params.id)
res.json({
success: true,
message: 'Webhook平台已删除'
})
} catch (error) {
logger.error('删除webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '删除webhook平台失败'
})
}
})
// 切换webhook平台启用状态
router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.togglePlatform(req.params.id)
res.json({
success: true,
message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`,
platform
})
} catch (error) {
logger.error('切换webhook平台状态失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '切换webhook平台状态失败'
})
}
})
// 测试Webhook连通性
router.post('/test', authenticateAdmin, async (req, res) => {
try {
const { url } = req.body
const { url, type = 'custom', secret, enableSign } = req.body
if (!url) {
return res.status(400).json({
error: 'Missing webhook URL',
message: 'Please provide a webhook URL to test'
message: '请提供webhook URL'
})
}
@@ -22,99 +129,144 @@ router.post('/test', authenticateAdmin, async (req, res) => {
} catch (urlError) {
return res.status(400).json({
error: 'Invalid URL format',
message: 'Please provide a valid webhook URL'
message: '请提供有效的webhook URL'
})
}
logger.info(`🧪 Testing webhook URL: ${url}`)
logger.info(`🧪 测试webhook: ${type} - ${url}`)
const result = await webhookNotifier.testWebhook(url)
// 创建临时平台配置
const platform = {
type,
url,
secret,
enableSign,
enabled: true,
timeout: 10000
}
const result = await webhookService.testWebhook(platform)
if (result.success) {
logger.info(`✅ Webhook test successful for: ${url}`)
logger.info(`✅ Webhook测试成功: ${url}`)
res.json({
success: true,
message: 'Webhook test successful',
message: 'Webhook测试成功',
url
})
} else {
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
res.status(400).json({
success: false,
message: 'Webhook test failed',
message: 'Webhook测试失败',
url,
error: result.error
})
}
} catch (error) {
logger.error('❌ Webhook test error:', error)
logger.error('❌ Webhook测试错误:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to test webhook'
message: '测试webhook失败'
})
}
})
// 手动触发账号异常通知(用于测试)
// 手动触发测试通知
router.post('/test-notification', authenticateAdmin, async (req, res) => {
try {
const {
type = 'test',
accountId = 'test-account-id',
accountName = 'Test Account',
accountName = '测试账号',
platform = 'claude-oauth',
status = 'error',
errorCode = 'TEST_ERROR',
reason = 'Manual test notification'
status = 'test',
errorCode = 'TEST_NOTIFICATION',
reason = '手动测试通知',
message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作'
} = req.body
logger.info(`🧪 Sending test notification for account: ${accountName}`)
logger.info(`🧪 发送测试通知: ${type}`)
await webhookNotifier.sendAccountAnomalyNotification({
// 先检查webhook配置
const config = await webhookConfigService.getConfig()
logger.debug(
`Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}`
)
if (!config.enabled) {
return res.status(400).json({
success: false,
message: 'Webhook通知未启用请先在设置中启用通知功能'
})
}
const enabledPlatforms = await webhookConfigService.getEnabledPlatforms()
logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`)
if (enabledPlatforms.length === 0) {
return res.status(400).json({
success: false,
message: '没有启用的通知平台,请先添加并启用至少一个通知平台'
})
}
const testData = {
accountId,
accountName,
platform,
status,
errorCode,
reason
})
reason,
message,
timestamp: new Date().toISOString()
}
logger.info(`✅ Test notification sent successfully`)
const result = await webhookService.sendNotification(type, testData)
// 如果没有返回结果,说明可能是配置问题
if (!result) {
return res.status(400).json({
success: false,
message: 'Webhook服务未返回结果请检查配置和日志',
enabledPlatforms: enabledPlatforms.length
})
}
// 如果没有成功和失败的记录
if (result.succeeded === 0 && result.failed === 0) {
return res.status(400).json({
success: false,
message: '没有发送任何通知,请检查通知类型配置',
result,
enabledPlatforms: enabledPlatforms.length
})
}
if (result.failed > 0) {
logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`)
return res.json({
success: true,
message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`,
data: testData,
result
})
}
logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`)
res.json({
success: true,
message: 'Test notification sent successfully',
data: {
accountId,
accountName,
platform,
status,
errorCode,
reason
}
message: `测试通知已成功发送到 ${result.succeeded} 个平台`,
data: testData,
result
})
} catch (error) {
logger.error('❌ Failed to send test notification:', error)
logger.error('❌ 发送测试通知失败:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to send test notification'
message: `发送测试通知失败: ${error.message}`
})
}
})
// 获取Webhook配置信息
router.get('/config', authenticateAdmin, (req, res) => {
const config = require('../../config/config')
res.json({
success: true,
config: {
enabled: config.webhook?.enabled !== false,
urls: config.webhook?.urls || [],
timeout: config.webhook?.timeout || 10000,
retries: config.webhook?.retries || 3,
urlCount: (config.webhook?.urls || []).length
}
})
})
module.exports = router

View File

@@ -20,6 +20,7 @@ class ApiKeyService {
claudeConsoleAccountId = null,
geminiAccountId = null,
openaiAccountId = null,
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true,
@@ -53,6 +54,7 @@ class ApiKeyService {
claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '',
openaiAccountId: openaiAccountId || '',
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
@@ -88,6 +90,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
@@ -190,6 +193,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
@@ -337,6 +341,7 @@ class ApiKeyService {
'claudeConsoleAccountId',
'geminiAccountId',
'openaiAccountId',
'azureOpenaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions',
'expiresAt',

View File

@@ -0,0 +1,479 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const config = require('../../config/config')
const logger = require('../utils/logger')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const IV_LENGTH = 16
// 🚀 安全的加密密钥生成支持动态salt
const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt'
class EncryptionKeyManager {
constructor() {
this.keyCache = new Map()
this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时
}
getKey(version = 'current') {
const cached = this.keyCache.get(version)
if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) {
return cached.key
}
// 生成新密钥
const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
this.keyCache.set(version, {
key,
timestamp: Date.now()
})
logger.debug('🔑 Azure OpenAI encryption key generated/refreshed')
return key
}
// 清理过期密钥
cleanup() {
const now = Date.now()
for (const [version, cached] of this.keyCache.entries()) {
if (now - cached.timestamp > this.keyRotationInterval) {
this.keyCache.delete(version)
}
}
}
}
const encryptionKeyManager = new EncryptionKeyManager()
// 定期清理过期密钥
setInterval(
() => {
encryptionKeyManager.cleanup()
},
60 * 60 * 1000
) // 每小时清理一次
// 生成加密密钥 - 使用安全的密钥管理器
function generateEncryptionKey() {
return encryptionKeyManager.getKey()
}
// Azure OpenAI 账户键前缀
const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
// 加密函数
function encrypt(text) {
if (!text) {
return ''
}
const key = generateEncryptionKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密函数 - 移除缓存以提高安全性
function decrypt(text) {
if (!text) {
return ''
}
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
const ivHex = text.substring(0, 32)
const encryptedHex = text.substring(33) // 跳过冒号
if (ivHex.length !== 32 || !encryptedHex) {
throw new Error('Invalid encrypted text format')
}
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
const result = decrypted.toString()
return result
} catch (error) {
logger.error('Azure OpenAI decryption error:', error.message)
return ''
}
}
// 创建账户
async function createAccount(accountData) {
const accountId = uuidv4()
const now = new Date().toISOString()
const account = {
id: accountId,
name: accountData.name,
description: accountData.description || '',
accountType: accountData.accountType || 'shared',
groupId: accountData.groupId || null,
priority: accountData.priority || 50,
// Azure OpenAI 特有字段
azureEndpoint: accountData.azureEndpoint || '',
apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本
deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称
apiKey: encrypt(accountData.apiKey || ''),
// 支持的模型
supportedModels: JSON.stringify(
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
),
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false',
createdAt: now,
updatedAt: now
}
// 代理配置
if (accountData.proxy) {
account.proxy =
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy)
}
const client = redisClient.getClientSafe()
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
}
logger.info(`Created Azure OpenAI account: ${accountId}`)
return account
}
// 获取账户
async function getAccount(accountId) {
const client = redisClient.getClientSafe()
const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
return null
}
// 解密敏感数据(仅用于内部处理,不返回给前端)
if (accountData.apiKey) {
accountData.apiKey = decrypt(accountData.apiKey)
}
// 解析代理配置
if (accountData.proxy && typeof accountData.proxy === 'string') {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 解析支持的模型
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
}
}
return accountData
}
// 更新账户
async function updateAccount(accountId, updates) {
const existingAccount = await getAccount(accountId)
if (!existingAccount) {
throw new Error('Account not found')
}
updates.updatedAt = new Date().toISOString()
// 加密敏感数据
if (updates.apiKey) {
updates.apiKey = encrypt(updates.apiKey)
}
// 处理代理配置
if (updates.proxy) {
updates.proxy =
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
}
// 处理支持的模型
if (updates.supportedModels) {
updates.supportedModels =
typeof updates.supportedModels === 'string'
? updates.supportedModels
: JSON.stringify(updates.supportedModels)
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
if (updates.accountType === 'shared') {
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
} else {
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
}
}
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
logger.info(`Updated Azure OpenAI account: ${accountId}`)
// 合并更新后的账户数据
const updatedAccount = { ...existingAccount, ...updates }
// 返回时解析代理配置
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') {
try {
updatedAccount.proxy = JSON.parse(updatedAccount.proxy)
} catch (e) {
updatedAccount.proxy = null
}
}
return updatedAccount
}
// 删除账户
async function deleteAccount(accountId) {
const client = redisClient.getClientSafe()
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
// 从Redis中删除账户数据
await client.del(accountKey)
// 从共享账户集合中移除
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
logger.info(`Deleted Azure OpenAI account: ${accountId}`)
return true
}
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
if (!keys || keys.length === 0) {
return []
}
const accounts = []
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
// 不返回敏感数据给前端
delete accountData.apiKey
// 解析代理配置
if (accountData.proxy && typeof accountData.proxy === 'string') {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 解析支持的模型
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
}
}
accounts.push(accountData)
}
}
return accounts
}
// 获取共享账户
async function getSharedAccounts() {
const client = redisClient.getClientSafe()
const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY)
if (!accountIds || accountIds.length === 0) {
return []
}
const accounts = []
for (const accountId of accountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true') {
accounts.push(account)
}
}
return accounts
}
// 选择可用账户
async function selectAvailableAccount(sessionId = null) {
// 如果有会话ID尝试获取之前分配的账户
if (sessionId) {
const client = redisClient.getClientSafe()
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
const accountId = await client.get(mappingKey)
if (accountId) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && account.schedulable === 'true') {
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
return account
}
}
}
// 获取所有共享账户
const sharedAccounts = await getSharedAccounts()
// 过滤出可用的账户
const availableAccounts = sharedAccounts.filter(
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
)
if (availableAccounts.length === 0) {
throw new Error('No available Azure OpenAI accounts')
}
// 按优先级排序并选择
availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50))
const selectedAccount = availableAccounts[0]
// 如果有会话ID保存映射关系
if (sessionId && selectedAccount) {
const client = redisClient.getClientSafe()
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期
}
logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`)
return selectedAccount
}
// 更新账户使用量
async function updateAccountUsage(accountId, tokens) {
const client = redisClient.getClientSafe()
const now = new Date().toISOString()
// 使用 HINCRBY 原子操作更新使用量
await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens)
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now)
logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`)
}
// 健康检查单个账户
async function healthCheckAccount(accountId) {
try {
const account = await getAccount(accountId)
if (!account) {
return { id: accountId, status: 'error', message: 'Account not found' }
}
// 简单检查配置是否完整
if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) {
return {
id: accountId,
status: 'error',
message: 'Incomplete configuration'
}
}
// 可以在这里添加实际的API调用测试
// 暂时返回成功状态
return {
id: accountId,
status: 'healthy',
message: 'Account is configured correctly'
}
} catch (error) {
logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error)
return {
id: accountId,
status: 'error',
message: error.message
}
}
}
// 批量健康检查
async function performHealthChecks() {
const accounts = await getAllAccounts()
const results = []
for (const account of accounts) {
const result = await healthCheckAccount(account.id)
results.push(result)
}
return results
}
// 切换账户的可调度状态
async function toggleSchedulable(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const newSchedulable = account.schedulable === 'true' ? 'false' : 'true'
await updateAccount(accountId, { schedulable: newSchedulable })
return {
id: accountId,
schedulable: newSchedulable === 'true'
}
}
// 迁移 API Keys 以支持 Azure OpenAI
async function migrateApiKeysForAzureSupport() {
const client = redisClient.getClientSafe()
const apiKeyIds = await client.smembers('api_keys')
let migratedCount = 0
for (const keyId of apiKeyIds) {
const keyData = await client.hgetall(`api_key:${keyId}`)
if (keyData && !keyData.azureOpenaiAccountId) {
// 添加 Azure OpenAI 账户ID字段初始为空
await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '')
migratedCount++
}
}
logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`)
return migratedCount
}
module.exports = {
createAccount,
getAccount,
updateAccount,
deleteAccount,
getAllAccounts,
getSharedAccounts,
selectAvailableAccount,
updateAccountUsage,
healthCheckAccount,
performHealthChecks,
toggleSchedulable,
migrateApiKeysForAzureSupport,
encrypt,
decrypt
}

View File

@@ -0,0 +1,529 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
// 转换模型名称(去掉 azure/ 前缀)
function normalizeModelName(model) {
if (model && model.startsWith('azure/')) {
return model.replace('azure/', '')
}
return model
}
// 处理 Azure OpenAI 请求
async function handleAzureOpenAIRequest({
account,
requestBody,
headers: _headers = {}, // 前缀下划线表示未使用
isStream = false,
endpoint = 'chat/completions'
}) {
// 声明变量在函数顶部,确保在 catch 块中也能访问
let requestUrl = ''
let proxyAgent = null
let deploymentName = ''
try {
// 构建 Azure OpenAI 请求 URL
const baseUrl = account.azureEndpoint
deploymentName = account.deploymentName || 'default'
// Azure Responses API requires preview versions; fall back appropriately
const apiVersion =
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
if (endpoint === 'chat/completions') {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
} else if (endpoint === 'responses') {
requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}`
} else {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}`
}
// 准备请求头
const requestHeaders = {
'Content-Type': 'application/json',
'api-key': account.apiKey
}
// 移除不需要的头部
delete requestHeaders['anthropic-version']
delete requestHeaders['x-api-key']
delete requestHeaders['host']
// 处理请求体
const processedBody = { ...requestBody }
// 标准化模型名称
if (processedBody.model) {
processedBody.model = normalizeModelName(processedBody.model)
} else {
processedBody.model = 'gpt-4'
}
// 使用统一的代理创建工具
proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
// 配置请求选项
const axiosConfig = {
method: 'POST',
url: requestUrl,
headers: requestHeaders,
data: processedBody,
timeout: 600000, // 10 minutes for Azure OpenAI
validateStatus: () => true,
// 添加连接保活选项
keepAlive: true,
maxRedirects: 5,
// 防止socket hang up
socketKeepAlive: true
}
// 如果有代理,添加代理配置
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
// 为代理添加额外的keep-alive设置
if (proxyAgent.options) {
proxyAgent.options.keepAlive = true
proxyAgent.options.keepAliveMsecs = 1000
}
logger.debug(
`Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}`
)
}
// 流式请求特殊处理
if (isStream) {
axiosConfig.responseType = 'stream'
requestHeaders.accept = 'text/event-stream'
} else {
requestHeaders.accept = 'application/json'
}
logger.debug(`Making Azure OpenAI request`, {
requestUrl,
method: 'POST',
endpoint,
deploymentName,
apiVersion,
hasProxy: !!proxyAgent,
proxyInfo: ProxyHelper.maskProxyInfo(account.proxy),
isStream,
requestBodySize: JSON.stringify(processedBody).length
})
logger.debug('Azure OpenAI request headers', {
'content-type': requestHeaders['Content-Type'],
'user-agent': requestHeaders['user-agent'] || 'not-set',
customHeaders: Object.keys(requestHeaders).filter(
(key) => !['Content-Type', 'user-agent'].includes(key)
)
})
logger.debug('Azure OpenAI request body', {
model: processedBody.model,
messages: processedBody.messages?.length || 0,
otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key))
})
const requestStartTime = Date.now()
logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`)
// 发送请求
const response = await axios(axiosConfig)
const requestDuration = Date.now() - requestStartTime
logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`)
logger.debug(`Azure OpenAI response received`, {
status: response.status,
statusText: response.statusText,
duration: `${requestDuration}ms`,
responseHeaders: Object.keys(response.headers || {}),
hasData: !!response.data,
contentType: response.headers?.['content-type'] || 'unknown'
})
return response
} catch (error) {
const errorDetails = {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: requestUrl || 'unknown',
endpoint,
deploymentName: deploymentName || account?.deploymentName || 'unknown',
hasProxy: !!proxyAgent,
proxyType: account?.proxy?.type || 'none',
isTimeout: error.code === 'ECONNABORTED',
isNetworkError: !error.response,
stack: error.stack
}
// 特殊错误类型的详细日志
if (error.code === 'ENOTFOUND') {
logger.error('DNS Resolution Failed for Azure OpenAI', {
...errorDetails,
hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown',
suggestion: 'Check if Azure endpoint URL is correct and accessible'
})
} else if (error.code === 'ECONNREFUSED') {
logger.error('Connection Refused by Azure OpenAI', {
...errorDetails,
suggestion: 'Check if proxy settings are correct or Azure service is accessible'
})
} else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) {
logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', {
...errorDetails,
suggestion:
'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.'
})
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
logger.error('🚨 Azure OpenAI Request Timeout', {
...errorDetails,
timeoutMs: 600000,
suggestion:
'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.'
})
} else if (
error.code === 'CERT_AUTHORITY_INVALID' ||
error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
) {
logger.error('SSL Certificate Error for Azure OpenAI', {
...errorDetails,
suggestion: 'SSL certificate validation failed - check proxy SSL settings'
})
} else if (error.response?.status === 401) {
logger.error('Azure OpenAI Authentication Failed', {
...errorDetails,
suggestion: 'Check if Azure OpenAI API key is valid and not expired'
})
} else if (error.response?.status === 404) {
logger.error('Azure OpenAI Deployment Not Found', {
...errorDetails,
suggestion: 'Check if deployment name and Azure endpoint are correct'
})
} else {
logger.error('Azure OpenAI Request Failed', errorDetails)
}
throw error
}
}
// 安全的流管理器
class StreamManager {
constructor() {
this.activeStreams = new Set()
this.cleanupCallbacks = new Map()
}
registerStream(streamId, cleanup) {
this.activeStreams.add(streamId)
this.cleanupCallbacks.set(streamId, cleanup)
}
cleanup(streamId) {
if (this.activeStreams.has(streamId)) {
try {
const cleanup = this.cleanupCallbacks.get(streamId)
if (cleanup) {
cleanup()
}
} catch (error) {
logger.warn(`Stream cleanup error for ${streamId}:`, error.message)
} finally {
this.activeStreams.delete(streamId)
this.cleanupCallbacks.delete(streamId)
}
}
}
getActiveStreamCount() {
return this.activeStreams.size
}
}
const streamManager = new StreamManager()
// SSE 缓冲区大小限制
const MAX_BUFFER_SIZE = 64 * 1024 // 64KB
const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小
// 处理流式响应
function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
const { onData, onEnd, onError } = options
const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
logger.info(`Starting Azure OpenAI stream handling`, {
streamId,
upstreamStatus: upstreamResponse.status,
upstreamHeaders: Object.keys(upstreamResponse.headers || {}),
clientRemoteAddress: clientResponse.req?.connection?.remoteAddress,
hasOnData: !!onData,
hasOnEnd: !!onEnd,
hasOnError: !!onError
})
return new Promise((resolve, reject) => {
let buffer = ''
let usageData = null
let actualModel = null
let hasEnded = false
let eventCount = 0
const maxEvents = 10000 // 最大事件数量限制
// 设置响应头
clientResponse.setHeader('Content-Type', 'text/event-stream')
clientResponse.setHeader('Cache-Control', 'no-cache')
clientResponse.setHeader('Connection', 'keep-alive')
clientResponse.setHeader('X-Accel-Buffering', 'no')
// 透传某些头部
const passThroughHeaders = [
'x-request-id',
'x-ratelimit-remaining-requests',
'x-ratelimit-remaining-tokens'
]
passThroughHeaders.forEach((header) => {
const value = upstreamResponse.headers[header]
if (value) {
clientResponse.setHeader(header, value)
}
})
// 立即刷新响应头
if (typeof clientResponse.flushHeaders === 'function') {
clientResponse.flushHeaders()
}
// 解析 SSE 事件以捕获 usage 数据
const parseSSEForUsage = (data) => {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
if (jsonStr.trim() === '[DONE]') {
continue
}
const eventData = JSON.parse(jsonStr)
// 获取模型信息
if (eventData.model) {
actualModel = eventData.model
}
// 获取使用统计Responses API: response.completed -> response.usage
if (eventData.type === 'response.completed' && eventData.response) {
if (eventData.response.model) {
actualModel = eventData.response.model
}
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
}
}
// 兼容 Chat Completions 风格(顶层 usage
if (!usageData && eventData.usage) {
usageData = eventData.usage
logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
}
// 检查是否是完成事件
if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
// 这是最后一个 chunk
}
} catch (e) {
// 忽略解析错误
}
}
}
}
// 注册流清理
const cleanup = () => {
if (!hasEnded) {
hasEnded = true
try {
upstreamResponse.data?.removeAllListeners?.()
upstreamResponse.data?.destroy?.()
if (!clientResponse.headersSent) {
clientResponse.status(502).end()
} else if (!clientResponse.destroyed) {
clientResponse.end()
}
} catch (error) {
logger.warn('Stream cleanup error:', error.message)
}
}
}
streamManager.registerStream(streamId, cleanup)
upstreamResponse.data.on('data', (chunk) => {
try {
if (hasEnded || clientResponse.destroyed) {
return
}
eventCount++
if (eventCount > maxEvents) {
logger.warn(`Stream ${streamId} exceeded max events limit`)
cleanup()
return
}
const chunkStr = chunk.toString()
// 转发数据给客户端
if (!clientResponse.destroyed) {
clientResponse.write(chunk)
}
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制
buffer += chunkStr
// 防止缓冲区过大
if (buffer.length > MAX_BUFFER_SIZE) {
logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
}
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim() && event.length <= MAX_EVENT_SIZE) {
parseSSEForUsage(event)
}
}
}
if (onData) {
onData(chunk, { usageData, actualModel })
}
} catch (error) {
logger.error('Error processing Azure OpenAI stream chunk:', error)
if (!hasEnded) {
cleanup()
reject(error)
}
}
})
upstreamResponse.data.on('end', () => {
if (hasEnded) {
return
}
streamManager.cleanup(streamId)
hasEnded = true
try {
// 处理剩余的 buffer
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
parseSSEForUsage(buffer)
}
if (onEnd) {
onEnd({ usageData, actualModel })
}
if (!clientResponse.destroyed) {
clientResponse.end()
}
resolve({ usageData, actualModel })
} catch (error) {
logger.error('Stream end handling error:', error)
reject(error)
}
})
upstreamResponse.data.on('error', (error) => {
if (hasEnded) {
return
}
streamManager.cleanup(streamId)
hasEnded = true
logger.error('Upstream stream error:', error)
try {
if (onError) {
onError(error)
}
if (!clientResponse.headersSent) {
clientResponse.status(502).json({ error: { message: 'Upstream stream error' } })
} else if (!clientResponse.destroyed) {
clientResponse.end()
}
} catch (cleanupError) {
logger.warn('Error during stream error cleanup:', cleanupError.message)
}
reject(error)
})
// 客户端断开时清理
const clientCleanup = () => {
streamManager.cleanup(streamId)
}
clientResponse.on('close', clientCleanup)
clientResponse.on('aborted', clientCleanup)
clientResponse.on('error', clientCleanup)
})
}
// 处理非流式响应
function handleNonStreamResponse(upstreamResponse, clientResponse) {
try {
// 设置状态码
clientResponse.status(upstreamResponse.status)
// 设置响应头
clientResponse.setHeader('Content-Type', 'application/json')
// 透传某些头部
const passThroughHeaders = [
'x-request-id',
'x-ratelimit-remaining-requests',
'x-ratelimit-remaining-tokens'
]
passThroughHeaders.forEach((header) => {
const value = upstreamResponse.headers[header]
if (value) {
clientResponse.setHeader(header, value)
}
})
// 返回响应数据
const responseData = upstreamResponse.data
clientResponse.json(responseData)
// 提取 usage 数据
const usageData = responseData.usage
const actualModel = responseData.model
return { usageData, actualModel, responseData }
} catch (error) {
logger.error('Error handling Azure OpenAI non-stream response:', error)
throw error
}
}
module.exports = {
handleAzureOpenAIRequest,
handleStreamResponse,
handleNonStreamResponse,
normalizeModelName
}

View File

@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const axios = require('axios')
const redis = require('../models/redis')
const logger = require('../utils/logger')
@@ -55,6 +54,7 @@ class ClaudeAccountService {
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
platform = 'claude',
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
subscriptionInfo = null // 手动设置的订阅信息
@@ -79,7 +79,8 @@ class ClaudeAccountService {
scopes: claudeAiOauth.scopes.join(' '),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared'
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(),
lastUsedAt: '',
@@ -108,7 +109,8 @@ class ClaudeAccountService {
scopes: '',
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared'
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(),
lastUsedAt: '',
@@ -151,6 +153,7 @@ class ClaudeAccountService {
isActive,
proxy,
accountType,
platform,
priority,
status: accountData.status,
createdAt: accountData.createdAt,
@@ -444,7 +447,7 @@ class ClaudeAccountService {
errorMessage: account.errorMessage,
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
priority: parseInt(account.priority) || 50, // 兼容旧数据默认优先级50
platform: 'claude-oauth', // 添加平台标识,用于前端区分
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
@@ -857,29 +860,19 @@ class ClaudeAccountService {
}
}
// 🌐 创建代理agent
// 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else if (proxyConfig) {
logger.debug('🌐 Failed to create proxy agent for Claude')
} else {
logger.debug('🌐 No proxy configured for Claude request')
}
try {
const proxy = JSON.parse(proxyConfig)
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
logger.warn('⚠️ Invalid proxy configuration:', error)
}
return null
return proxyAgent
}
// 🔐 加密敏感数据
@@ -1094,6 +1087,22 @@ class ClaudeAccountService {
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`)
}
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude-oauth',
status: 'error',
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)

View File

@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
@@ -367,6 +366,22 @@ class ClaudeConsoleAccountService {
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
logger.warn(
`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`
)
@@ -480,29 +495,19 @@ class ClaudeConsoleAccountService {
}
}
// 🌐 创建代理agent
// 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else if (proxyConfig) {
logger.debug('🌐 Failed to create proxy agent for Claude Console')
} else {
logger.debug('🌐 No proxy configured for Claude Console request')
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
logger.warn('⚠️ Invalid proxy configuration:', error)
}
return null
return proxyAgent
}
// 🔐 加密敏感数据

View File

@@ -84,7 +84,16 @@ class ClaudeConsoleRelayService {
// 构建完整的API URL
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
let apiEndpoint
if (options.customPath) {
// 如果指定了自定义路径(如 count_tokens使用它
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
apiEndpoint = `${baseUrl}${options.customPath}`
} else {
// 默认使用 messages 端点
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
}
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)

View File

@@ -2,8 +2,7 @@ const https = require('https')
const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper')
@@ -496,32 +495,28 @@ class ClaudeRelayService {
}
}
// 🌐 获取代理Agent
// 🌐 获取代理Agent(使用统一的代理工具)
async _getProxyAgent(accountId) {
try {
const accountData = await claudeAccountService.getAllAccounts()
const account = accountData.find((acc) => acc.id === accountId)
if (!account || !account.proxy) {
logger.debug('🌐 No proxy configured for Claude account')
return null
}
const { proxy } = account
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
)
}
return proxyAgent
} catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error)
return null
}
return null
}
// 🔧 过滤客户端请求头
@@ -596,10 +591,18 @@ class ClaudeRelayService {
}
return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens
let requestPath = url.pathname
if (requestOptions.customPath) {
const baseUrl = new URL('https://api.anthropic.com')
const customUrl = new URL(requestOptions.customPath, baseUrl)
requestPath = customUrl.pathname
}
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
path: requestPath,
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -5,6 +5,7 @@ const config = require('../../config/config')
const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper')
const {
logRefreshStart,
logRefreshSuccess,
@@ -109,11 +110,32 @@ setInterval(
10 * 60 * 1000
)
// 创建 OAuth2 客户端
function createOAuth2Client(redirectUri = null) {
// 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
// 如果没有提供 redirectUri使用默认值
const uri = redirectUri || 'http://localhost:45462'
return new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, uri)
// 准备客户端选项
const clientOptions = {
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
redirectUri: uri
}
// 如果有代理配置,设置 transporterOptions
if (proxyConfig) {
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 通过 transporterOptions 传递代理配置给底层的 Gaxios
clientOptions.transporterOptions = {
agent: proxyAgent,
httpsAgent: proxyAgent
}
logger.debug('Created OAuth2Client with proxy configuration')
}
}
return new OAuth2Client(clientOptions)
}
// 生成授权 URL (支持 PKCE)
@@ -196,11 +218,25 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
}
}
// 交换授权码获取 tokens (支持 PKCE)
async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) {
const oAuth2Client = createOAuth2Client(redirectUri)
// 交换授权码获取 tokens (支持 PKCE 和代理)
async function exchangeCodeForTokens(
code,
redirectUri = null,
codeVerifier = null,
proxyConfig = null
) {
try {
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
if (proxyConfig) {
logger.info(
`🌐 Using proxy for Gemini token exchange: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini token exchange')
}
const tokenParams = {
code,
redirect_uri: redirectUri
@@ -228,8 +264,9 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken) {
const oAuth2Client = createOAuth2Client()
async function refreshAccessToken(refreshToken, proxyConfig = null) {
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig)
try {
// 设置 refresh_token
@@ -237,6 +274,14 @@ async function refreshAccessToken(refreshToken) {
refresh_token: refreshToken
})
if (proxyConfig) {
logger.info(
`🔄 Using proxy for Gemini token refresh: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🔄 No proxy configured for Gemini token refresh')
}
// 调用 refreshAccessToken 获取新的 tokens
const response = await oAuth2Client.refreshAccessToken()
const { credentials } = response
@@ -261,7 +306,9 @@ async function refreshAccessToken(refreshToken) {
logger.error('Error refreshing access token:', {
message: error.message,
code: error.code,
response: error.response?.data
response: error.response?.data,
hasProxy: !!proxyConfig,
proxy: proxyConfig ? ProxyHelper.maskProxyInfo(proxyConfig) : 'No proxy'
})
throw new Error(`Failed to refresh access token: ${error.message}`)
}
@@ -786,7 +833,8 @@ async function refreshAccountToken(accountId) {
logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`)
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
const newTokens = await refreshAccessToken(account.refreshToken)
// 传入账户的代理配置
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
// 更新账户信息
const updates = {
@@ -1169,7 +1217,8 @@ async function generateContent(
requestData,
userPromptId,
projectId = null,
sessionId = null
sessionId = null,
proxyConfig = null
) {
const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1206,6 +1255,17 @@ async function generateContent(
timeout: 60000 // 生成内容可能需要更长时间
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini generateContent')
}
const response = await axios(axiosConfig)
logger.info('✅ generateContent API调用成功')
@@ -1219,7 +1279,8 @@ async function generateContentStream(
userPromptId,
projectId = null,
sessionId = null,
signal = null
signal = null,
proxyConfig = null
) {
const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1260,6 +1321,17 @@ async function generateContentStream(
timeout: 60000
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini streamGenerateContent')
}
// 如果提供了中止信号,添加到配置中
if (signal) {
axiosConfig.signal = signal

View File

@@ -1,6 +1,5 @@
const axios = require('axios')
const { HttpsProxyAgent } = require('https-proxy-agent')
const { SocksProxyAgent } = require('socks-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const config = require('../../config/config')
const apiKeyService = require('./apiKeyService')
@@ -9,34 +8,9 @@ const apiKeyService = require('./apiKeyService')
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'
const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp'
// 创建代理 agent
// 创建代理 agent(使用统一的代理工具)
function createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
if (!proxy.type || !proxy.host || !proxy.port) {
return null
}
const proxyUrl =
proxy.username && proxy.password
? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}`
: `${proxy.type}://${proxy.host}:${proxy.port}`
if (proxy.type === 'socks5') {
return new SocksProxyAgent(proxyUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
return new HttpsProxyAgent(proxyUrl)
}
} catch (error) {
logger.error('Error creating proxy agent:', error)
}
return null
return ProxyHelper.createProxyAgent(proxyConfig)
}
// 转换 OpenAI 消息格式到 Gemini 格式
@@ -306,7 +280,9 @@ async function sendGeminiRequest({
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.debug('Using proxy for Gemini request')
logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
} else {
logger.debug('🌐 No proxy configured for Gemini API request')
}
// 添加 AbortController 信号支持
@@ -412,6 +388,11 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini models request')
}
try {
@@ -508,7 +489,11 @@ async function countTokens({
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.debug('Using proxy for Gemini countTokens request')
logger.info(
`🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini countTokens request')
}
try {

View File

@@ -2,8 +2,7 @@ const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const axios = require('axios')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const config = require('../../config/config')
const logger = require('../utils/logger')
// const { maskToken } = require('../utils/tokenMask')
@@ -133,18 +132,14 @@ async function refreshAccessToken(refreshToken, proxy = null) {
}
// 配置代理(如果有)
if (proxy && proxy.host && proxy.port) {
if (proxy.type === 'socks5') {
const proxyAuth =
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}`
requestOptions.httpsAgent = new SocksProxyAgent(socksProxy)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const proxyAuth =
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}`
requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy)
}
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxyAgent) {
requestOptions.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
)
} else {
logger.debug('🌐 No proxy configured for OpenAI token refresh')
}
// 发送请求

View File

@@ -0,0 +1,272 @@
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { v4: uuidv4 } = require('uuid')
class WebhookConfigService {
constructor() {
this.KEY_PREFIX = 'webhook_config'
this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default`
}
/**
* 获取webhook配置
*/
async getConfig() {
try {
const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY)
if (!configStr) {
// 返回默认配置
return this.getDefaultConfig()
}
return JSON.parse(configStr)
} catch (error) {
logger.error('获取webhook配置失败:', error)
return this.getDefaultConfig()
}
}
/**
* 保存webhook配置
*/
async saveConfig(config) {
try {
// 验证配置
this.validateConfig(config)
// 添加更新时间
config.updatedAt = new Date().toISOString()
await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config))
logger.info('✅ Webhook配置已保存')
return config
} catch (error) {
logger.error('保存webhook配置失败:', error)
throw error
}
}
/**
* 验证配置
*/
validateConfig(config) {
if (!config || typeof config !== 'object') {
throw new Error('无效的配置格式')
}
// 验证平台配置
if (config.platforms) {
const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
for (const platform of config.platforms) {
if (!validPlatforms.includes(platform.type)) {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
// 验证平台特定的配置
this.validatePlatformConfig(platform)
}
}
}
/**
* 验证平台特定配置
*/
validatePlatformConfig(platform) {
switch (platform.type) {
case 'wechat_work':
// 企业微信不需要额外配置
break
case 'dingtalk':
// 钉钉可能需要secret用于签名
if (platform.enableSign && !platform.secret) {
throw new Error('钉钉启用签名时必须提供secret')
}
break
case 'feishu':
// 飞书可能需要签名
if (platform.enableSign && !platform.secret) {
throw new Error('飞书启用签名时必须提供secret')
}
break
case 'slack':
// Slack webhook URL通常包含token
if (!platform.url.includes('hooks.slack.com')) {
logger.warn('⚠️ Slack webhook URL格式可能不正确')
}
break
case 'discord':
// Discord webhook URL格式检查
if (!platform.url.includes('discord.com/api/webhooks')) {
logger.warn('⚠️ Discord webhook URL格式可能不正确')
}
break
case 'custom':
// 自定义webhook用户自行负责格式
break
}
}
/**
* 验证URL格式
*/
isValidUrl(url) {
try {
new URL(url)
return true
} catch {
return false
}
}
/**
* 获取默认配置
*/
getDefaultConfig() {
return {
enabled: false,
platforms: [],
notificationTypes: {
accountAnomaly: true, // 账号异常
quotaWarning: true, // 配额警告
systemError: true, // 系统错误
securityAlert: true, // 安全警报
test: true // 测试通知
},
retrySettings: {
maxRetries: 3,
retryDelay: 1000, // 毫秒
timeout: 10000 // 毫秒
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
}
/**
* 添加webhook平台
*/
async addPlatform(platform) {
try {
const config = await this.getConfig()
// 生成唯一ID
platform.id = platform.id || uuidv4()
platform.enabled = platform.enabled !== false
platform.createdAt = new Date().toISOString()
// 验证平台配置
this.validatePlatformConfig(platform)
// 添加到配置
config.platforms = config.platforms || []
config.platforms.push(platform)
await this.saveConfig(config)
return platform
} catch (error) {
logger.error('添加webhook平台失败:', error)
throw error
}
}
/**
* 更新webhook平台
*/
async updatePlatform(platformId, updates) {
try {
const config = await this.getConfig()
const index = config.platforms.findIndex((p) => p.id === platformId)
if (index === -1) {
throw new Error('找不到指定的webhook平台')
}
// 合并更新
config.platforms[index] = {
...config.platforms[index],
...updates,
updatedAt: new Date().toISOString()
}
// 验证更新后的配置
this.validatePlatformConfig(config.platforms[index])
await this.saveConfig(config)
return config.platforms[index]
} catch (error) {
logger.error('更新webhook平台失败:', error)
throw error
}
}
/**
* 删除webhook平台
*/
async deletePlatform(platformId) {
try {
const config = await this.getConfig()
config.platforms = config.platforms.filter((p) => p.id !== platformId)
await this.saveConfig(config)
logger.info(`✅ 已删除webhook平台: ${platformId}`)
return true
} catch (error) {
logger.error('删除webhook平台失败:', error)
throw error
}
}
/**
* 切换webhook平台启用状态
*/
async togglePlatform(platformId) {
try {
const config = await this.getConfig()
const platform = config.platforms.find((p) => p.id === platformId)
if (!platform) {
throw new Error('找不到指定的webhook平台')
}
platform.enabled = !platform.enabled
platform.updatedAt = new Date().toISOString()
await this.saveConfig(config)
logger.info(`✅ Webhook平台 ${platformId}${platform.enabled ? '启用' : '禁用'}`)
return platform
} catch (error) {
logger.error('切换webhook平台状态失败:', error)
throw error
}
}
/**
* 获取启用的平台列表
*/
async getEnabledPlatforms() {
try {
const config = await this.getConfig()
if (!config.enabled || !config.platforms) {
return []
}
return config.platforms.filter((p) => p.enabled)
} catch (error) {
logger.error('获取启用的webhook平台失败:', error)
return []
}
}
}
module.exports = new WebhookConfigService()

View File

@@ -0,0 +1,495 @@
const axios = require('axios')
const crypto = require('crypto')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
class WebhookService {
constructor() {
this.platformHandlers = {
wechat_work: this.sendToWechatWork.bind(this),
dingtalk: this.sendToDingTalk.bind(this),
feishu: this.sendToFeishu.bind(this),
slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.bind(this),
custom: this.sendToCustom.bind(this)
}
}
/**
* 发送通知到所有启用的平台
*/
async sendNotification(type, data) {
try {
const config = await webhookConfigService.getConfig()
// 检查是否启用webhook
if (!config.enabled) {
logger.debug('Webhook通知已禁用')
return
}
// 检查通知类型是否启用test类型始终允许发送
if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) {
logger.debug(`通知类型 ${type} 已禁用`)
return
}
// 获取启用的平台
const enabledPlatforms = await webhookConfigService.getEnabledPlatforms()
if (enabledPlatforms.length === 0) {
logger.debug('没有启用的webhook平台')
return
}
logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`)
// 并发发送到所有平台
const promises = enabledPlatforms.map((platform) =>
this.sendToPlatform(platform, type, data, config.retrySettings)
)
const results = await Promise.allSettled(promises)
// 记录结果
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`)
} else {
logger.info(`✅ 所有webhook通知发送成功`)
}
return { succeeded, failed }
} catch (error) {
logger.error('发送webhook通知失败:', error)
throw error
}
}
/**
* 发送到特定平台
*/
async sendToPlatform(platform, type, data, retrySettings) {
try {
const handler = this.platformHandlers[platform.type]
if (!handler) {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
// 使用平台特定的处理器
await this.retryWithBackoff(
() => handler(platform, type, data),
retrySettings?.maxRetries || 3,
retrySettings?.retryDelay || 1000
)
logger.info(`✅ 成功发送到 ${platform.name || platform.type}`)
} catch (error) {
logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message)
throw error
}
}
/**
* 企业微信webhook
*/
async sendToWechatWork(platform, type, data) {
const content = this.formatMessageForWechatWork(type, data)
const payload = {
msgtype: 'markdown',
markdown: {
content
}
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 钉钉webhook
*/
async sendToDingTalk(platform, type, data) {
const content = this.formatMessageForDingTalk(type, data)
let { url } = platform
const payload = {
msgtype: 'markdown',
markdown: {
title: this.getNotificationTitle(type),
text: content
}
}
// 如果启用签名
if (platform.enableSign && platform.secret) {
const timestamp = Date.now()
const sign = this.generateDingTalkSign(platform.secret, timestamp)
url = `${url}&timestamp=${timestamp}&sign=${encodeURIComponent(sign)}`
}
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
}
/**
* 飞书webhook
*/
async sendToFeishu(platform, type, data) {
const content = this.formatMessageForFeishu(type, data)
const payload = {
msg_type: 'interactive',
card: {
elements: [
{
tag: 'markdown',
content
}
],
header: {
title: {
tag: 'plain_text',
content: this.getNotificationTitle(type)
},
template: this.getFeishuCardColor(type)
}
}
}
// 如果启用签名
if (platform.enableSign && platform.secret) {
const timestamp = Math.floor(Date.now() / 1000)
const sign = this.generateFeishuSign(platform.secret, timestamp)
payload.timestamp = timestamp.toString()
payload.sign = sign
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Slack webhook
*/
async sendToSlack(platform, type, data) {
const text = this.formatMessageForSlack(type, data)
const payload = {
text,
username: 'Claude Relay Service',
icon_emoji: this.getSlackEmoji(type)
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Discord webhook
*/
async sendToDiscord(platform, type, data) {
const embed = this.formatMessageForDiscord(type, data)
const payload = {
username: 'Claude Relay Service',
embeds: [embed]
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 自定义webhook
*/
async sendToCustom(platform, type, data) {
// 使用通用格式
const payload = {
type,
service: 'claude-relay-service',
timestamp: new Date().toISOString(),
data
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 发送HTTP请求
*/
async sendHttpRequest(url, payload, timeout) {
const response = await axios.post(url, payload, {
timeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/2.0'
}
})
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.data
}
/**
* 重试机制
*/
async retryWithBackoff(fn, maxRetries, baseDelay) {
let lastError
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
if (i < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, i) // 指数退避
logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError
}
/**
* 生成钉钉签名
*/
generateDingTalkSign(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', secret)
hmac.update(stringToSign)
return hmac.digest('base64')
}
/**
* 生成飞书签名
*/
generateFeishuSign(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
hmac.update('')
return hmac.digest('base64')
}
/**
* 格式化企业微信消息
*/
formatMessageForWechatWork(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
return (
`## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` +
`> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
)
}
/**
* 格式化钉钉消息
*/
formatMessageForDingTalk(type, data) {
const details = this.formatNotificationDetails(data)
return (
`#### 服务: Claude Relay Service\n` +
`#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
)
}
/**
* 格式化飞书消息
*/
formatMessageForFeishu(type, data) {
return this.formatNotificationDetails(data)
}
/**
* 格式化Slack消息
*/
formatMessageForSlack(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
return `*${title}*\n${details}`
}
/**
* 格式化Discord消息
*/
formatMessageForDiscord(type, data) {
const title = this.getNotificationTitle(type)
const color = this.getDiscordColor(type)
const fields = this.formatNotificationFields(data)
return {
title,
color,
fields,
timestamp: new Date().toISOString(),
footer: {
text: 'Claude Relay Service'
}
}
}
/**
* 获取通知标题
*/
getNotificationTitle(type) {
const titles = {
accountAnomaly: '⚠️ 账号异常通知',
quotaWarning: '📊 配额警告',
systemError: '❌ 系统错误',
securityAlert: '🔒 安全警报',
test: '🧪 测试通知'
}
return titles[type] || '📢 系统通知'
}
/**
* 格式化通知详情
*/
formatNotificationDetails(data) {
const lines = []
if (data.accountName) {
lines.push(`**账号**: ${data.accountName}`)
}
if (data.platform) {
lines.push(`**平台**: ${data.platform}`)
}
if (data.status) {
lines.push(`**状态**: ${data.status}`)
}
if (data.errorCode) {
lines.push(`**错误代码**: ${data.errorCode}`)
}
if (data.reason) {
lines.push(`**原因**: ${data.reason}`)
}
if (data.message) {
lines.push(`**消息**: ${data.message}`)
}
if (data.quota) {
lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`)
}
if (data.usage) {
lines.push(`**使用率**: ${data.usage}%`)
}
return lines.join('\n')
}
/**
* 格式化Discord字段
*/
formatNotificationFields(data) {
const fields = []
if (data.accountName) {
fields.push({ name: '账号', value: data.accountName, inline: true })
}
if (data.platform) {
fields.push({ name: '平台', value: data.platform, inline: true })
}
if (data.status) {
fields.push({ name: '状态', value: data.status, inline: true })
}
if (data.errorCode) {
fields.push({ name: '错误代码', value: data.errorCode, inline: false })
}
if (data.reason) {
fields.push({ name: '原因', value: data.reason, inline: false })
}
if (data.message) {
fields.push({ name: '消息', value: data.message, inline: false })
}
return fields
}
/**
* 获取飞书卡片颜色
*/
getFeishuCardColor(type) {
const colors = {
accountAnomaly: 'orange',
quotaWarning: 'yellow',
systemError: 'red',
securityAlert: 'red',
test: 'blue'
}
return colors[type] || 'blue'
}
/**
* 获取Slack emoji
*/
getSlackEmoji(type) {
const emojis = {
accountAnomaly: ':warning:',
quotaWarning: ':chart_with_downwards_trend:',
systemError: ':x:',
securityAlert: ':lock:',
test: ':test_tube:'
}
return emojis[type] || ':bell:'
}
/**
* 获取Discord颜色
*/
getDiscordColor(type) {
const colors = {
accountAnomaly: 0xff9800, // 橙色
quotaWarning: 0xffeb3b, // 黄色
systemError: 0xf44336, // 红色
securityAlert: 0xf44336, // 红色
test: 0x2196f3 // 蓝色
}
return colors[type] || 0x9e9e9e // 灰色
}
/**
* 测试webhook连接
*/
async testWebhook(platform) {
try {
const testData = {
message: 'Claude Relay Service webhook测试',
timestamp: new Date().toISOString()
}
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
return { success: true }
} catch (error) {
return {
success: false,
error: error.message
}
}
}
}
module.exports = new WebhookService()

View File

@@ -5,7 +5,7 @@ const path = require('path')
const fs = require('fs')
const os = require('os')
// 安全的 JSON 序列化函数,处理循环引用
// 安全的 JSON 序列化函数,处理循环引用和特殊字符
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
const seen = new WeakSet()
// 如果是fullDepth模式增加深度限制
@@ -16,6 +16,28 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
return '[Max Depth Reached]'
}
// 处理字符串值清理可能导致JSON解析错误的特殊字符
if (typeof value === 'string') {
try {
// 移除或转义可能导致JSON解析错误的字符
let cleanValue = value
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符
.replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符
// eslint-disable-next-line no-control-regex
.replace(/\u0000/g, '') // 移除NUL字节
// 如果字符串过长,截断并添加省略号
if (cleanValue.length > 1000) {
cleanValue = `${cleanValue.substring(0, 997)}...`
}
return cleanValue
} catch (error) {
return '[Invalid String Data]'
}
}
if (value !== null && typeof value === 'object') {
if (seen.has(value)) {
return '[Circular Reference]'
@@ -40,7 +62,10 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
} else {
const result = {}
for (const [k, v] of Object.entries(value)) {
result[k] = replacer(k, v, depth + 1)
// 确保键名也是安全的
// eslint-disable-next-line no-control-regex
const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k
result[safeKey] = replacer(safeKey, v, depth + 1)
}
return result
}
@@ -50,9 +75,20 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
}
try {
return JSON.stringify(replacer('', obj))
const processed = replacer('', obj)
return JSON.stringify(processed)
} catch (error) {
return JSON.stringify({ error: 'Failed to serialize object', message: error.message })
// 如果JSON.stringify仍然失败使用更保守的方法
try {
return JSON.stringify({
error: 'Failed to serialize object',
message: error.message,
type: typeof obj,
keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined
})
} catch (finalError) {
return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}'
}
}
}
@@ -60,8 +96,8 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
const createLogFormat = (colorize = false) => {
const formats = [
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] })
winston.format.errors({ stack: true })
// 移除 winston.format.metadata() 来避免自动包装
]
if (colorize) {
@@ -69,7 +105,7 @@ const createLogFormat = (colorize = false) => {
}
formats.push(
winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => {
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => {
const emoji = {
error: '❌',
warn: '⚠️ ',
@@ -80,12 +116,7 @@ const createLogFormat = (colorize = false) => {
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}`
// 添加元数据
if (metadata && Object.keys(metadata).length > 0) {
logMessage += ` | ${safeStringify(metadata)}`
}
// 添加其他属性
// 直接处理额外数据不需要metadata包装
const additionalData = { ...rest }
delete additionalData.level
delete additionalData.message

View File

@@ -4,8 +4,7 @@
*/
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('./proxyHelper')
const axios = require('axios')
const logger = require('./logger')
@@ -125,36 +124,12 @@ function generateSetupTokenParams() {
}
/**
* 创建代理agent
* 创建代理agent(使用统一的代理工具)
* @param {object|null} proxyConfig - 代理配置对象
* @returns {object|null} 代理agent或null
*/
function createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
}
try {
if (proxyConfig.type === 'socks5') {
const auth =
proxyConfig.username && proxyConfig.password
? `${proxyConfig.username}:${proxyConfig.password}@`
: ''
const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') {
const auth =
proxyConfig.username && proxyConfig.password
? `${proxyConfig.username}:${proxyConfig.password}@`
: ''
const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
console.warn('⚠️ Invalid proxy configuration:', error)
}
return null
return ProxyHelper.createProxyAgent(proxyConfig)
}
/**
@@ -182,6 +157,14 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(
`🌐 Using proxy for OAuth token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for OAuth token exchange')
}
logger.debug('🔄 Attempting OAuth token exchange', {
url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length,
@@ -379,6 +362,14 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(
`🌐 Using proxy for Setup Token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Setup Token exchange')
}
logger.debug('🔄 Attempting Setup Token exchange', {
url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length,

212
src/utils/proxyHelper.js Normal file
View File

@@ -0,0 +1,212 @@
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const logger = require('./logger')
const config = require('../../config/config')
/**
* 统一的代理创建工具
* 支持 SOCKS5 和 HTTP/HTTPS 代理,可配置 IPv4/IPv6
*/
class ProxyHelper {
/**
* 创建代理 Agent
* @param {object|string|null} proxyConfig - 代理配置对象或 JSON 字符串
* @param {object} options - 额外选项
* @param {boolean|number} options.useIPv4 - 是否使用 IPv4 (true=IPv4, false=IPv6, undefined=auto)
* @returns {Agent|null} 代理 Agent 实例或 null
*/
static createProxyAgent(proxyConfig, options = {}) {
if (!proxyConfig) {
return null
}
try {
// 解析代理配置
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
// 验证必要字段
if (!proxy.type || !proxy.host || !proxy.port) {
logger.warn('⚠️ Invalid proxy configuration: missing required fields (type, host, port)')
return null
}
// 获取 IPv4/IPv6 配置
const useIPv4 = ProxyHelper._getIPFamilyPreference(options.useIPv4)
// 构建认证信息
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
// 根据代理类型创建 Agent
if (proxy.type === 'socks5') {
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
const socksOptions = {}
// 设置 IP 协议族(如果指定)
if (useIPv4 !== null) {
socksOptions.family = useIPv4 ? 4 : 6
}
return new SocksProxyAgent(socksUrl, socksOptions)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
const httpOptions = {}
// HttpsProxyAgent 支持 family 参数(通过底层的 agent-base
if (useIPv4 !== null) {
httpOptions.family = useIPv4 ? 4 : 6
}
return new HttpsProxyAgent(proxyUrl, httpOptions)
} else {
logger.warn(`⚠️ Unsupported proxy type: ${proxy.type}`)
return null
}
} catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error.message)
return null
}
}
/**
* 获取 IP 协议族偏好设置
* @param {boolean|number|string} preference - 用户偏好设置
* @returns {boolean|null} true=IPv4, false=IPv6, null=auto
* @private
*/
static _getIPFamilyPreference(preference) {
// 如果没有指定偏好,使用配置文件或默认值
if (preference === undefined) {
// 从配置文件读取默认设置,默认使用 IPv4
const defaultUseIPv4 = config.proxy?.useIPv4
if (defaultUseIPv4 !== undefined) {
return defaultUseIPv4
}
// 默认值IPv4兼容性更好
return true
}
// 处理各种输入格式
if (typeof preference === 'boolean') {
return preference
}
if (typeof preference === 'number') {
return preference === 4 ? true : preference === 6 ? false : null
}
if (typeof preference === 'string') {
const lower = preference.toLowerCase()
if (lower === 'ipv4' || lower === '4') {
return true
}
if (lower === 'ipv6' || lower === '6') {
return false
}
if (lower === 'auto' || lower === 'both') {
return null
}
}
// 无法识别的值返回默认IPv4
return true
}
/**
* 验证代理配置
* @param {object|string} proxyConfig - 代理配置
* @returns {boolean} 是否有效
*/
static validateProxyConfig(proxyConfig) {
if (!proxyConfig) {
return false
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
// 检查必要字段
if (!proxy.type || !proxy.host || !proxy.port) {
return false
}
// 检查支持的类型
if (!['socks5', 'http', 'https'].includes(proxy.type)) {
return false
}
// 检查端口范围
const port = parseInt(proxy.port)
if (isNaN(port) || port < 1 || port > 65535) {
return false
}
return true
} catch (error) {
return false
}
}
/**
* 获取代理配置的描述信息
* @param {object|string} proxyConfig - 代理配置
* @returns {string} 代理描述
*/
static getProxyDescription(proxyConfig) {
if (!proxyConfig) {
return 'No proxy'
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
const hasAuth = proxy.username && proxy.password
return `${proxy.type}://${proxy.host}:${proxy.port}${hasAuth ? ' (with auth)' : ''}`
} catch (error) {
return 'Invalid proxy config'
}
}
/**
* 脱敏代理配置信息用于日志记录
* @param {object|string} proxyConfig - 代理配置
* @returns {string} 脱敏后的代理信息
*/
static maskProxyInfo(proxyConfig) {
if (!proxyConfig) {
return 'No proxy'
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
let proxyDesc = `${proxy.type}://${proxy.host}:${proxy.port}`
// 如果有认证信息,进行脱敏处理
if (proxy.username && proxy.password) {
const maskedUsername =
proxy.username.length <= 2
? proxy.username
: proxy.username[0] +
'*'.repeat(Math.max(1, proxy.username.length - 2)) +
proxy.username.slice(-1)
const maskedPassword = '*'.repeat(Math.min(8, proxy.password.length))
proxyDesc += ` (auth: ${maskedUsername}:${maskedPassword})`
}
return proxyDesc
} catch (error) {
return 'Invalid proxy config'
}
}
/**
* 创建代理 Agent兼容旧的函数接口
* @param {object|string|null} proxyConfig - 代理配置
* @param {boolean} useIPv4 - 是否使用 IPv4
* @returns {Agent|null} 代理 Agent 实例或 null
* @deprecated 使用 createProxyAgent 替代
*/
static createProxy(proxyConfig, useIPv4 = true) {
logger.warn('⚠️ ProxyHelper.createProxy is deprecated, use createProxyAgent instead')
return ProxyHelper.createProxyAgent(proxyConfig, { useIPv4 })
}
}
module.exports = ProxyHelper

View File

@@ -1,13 +1,9 @@
const axios = require('axios')
const logger = require('./logger')
const config = require('../../config/config')
const webhookService = require('../services/webhookService')
class WebhookNotifier {
constructor() {
this.webhookUrls = config.webhook?.urls || []
this.timeout = config.webhook?.timeout || 10000
this.retries = config.webhook?.retries || 3
this.enabled = config.webhook?.enabled !== false
// 保留此类用于兼容性实际功能委托给webhookService
}
/**
@@ -22,94 +18,40 @@ class WebhookNotifier {
* @param {string} notification.timestamp - 时间戳
*/
async sendAccountAnomalyNotification(notification) {
if (!this.enabled || this.webhookUrls.length === 0) {
logger.debug('Webhook notification disabled or no URLs configured')
return
}
const payload = {
type: 'account_anomaly',
data: {
try {
// 使用新的webhookService发送通知
await webhookService.sendNotification('accountAnomaly', {
accountId: notification.accountId,
accountName: notification.accountName,
platform: notification.platform,
status: notification.status,
errorCode: notification.errorCode,
errorCode:
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
reason: notification.reason,
timestamp: notification.timestamp || new Date().toISOString(),
service: 'claude-relay-service'
}
}
logger.info(
`📢 Sending account anomaly webhook notification: ${notification.accountName} (${notification.accountId}) - ${notification.status}`
)
const promises = this.webhookUrls.map((url) => this._sendWebhook(url, payload))
try {
await Promise.allSettled(promises)
} catch (error) {
logger.error('Failed to send webhook notifications:', error)
}
}
/**
* 发送Webhook请求
* @param {string} url - Webhook URL
* @param {Object} payload - 请求载荷
*/
async _sendWebhook(url, payload, attempt = 1) {
try {
const response = await axios.post(url, payload, {
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/webhook-notifier'
}
timestamp: notification.timestamp || new Date().toISOString()
})
if (response.status >= 200 && response.status < 300) {
logger.info(`✅ Webhook sent successfully to ${url}`)
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error) {
logger.error(
`❌ Failed to send webhook to ${url} (attempt ${attempt}/${this.retries}):`,
error.message
)
// 重试机制
if (attempt < this.retries) {
const delay = Math.pow(2, attempt - 1) * 1000 // 指数退避
logger.info(`🔄 Retrying webhook to ${url} in ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
return this._sendWebhook(url, payload, attempt + 1)
}
logger.error(`💥 All ${this.retries} webhook attempts failed for ${url}`)
logger.error('Failed to send account anomaly notification:', error)
}
}
/**
* 测试Webhook连通性
* 测试Webhook连通性(兼容旧接口)
* @param {string} url - Webhook URL
* @param {string} type - 平台类型(可选)
*/
async testWebhook(url) {
const testPayload = {
type: 'test',
data: {
message: 'Claude Relay Service webhook test',
timestamp: new Date().toISOString(),
service: 'claude-relay-service'
}
}
async testWebhook(url, type = 'custom') {
try {
await this._sendWebhook(url, testPayload)
return { success: true }
// 创建临时平台配置
const platform = {
type,
url,
enabled: true,
timeout: 10000
}
const result = await webhookService.testWebhook(platform)
return result
} catch (error) {
return { success: false, error: error.message }
}