mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-24 20:52:34 +00:00
Merge branch 'main' into antigravity
This commit is contained in:
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const accountBalanceService = require('../../services/accountBalanceService')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const ensureValidPlatform = (rawPlatform) => {
|
||||
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||
if (!normalized) {
|
||||
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||
}
|
||||
|
||||
const supported = accountBalanceService.getSupportedPlatforms()
|
||||
if (!supported.includes(normalized)) {
|
||||
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||
}
|
||||
|
||||
return { ok: true, platform: normalized }
|
||||
}
|
||||
|
||||
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform, queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||
queryApi
|
||||
})
|
||||
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('获取账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||
// POST /admin/accounts/:accountId/balance/refresh
|
||||
// Body: { platform: 'xxx' }
|
||||
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.body || {}
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||
|
||||
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||
if (!balance) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(balance)
|
||||
} catch (error) {
|
||||
logger.error('刷新账户余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 3) 批量获取平台所有账户余额
|
||||
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.params
|
||||
const { queryApi } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||
|
||||
return res.json({ success: true, data: balances })
|
||||
} catch (error) {
|
||||
logger.error('批量获取余额失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 4) 获取余额汇总(Dashboard 用)
|
||||
// GET /admin/accounts/balance/summary
|
||||
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const summary = await accountBalanceService.getBalanceSummary()
|
||||
return res.json({ success: true, data: summary })
|
||||
} catch (error) {
|
||||
logger.error('获取余额汇总失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 5) 清除缓存
|
||||
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||
|
||||
return res.json({ success: true, message: '缓存已清除' })
|
||||
} catch (error) {
|
||||
logger.error('清除缓存失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 6) 获取/保存/测试余额脚本配置(单账户)
|
||||
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const config = await accountBalanceService.redis.getBalanceScriptConfig(
|
||||
valid.platform,
|
||||
accountId
|
||||
)
|
||||
return res.json({ success: true, data: config || null })
|
||||
} catch (error) {
|
||||
logger.error('获取余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
|
||||
return res.json({ success: true, data: payload })
|
||||
} catch (error) {
|
||||
logger.error('保存余额脚本配置失败', error)
|
||||
return res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const { platform } = req.query
|
||||
const valid = ensureValidPlatform(platform)
|
||||
if (!valid.ok) {
|
||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||
}
|
||||
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||
})
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const { scriptBody } = payload
|
||||
if (!scriptBody) {
|
||||
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
|
||||
}
|
||||
|
||||
const result = await balanceScriptService.execute({
|
||||
scriptBody,
|
||||
timeoutSeconds: payload.timeoutSeconds || 10,
|
||||
variables: {
|
||||
baseUrl: payload.baseUrl || '',
|
||||
apiKey: payload.apiKey || '',
|
||||
token: payload.token || '',
|
||||
accountId,
|
||||
platform: valid.platform,
|
||||
extra: payload.extra || ''
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('测试余额脚本失败', error)
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -8,6 +8,43 @@ const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 有效的权限值列表
|
||||
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
|
||||
|
||||
/**
|
||||
* 验证权限数组格式
|
||||
* @param {any} permissions - 权限值(可以是数组或其他)
|
||||
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||
*/
|
||||
function validatePermissions(permissions) {
|
||||
// 空值或未定义表示全部服务
|
||||
if (permissions === undefined || permissions === null || permissions === '') {
|
||||
return null
|
||||
}
|
||||
// 兼容旧格式字符串
|
||||
if (typeof permissions === 'string') {
|
||||
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
|
||||
return null
|
||||
}
|
||||
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
// 新格式数组
|
||||
if (Array.isArray(permissions)) {
|
||||
// 空数组表示全部服务
|
||||
if (permissions.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 验证数组中的每个值
|
||||
for (const perm of permissions) {
|
||||
if (!VALID_PERMISSIONS.includes(perm)) {
|
||||
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
|
||||
// 👥 用户管理 (用于API Key分配)
|
||||
|
||||
// 获取所有用户列表(用于API Key分配)
|
||||
@@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证服务权限字段
|
||||
if (
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const permissionsError = validatePermissions(permissions)
|
||||
if (permissionsError) {
|
||||
return res.status(400).json({ error: permissionsError })
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
@@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||||
}
|
||||
|
||||
if (
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const batchPermissionsError = validatePermissions(permissions)
|
||||
if (batchPermissionsError) {
|
||||
return res.status(400).json({ error: batchPermissionsError })
|
||||
}
|
||||
|
||||
// 生成批量API Keys
|
||||
@@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
updates.permissions !== undefined &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
if (updates.permissions !== undefined) {
|
||||
const updatePermissionsError = validatePermissions(updates.permissions)
|
||||
if (updatePermissionsError) {
|
||||
return res.status(400).json({ error: updatePermissionsError })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
})
|
||||
// 验证服务权限字段(支持数组格式)
|
||||
const singlePermissionsError = validatePermissions(permissions)
|
||||
if (singlePermissionsError) {
|
||||
return res.status(400).json({ error: singlePermissionsError })
|
||||
}
|
||||
updates.permissions = permissions
|
||||
}
|
||||
|
||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const router = express.Router()
|
||||
|
||||
// 获取全部脚本配置列表
|
||||
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
|
||||
const items = balanceScriptService.listConfigs()
|
||||
return res.json({ success: true, data: items })
|
||||
})
|
||||
|
||||
// 获取单个脚本配置
|
||||
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
const { name } = req.params
|
||||
const config = balanceScriptService.getConfig(name || 'default')
|
||||
return res.json({ success: true, data: config })
|
||||
})
|
||||
|
||||
// 保存脚本配置
|
||||
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: saved })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 测试脚本(不落库)
|
||||
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params
|
||||
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -9,6 +9,7 @@ const router = express.Router()
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeRelayService = require('../../services/claudeRelayService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
expiresAt,
|
||||
extInfo
|
||||
extInfo,
|
||||
maxConcurrency,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||
extInfo: extInfo || null
|
||||
extInfo: extInfo || null,
|
||||
maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用
|
||||
interceptWarmup: interceptWarmup === true // 拦截预热请求:默认为false
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 账户定时测试相关端点
|
||||
// ============================================================================
|
||||
|
||||
// 获取账户测试历史
|
||||
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
const history = await redis.getAccountTestHistory(accountId, 'claude')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
history
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get test history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取账户定时测试配置
|
||||
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
config: testConfig || {
|
||||
enabled: false,
|
||||
cronExpression: '0 8 * * *',
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get test config',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置账户定时测试配置
|
||||
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { enabled, cronExpression, model } = req.body
|
||||
|
||||
try {
|
||||
// 验证 enabled 参数
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'enabled must be a boolean'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 cronExpression 参数
|
||||
if (!cronExpression || typeof cronExpression !== 'string') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'cronExpression is required and must be a string'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制 cronExpression 长度防止 DoS
|
||||
const MAX_CRON_LENGTH = 100
|
||||
if (cronExpression.length > MAX_CRON_LENGTH) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
|
||||
})
|
||||
}
|
||||
|
||||
// 使用 service 的方法验证 cron 表达式
|
||||
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
|
||||
})
|
||||
}
|
||||
|
||||
// 验证模型参数
|
||||
const testModel = model || 'claude-sonnet-4-5-20250929'
|
||||
if (typeof testModel !== 'string' || testModel.length > 256) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'model must be a valid string (max 256 characters)'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查账户是否存在
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: 'Account not found',
|
||||
message: `Claude account ${accountId} not found`
|
||||
})
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await redis.saveAccountTestConfig(accountId, 'claude', {
|
||||
enabled,
|
||||
cronExpression,
|
||||
model: testModel
|
||||
})
|
||||
|
||||
logger.success(
|
||||
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Test config updated successfully',
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
config: { enabled, cronExpression, model: testModel }
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update test config',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发账户测试(非流式,返回JSON结果)
|
||||
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
// 检查账户是否存在
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: 'Account not found',
|
||||
message: `Claude account ${accountId} not found`
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
|
||||
|
||||
// 执行测试
|
||||
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
|
||||
|
||||
// 保存测试结果到历史
|
||||
await redis.saveAccountTestResult(accountId, 'claude', testResult)
|
||||
await redis.setAccountLastTestTime(accountId, 'claude')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
result: testResult
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to run test',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 批量获取多个账户的测试历史
|
||||
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
|
||||
const { accountIds } = req.body
|
||||
|
||||
try {
|
||||
if (!Array.isArray(accountIds) || accountIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'accountIds must be a non-empty array'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制批量查询数量
|
||||
const limitedIds = accountIds.slice(0, 100)
|
||||
|
||||
const accounts = limitedIds.map((accountId) => ({
|
||||
accountId,
|
||||
platform: 'claude'
|
||||
}))
|
||||
|
||||
const historyMap = await redis.getAccountsTestHistory(accounts)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: historyMap
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get batch test history:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get batch test history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
dailyQuota,
|
||||
quotaResetTime,
|
||||
maxConcurrentTasks,
|
||||
disableAutoProtection
|
||||
disableAutoProtection,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||
? Number(maxConcurrentTasks)
|
||||
: 0,
|
||||
disableAutoProtection: normalizedDisableAutoProtection
|
||||
disableAutoProtection: normalizedDisableAutoProtection,
|
||||
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
|
||||
@@ -21,9 +21,11 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
||||
const droidAccountsRoutes = require('./droidAccounts')
|
||||
const dashboardRoutes = require('./dashboard')
|
||||
const usageStatsRoutes = require('./usageStats')
|
||||
const accountBalanceRoutes = require('./accountBalance')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -36,9 +38,11 @@ router.use('/', openaiResponsesAccountsRoutes)
|
||||
router.use('/', droidAccountsRoutes)
|
||||
router.use('/', dashboardRoutes)
|
||||
router.use('/', usageStatsRoutes)
|
||||
router.use('/', accountBalanceRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Admin Routes - Sync / Export (for migration)
|
||||
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const redis = require('../../models/redis')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
function toBool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultValue
|
||||
}
|
||||
if (value === true || value === 'true') {
|
||||
return true
|
||||
}
|
||||
if (value === false || value === 'false') {
|
||||
return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function normalizeProxy(proxy) {
|
||||
if (!proxy || typeof proxy !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||
const host = proxy.host || ''
|
||||
const port = Number(proxy.port || 0)
|
||||
|
||||
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: String(protocol),
|
||||
host: String(host),
|
||||
port,
|
||||
username: proxy.username ? String(proxy.username) : '',
|
||||
password: proxy.password ? String(proxy.password) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||
if (!supportedModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
for (const model of supportedModels) {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
mapping[model.trim()] = model.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
if (typeof supportedModels === 'object') {
|
||||
const mapping = {}
|
||||
for (const [from, to] of Object.entries(supportedModels)) {
|
||||
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||
mapping[from.trim()] = to.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function safeParseJson(raw, fallback = null) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (_) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Export accounts for migration (includes secrets).
|
||||
// GET /admin/sync/export-accounts?include_secrets=true
|
||||
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||
if (!includeSecrets) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'include_secrets_required',
|
||||
message: 'Set include_secrets=true to export secrets'
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Claude official OAuth / Setup Token accounts =====
|
||||
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||
let decryptedClaudeAiOauth = null
|
||||
if (account.claudeAiOauth) {
|
||||
try {
|
||||
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||
} catch (_) {
|
||||
decryptedClaudeAiOauth = null
|
||||
}
|
||||
}
|
||||
|
||||
const rawScopes =
|
||||
account.scopes && account.scopes.trim()
|
||||
? account.scopes
|
||||
: decryptedClaudeAiOauth?.scopes
|
||||
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||
: ''
|
||||
|
||||
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||
|
||||
const accessToken =
|
||||
account.accessToken && String(account.accessToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
: decryptedClaudeAiOauth?.accessToken || ''
|
||||
|
||||
const refreshToken =
|
||||
account.refreshToken && String(account.refreshToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||
|
||||
let expiresAt = null
|
||||
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||
expiresAt = new Date(expiresAtMs).toISOString()
|
||||
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||
try {
|
||||
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||
} catch (_) {
|
||||
expiresAt = null
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||
|
||||
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||
let orgUuid = null
|
||||
let accountUuid = null
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||
orgUuid = subscriptionInfo.organizationUuid || null
|
||||
accountUuid = subscriptionInfo.accountUuid || null
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||
if (!expiresIn && isOAuth) {
|
||||
expiresIn = 28800 // 8 hours
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken || undefined,
|
||||
expires_at: expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (orgUuid) {
|
||||
credentials.org_uuid = orgUuid
|
||||
}
|
||||
if (accountUuid) {
|
||||
credentials.account_uuid = accountUuid
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'claude-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'claude',
|
||||
crs_auth_type: authType,
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_subscription_info: account.subscriptionInfo || undefined
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'claude-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'claude',
|
||||
authType,
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Claude Console API Key accounts =====
|
||||
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||
const claudeConsoleAccounts = []
|
||||
for (const summary of claudeConsoleSummaries) {
|
||||
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.apiUrl
|
||||
}
|
||||
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
claudeConsoleAccounts.push({
|
||||
kind: 'claude-console-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'claude-console',
|
||||
isActive: full.isActive === true,
|
||||
schedulable: full.schedulable !== false,
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'claude-console-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'claude-console',
|
||||
crs_is_active: full.isActive === true,
|
||||
crs_schedulable: full.schedulable !== false,
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accessToken = account.accessToken
|
||||
? openaiAccountService.decrypt(account.accessToken)
|
||||
: ''
|
||||
if (!accessToken) {
|
||||
// Skip broken/legacy records without decryptable token
|
||||
continue
|
||||
}
|
||||
|
||||
const scopes =
|
||||
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||
? account.scopes.trim().split(' ')
|
||||
: []
|
||||
|
||||
const proxy = normalizeProxy(account.proxy)
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (account.expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||
if (!expiresIn) {
|
||||
expiresIn = 864000 // 10 days
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: account.refreshToken || undefined,
|
||||
id_token: account.idToken || undefined,
|
||||
expires_at: account.expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (account.accountId) {
|
||||
credentials.chatgpt_account_id = account.accountId
|
||||
}
|
||||
if (account.chatgptUserId) {
|
||||
credentials.chatgpt_user_id = account.chatgptUserId
|
||||
}
|
||||
if (account.organizationId) {
|
||||
credentials.organization_id = account.organizationId
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'openai-oauth-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'openai',
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_email: account.email || undefined,
|
||||
crs_chatgpt_account_id: account.accountId || undefined,
|
||||
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||
crs_organization_id: account.organizationId || undefined
|
||||
}
|
||||
|
||||
openaiOAuthAccounts.push({
|
||||
kind: 'openai-oauth-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'openai',
|
||||
authType: 'oauth',
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.baseApi
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
openaiResponsesAccounts.push({
|
||||
kind: 'openai-responses-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'openai-responses',
|
||||
isActive: full.isActive === 'true',
|
||||
schedulable: full.schedulable !== 'false',
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'openai-responses-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'openai-responses',
|
||||
crs_is_active: full.isActive === 'true',
|
||||
crs_schedulable: full.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
openaiOAuthAccounts,
|
||||
openaiResponsesAccounts
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to export accounts for sync:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'export_failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -12,6 +12,13 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
const {
|
||||
isWarmupRequest,
|
||||
buildMockWarmupResponse,
|
||||
sendMockWarmupStream
|
||||
} = require('../utils/warmupInterceptor')
|
||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||
const {
|
||||
@@ -115,6 +122,16 @@ async function handleMessagesRequest(req, res) {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Claude 服务权限校验,阻止未授权的 Key
|
||||
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Claude 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态)
|
||||
if (req._concurrencyRetryAttempted === undefined) {
|
||||
req._concurrencyRetryAttempted = false
|
||||
@@ -398,6 +415,23 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 预热请求拦截检查(在转发之前)
|
||||
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||
const account =
|
||||
accountType === 'claude-official'
|
||||
? await claudeAccountService.getAccount(accountId)
|
||||
: await claudeConsoleAccountService.getAccount(accountId)
|
||||
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
|
||||
if (isStream) {
|
||||
return sendMockWarmupStream(res, req.body.model)
|
||||
} else {
|
||||
return res.json(buildMockWarmupResponse(req.body.model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据账号类型选择对应的转发服务并调用
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||
@@ -897,6 +931,21 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 预热请求拦截检查(非流式,在转发之前)
|
||||
if (accountType === 'claude-official' || accountType === 'claude-console') {
|
||||
const account =
|
||||
accountType === 'claude-official'
|
||||
? await claudeAccountService.getAccount(accountId)
|
||||
: await claudeConsoleAccountService.getAccount(accountId)
|
||||
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||
logger.api(
|
||||
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||
)
|
||||
return res.json(buildMockWarmupResponse(req.body.model))
|
||||
}
|
||||
}
|
||||
|
||||
// 根据账号类型选择对应的转发服务
|
||||
let response
|
||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||
@@ -1465,9 +1514,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
|
||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
|
||||
const processRequest = async () => {
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
@@ -1663,5 +1709,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
|
||||
router.post('/api/event_logging/batch', (req, res) => {
|
||||
res.status(200).json({ success: true })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
module.exports.handleMessagesRequest = handleMessagesRequest
|
||||
|
||||
@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
|
||||
const droidRelayService = require('../services/droidRelayService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function hasDroidPermission(apiKeyData) {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || permissions === 'droid'
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||
const crypto = require('crypto')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
@@ -31,8 +32,7 @@ function ensureAntigravityProjectId(account) {
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||
@@ -532,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
@@ -634,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
// 记录使用统计
|
||||
if (openaiResponse.usage) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
openaiResponse.usage.prompt_tokens || 0,
|
||||
|
||||
@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
|
||||
|
||||
// 检查 API Key 是否具备 OpenAI 权限
|
||||
function checkOpenAIPermissions(apiKeyData) {
|
||||
const permissions = apiKeyData?.permissions || 'all'
|
||||
return permissions === 'all' || permissions === 'openai'
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers = {}) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
handleStreamGenerateContent: geminiHandleStreamGenerateContent
|
||||
} = require('../handlers/geminiHandlers')
|
||||
const openaiRoutes = require('./openaiRoutes')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
|
||||
return await openaiRoutes.handleResponses(req, res)
|
||||
} else if (backend === 'gemini') {
|
||||
// Gemini 后端
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
|
||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||
)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
const sessionData = await redis.getSession(token)
|
||||
|
||||
if (!sessionData) {
|
||||
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
sessionData.lastActivity = new Date().toISOString()
|
||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||
|
||||
Reference in New Issue
Block a user