mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
refacto: 重构admin.js
This commit is contained in:
9916
src/routes/admin.js
9916
src/routes/admin.js
File diff suppressed because it is too large
Load Diff
153
src/routes/admin/accountGroups.js
Normal file
153
src/routes/admin/accountGroups.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 👥 账户分组管理
|
||||||
|
|
||||||
|
// 创建账户分组
|
||||||
|
router.post('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, platform, description } = req.body
|
||||||
|
|
||||||
|
const group = await accountGroupService.createGroup({
|
||||||
|
name,
|
||||||
|
platform,
|
||||||
|
description
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ success: true, data: group })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create account group:', error)
|
||||||
|
return res.status(400).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有分组
|
||||||
|
router.get('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform } = req.query
|
||||||
|
const groups = await accountGroupService.getAllGroups(platform)
|
||||||
|
return res.json({ success: true, data: groups })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account groups:', error)
|
||||||
|
return res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取分组详情
|
||||||
|
router.get('/:groupId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params
|
||||||
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: '分组不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: group })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account group:', error)
|
||||||
|
return res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新分组
|
||||||
|
router.put('/:groupId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
const updatedGroup = await accountGroupService.updateGroup(groupId, updates)
|
||||||
|
return res.json({ success: true, data: updatedGroup })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update account group:', error)
|
||||||
|
return res.status(400).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除分组
|
||||||
|
router.delete('/:groupId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params
|
||||||
|
await accountGroupService.deleteGroup(groupId)
|
||||||
|
return res.json({ success: true, message: '分组删除成功' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete account group:', error)
|
||||||
|
return res.status(400).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取分组成员
|
||||||
|
router.get('/:groupId/members', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params
|
||||||
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: '分组不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
|
||||||
|
// 获取成员详细信息
|
||||||
|
const members = []
|
||||||
|
for (const memberId of memberIds) {
|
||||||
|
// 根据分组平台优先查找对应账户
|
||||||
|
let account = null
|
||||||
|
switch (group.platform) {
|
||||||
|
case 'droid':
|
||||||
|
account = await droidAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'gemini':
|
||||||
|
account = await geminiAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'openai':
|
||||||
|
account = await openaiAccountService.getAccount(memberId)
|
||||||
|
break
|
||||||
|
case 'claude':
|
||||||
|
default:
|
||||||
|
account = await claudeAccountService.getAccount(memberId)
|
||||||
|
if (!account) {
|
||||||
|
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧数据:若按平台未找到,则继续尝试其他平台
|
||||||
|
if (!account) {
|
||||||
|
account = await claudeAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
account = await geminiAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
account = await openaiAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
if (!account && group.platform !== 'droid') {
|
||||||
|
account = await droidAccountService.getAccount(memberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
members.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: members })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get group members:', error)
|
||||||
|
return res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
1914
src/routes/admin/apiKeys.js
Normal file
1914
src/routes/admin/apiKeys.js
Normal file
File diff suppressed because it is too large
Load Diff
417
src/routes/admin/azureOpenaiAccounts.js
Normal file
417
src/routes/admin/azureOpenaiAccounts.js
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const azureOpenaiAccountService = require('../../services/azureOpenaiAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const axios = require('axios')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 获取所有 Azure OpenAI 账户
|
||||||
|
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await azureOpenaiAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'azure_openai') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息和分组信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error)
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.debug(`Failed to get group info for account ${account.id}:`, groupError)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: accountsWithStats
|
||||||
|
})
|
||||||
|
} 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,
|
||||||
|
groupIds,
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
if (accountType === 'group') {
|
||||||
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
// 使用多分组设置
|
||||||
|
await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai')
|
||||||
|
} else if (groupId) {
|
||||||
|
// 兼容单分组模式
|
||||||
|
await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id)
|
||||||
|
|
||||||
|
const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai')
|
||||||
|
|
||||||
|
await azureOpenaiAccountService.deleteAccount(id)
|
||||||
|
|
||||||
|
let message = 'Azure OpenAI账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} 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
|
||||||
371
src/routes/admin/bedrockAccounts.js
Normal file
371
src/routes/admin/bedrockAccounts.js
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Bedrock Accounts Management
|
||||||
|
* AWS Bedrock 账户管理路由
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
// ☁️ Bedrock 账户管理
|
||||||
|
|
||||||
|
// 获取所有Bedrock账户
|
||||||
|
router.get('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
const result = await bedrockAccountService.getAllAccounts()
|
||||||
|
if (!result.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get Bedrock accounts', message: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
let accounts = result.data
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'bedrock') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get usage stats for Bedrock account ${account.id}:`,
|
||||||
|
statsError.message
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get group info for account ${account.id}:`,
|
||||||
|
groupError.message
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Bedrock accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新的Bedrock账户
|
||||||
|
router.post('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
region,
|
||||||
|
awsCredentials,
|
||||||
|
defaultModel,
|
||||||
|
priority,
|
||||||
|
accountType,
|
||||||
|
credentialType
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证credentialType的有效性
|
||||||
|
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bedrockAccountService.createAccount({
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
region: region || 'us-east-1',
|
||||||
|
awsCredentials,
|
||||||
|
defaultModel,
|
||||||
|
priority: priority || 50,
|
||||||
|
accountType: accountType || 'shared',
|
||||||
|
credentialType: credentialType || 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to create Bedrock account', message: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`☁️ Admin created Bedrock account: ${name}`)
|
||||||
|
const formattedAccount = formatAccountExpiry(result.data)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Bedrock account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to create Bedrock account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新Bedrock账户
|
||||||
|
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId)
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (
|
||||||
|
mappedUpdates.priority !== undefined &&
|
||||||
|
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证credentialType的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.credentialType &&
|
||||||
|
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update Bedrock account', message: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Bedrock account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'Bedrock account updated successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Bedrock account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update Bedrock account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除Bedrock账户
|
||||||
|
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock')
|
||||||
|
|
||||||
|
const result = await bedrockAccountService.deleteAccount(accountId)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to delete Bedrock account', message: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = 'Bedrock账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Bedrock account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to delete Bedrock account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换Bedrock账户状态
|
||||||
|
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const accountResult = await bedrockAccountService.getAccount(accountId)
|
||||||
|
if (!accountResult.success) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !accountResult.data.isActive
|
||||||
|
const updateResult = await bedrockAccountService.updateAccount(accountId, {
|
||||||
|
isActive: newStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updateResult.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle account status', message: updateResult.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled Bedrock account status: ${accountId} -> ${
|
||||||
|
newStatus ? 'active' : 'inactive'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, isActive: newStatus })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Bedrock account status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换Bedrock账户调度状态
|
||||||
|
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const accountResult = await bedrockAccountService.getAccount(accountId)
|
||||||
|
if (!accountResult.success) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSchedulable = !accountResult.data.schedulable
|
||||||
|
const updateResult = await bedrockAccountService.updateAccount(accountId, {
|
||||||
|
schedulable: newSchedulable
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updateResult.success) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.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'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, schedulable: newSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Bedrock account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试Bedrock账户连接
|
||||||
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await bedrockAccountService.testAccount(accountId)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
||||||
|
return res.json({ success: true, data: result.data })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to test Bedrock account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
416
src/routes/admin/ccrAccounts.js
Normal file
416
src/routes/admin/ccrAccounts.js
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 🔧 CCR 账户管理
|
||||||
|
|
||||||
|
// 获取所有CCR账户
|
||||||
|
router.get('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await ccrAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'ccr') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id)
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
// 转换schedulable为布尔值
|
||||||
|
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get usage stats for CCR account ${account.id}:`,
|
||||||
|
statsError.message
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
// 转换schedulable为布尔值
|
||||||
|
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get group info for CCR account ${account.id}:`,
|
||||||
|
groupError.message
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get CCR accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新的CCR账户
|
||||||
|
router.post('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority,
|
||||||
|
supportedModels,
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
groupId,
|
||||||
|
dailyQuota,
|
||||||
|
quotaResetTime
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (!name || !apiUrl || !apiKey) {
|
||||||
|
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,验证groupId
|
||||||
|
if (accountType === 'group' && !groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await ccrAccountService.createAccount({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority: priority || 50,
|
||||||
|
supportedModels: supportedModels || [],
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration:
|
||||||
|
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||||
|
proxy,
|
||||||
|
accountType: accountType || 'shared',
|
||||||
|
dailyQuota: dailyQuota || 0,
|
||||||
|
quotaResetTime: quotaResetTime || '00:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
if (accountType === 'group' && groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(newAccount.id, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🔧 Admin created CCR account: ${name}`)
|
||||||
|
const formattedAccount = formatAccountExpiry(newAccount)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create CCR account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to create CCR account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新CCR账户
|
||||||
|
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'CCR', accountId)
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (
|
||||||
|
mappedUpdates.priority !== undefined &&
|
||||||
|
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.accountType &&
|
||||||
|
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId
|
||||||
|
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const oldGroup of oldGroups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果新类型是分组,处理多分组支持
|
||||||
|
if (mappedUpdates.accountType === 'group') {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||||
|
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||||
|
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||||
|
// 设置新的多分组
|
||||||
|
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
|
||||||
|
} else {
|
||||||
|
// groupIds 为空数组,从所有分组中移除
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||||
|
}
|
||||||
|
} else if (mappedUpdates.groupId) {
|
||||||
|
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||||
|
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ccrAccountService.updateAccount(accountId, mappedUpdates)
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated CCR account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'CCR account updated successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update CCR account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update CCR account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除CCR账户
|
||||||
|
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
// 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性)
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr')
|
||||||
|
|
||||||
|
// 获取账户信息以检查是否在分组中
|
||||||
|
const account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (account && account.accountType === 'group') {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const group of groups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ccrAccountService.deleteAccount(accountId)
|
||||||
|
|
||||||
|
let message = 'CCR账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
// 理论上不会发生,但保持消息格式一致
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted CCR account: ${accountId}`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete CCR account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换CCR账户状态
|
||||||
|
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !account.isActive
|
||||||
|
await ccrAccountService.updateAccount(accountId, { isActive: newStatus })
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, isActive: newStatus })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle CCR account status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换CCR账户调度状态
|
||||||
|
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSchedulable = !account.schedulable
|
||||||
|
await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||||
|
|
||||||
|
// 如果账号被禁用,发送webhook通知
|
||||||
|
if (!newSchedulable) {
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name || 'CCR Account',
|
||||||
|
platform: 'ccr',
|
||||||
|
status: 'disabled',
|
||||||
|
errorCode: 'CCR_MANUALLY_DISABLED',
|
||||||
|
reason: '账号已被管理员手动禁用调度',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${
|
||||||
|
newSchedulable ? 'schedulable' : 'not schedulable'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, schedulable: newSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle CCR account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取CCR账户的使用统计
|
||||||
|
router.get('/:accountId/usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const usageStats = await ccrAccountService.getAccountUsageStats(accountId)
|
||||||
|
|
||||||
|
if (!usageStats) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(usageStats)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重置CCR账户的每日使用量
|
||||||
|
router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
await ccrAccountService.resetDailyUsage(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置CCR账户状态(清除所有异常状态)
|
||||||
|
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||||
|
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset CCR account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重置所有CCR账户的每日使用量
|
||||||
|
router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ccrAccountService.resetAllDailyUsage()
|
||||||
|
|
||||||
|
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
||||||
|
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to reset all daily usage', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
790
src/routes/admin/claudeAccounts.js
Normal file
790
src/routes/admin/claudeAccounts.js
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Claude 官方账户管理
|
||||||
|
* OAuth 方式授权的 Claude 账户
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const oauthHelper = require('../../utils/oauthHelper')
|
||||||
|
const CostCalculator = require('../../utils/costCalculator')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
// 生成OAuth授权URL
|
||||||
|
router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { proxy } = req.body // 接收代理配置
|
||||||
|
const oauthParams = await oauthHelper.generateOAuthParams()
|
||||||
|
|
||||||
|
// 将codeVerifier和state临时存储到Redis,用于后续验证
|
||||||
|
const sessionId = require('crypto').randomUUID()
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
codeVerifier: oauthParams.codeVerifier,
|
||||||
|
state: oauthParams.state,
|
||||||
|
codeChallenge: oauthParams.codeChallenge,
|
||||||
|
proxy: proxy || null, // 存储代理配置
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success('🔗 Generated OAuth authorization URL with proxy support')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl: oauthParams.authUrl,
|
||||||
|
sessionId,
|
||||||
|
instructions: [
|
||||||
|
'1. 复制上面的链接到浏览器中打开',
|
||||||
|
'2. 登录您的 Anthropic 账户',
|
||||||
|
'3. 同意应用权限',
|
||||||
|
'4. 复制浏览器地址栏中的完整 URL',
|
||||||
|
'5. 在添加账户表单中粘贴完整的回调 URL 和授权码'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to generate OAuth URL:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证授权码并获取token
|
||||||
|
router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId, authorizationCode, callbackUrl } = req.body
|
||||||
|
|
||||||
|
if (!sessionId || (!authorizationCode && !callbackUrl)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Session ID and authorization code (or callback URL) are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Redis获取OAuth会话信息
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
if (!oauthSession) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查会话是否过期
|
||||||
|
if (new Date() > new Date(oauthSession.expiresAt)) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一处理授权码输入(可能是直接的code或完整的回调URL)
|
||||||
|
let finalAuthCode
|
||||||
|
const inputValue = callbackUrl || authorizationCode
|
||||||
|
|
||||||
|
try {
|
||||||
|
finalAuthCode = oauthHelper.parseCallbackUrl(inputValue)
|
||||||
|
} catch (parseError) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Failed to parse authorization input', message: parseError.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换访问令牌
|
||||||
|
const tokenData = await oauthHelper.exchangeCodeForTokens(
|
||||||
|
finalAuthCode,
|
||||||
|
oauthSession.codeVerifier,
|
||||||
|
oauthSession.state,
|
||||||
|
oauthSession.proxy // 传递代理配置
|
||||||
|
)
|
||||||
|
|
||||||
|
// 清理OAuth会话
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
|
logger.success('🎉 Successfully exchanged authorization code for tokens')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: tokenData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to exchange authorization code:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionId: req.body.sessionId,
|
||||||
|
// 不记录完整的授权码,只记录长度和前几个字符
|
||||||
|
codeLength: req.body.callbackUrl
|
||||||
|
? req.body.callbackUrl.length
|
||||||
|
: req.body.authorizationCode
|
||||||
|
? req.body.authorizationCode.length
|
||||||
|
: 0,
|
||||||
|
codePrefix: req.body.callbackUrl
|
||||||
|
? `${req.body.callbackUrl.substring(0, 10)}...`
|
||||||
|
: req.body.authorizationCode
|
||||||
|
? `${req.body.authorizationCode.substring(0, 10)}...`
|
||||||
|
: 'N/A'
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to exchange authorization code', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成Claude setup-token授权URL
|
||||||
|
router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { proxy } = req.body // 接收代理配置
|
||||||
|
const setupTokenParams = await oauthHelper.generateSetupTokenParams()
|
||||||
|
|
||||||
|
// 将codeVerifier和state临时存储到Redis,用于后续验证
|
||||||
|
const sessionId = require('crypto').randomUUID()
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
type: 'setup-token', // 标记为setup-token类型
|
||||||
|
codeVerifier: setupTokenParams.codeVerifier,
|
||||||
|
state: setupTokenParams.state,
|
||||||
|
codeChallenge: setupTokenParams.codeChallenge,
|
||||||
|
proxy: proxy || null, // 存储代理配置
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl: setupTokenParams.authUrl,
|
||||||
|
sessionId,
|
||||||
|
instructions: [
|
||||||
|
'1. 复制上面的链接到浏览器中打开',
|
||||||
|
'2. 登录您的 Claude 账户并授权 Claude Code',
|
||||||
|
'3. 完成授权后,从返回页面复制 Authorization Code',
|
||||||
|
'4. 在添加账户表单中粘贴 Authorization Code'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to generate Setup Token URL:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to generate Setup Token URL', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证setup-token授权码并获取token
|
||||||
|
router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId, authorizationCode, callbackUrl } = req.body
|
||||||
|
|
||||||
|
if (!sessionId || (!authorizationCode && !callbackUrl)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Session ID and authorization code (or callback URL) are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Redis获取OAuth会话信息
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
if (!oauthSession) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是setup-token类型
|
||||||
|
if (oauthSession.type !== 'setup-token') {
|
||||||
|
return res.status(400).json({ error: 'Invalid session type for setup token exchange' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查会话是否过期
|
||||||
|
if (new Date() > new Date(oauthSession.expiresAt)) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一处理授权码输入(可能是直接的code或完整的回调URL)
|
||||||
|
let finalAuthCode
|
||||||
|
const inputValue = callbackUrl || authorizationCode
|
||||||
|
|
||||||
|
try {
|
||||||
|
finalAuthCode = oauthHelper.parseCallbackUrl(inputValue)
|
||||||
|
} catch (parseError) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Failed to parse authorization input', message: parseError.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换Setup Token
|
||||||
|
const tokenData = await oauthHelper.exchangeSetupTokenCode(
|
||||||
|
finalAuthCode,
|
||||||
|
oauthSession.codeVerifier,
|
||||||
|
oauthSession.state,
|
||||||
|
oauthSession.proxy // 传递代理配置
|
||||||
|
)
|
||||||
|
|
||||||
|
// 清理OAuth会话
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
|
logger.success('🎉 Successfully exchanged setup token authorization code for tokens')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: tokenData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to exchange setup token authorization code:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionId: req.body.sessionId,
|
||||||
|
// 不记录完整的授权码,只记录长度和前几个字符
|
||||||
|
codeLength: req.body.callbackUrl
|
||||||
|
? req.body.callbackUrl.length
|
||||||
|
: req.body.authorizationCode
|
||||||
|
? req.body.authorizationCode.length
|
||||||
|
: 0,
|
||||||
|
codePrefix: req.body.callbackUrl
|
||||||
|
? `${req.body.callbackUrl.substring(0, 10)}...`
|
||||||
|
: req.body.authorizationCode
|
||||||
|
? `${req.body.authorizationCode.substring(0, 10)}...`
|
||||||
|
: 'N/A'
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to exchange setup token authorization code', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有Claude账户
|
||||||
|
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await claudeAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'claude') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
|
||||||
|
// 获取会话窗口使用统计(仅对有活跃窗口的账户)
|
||||||
|
let sessionWindowUsage = null
|
||||||
|
if (account.sessionWindow && account.sessionWindow.hasActiveWindow) {
|
||||||
|
const windowUsage = await redis.getAccountSessionWindowUsage(
|
||||||
|
account.id,
|
||||||
|
account.sessionWindow.windowStart,
|
||||||
|
account.sessionWindow.windowEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算会话窗口的总费用
|
||||||
|
let totalCost = 0
|
||||||
|
const modelCosts = {}
|
||||||
|
|
||||||
|
for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) {
|
||||||
|
const usageData = {
|
||||||
|
input_tokens: usage.inputTokens,
|
||||||
|
output_tokens: usage.outputTokens,
|
||||||
|
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: usage.cacheReadTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData))
|
||||||
|
const costResult = CostCalculator.calculateCost(usageData, modelName)
|
||||||
|
logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`)
|
||||||
|
|
||||||
|
modelCosts[modelName] = {
|
||||||
|
...usage,
|
||||||
|
cost: costResult.costs.total
|
||||||
|
}
|
||||||
|
totalCost += costResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionWindowUsage = {
|
||||||
|
totalTokens: windowUsage.totalAllTokens,
|
||||||
|
totalRequests: windowUsage.totalRequests,
|
||||||
|
totalCost,
|
||||||
|
modelUsage: modelCosts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
// 转换schedulable为布尔值
|
||||||
|
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages,
|
||||||
|
sessionWindow: sessionWindowUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message)
|
||||||
|
// 如果获取统计失败,返回空统计
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 },
|
||||||
|
sessionWindow: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get group info for account ${account.id}:`,
|
||||||
|
groupError.message
|
||||||
|
)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 },
|
||||||
|
sessionWindow: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量获取 Claude 账户的 OAuth Usage 数据
|
||||||
|
router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
const now = Date.now()
|
||||||
|
const usageCacheTtlMs = 300 * 1000
|
||||||
|
|
||||||
|
// 批量并发获取所有活跃 OAuth 账户的 Usage
|
||||||
|
const usagePromises = accounts.map(async (account) => {
|
||||||
|
// 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限
|
||||||
|
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
|
||||||
|
// 仅为 OAuth 授权的活跃账户调用 usage API
|
||||||
|
if (
|
||||||
|
isOAuth &&
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
account.accessToken &&
|
||||||
|
account.status === 'active'
|
||||||
|
) {
|
||||||
|
// 若快照在 300 秒内更新,直接使用缓存避免频繁请求
|
||||||
|
const cachedUsage = claudeAccountService.buildClaudeUsageSnapshot(account)
|
||||||
|
const lastUpdatedAt = account.claudeUsageUpdatedAt
|
||||||
|
? new Date(account.claudeUsageUpdatedAt).getTime()
|
||||||
|
: 0
|
||||||
|
const isCacheFresh = cachedUsage && lastUpdatedAt && now - lastUpdatedAt < usageCacheTtlMs
|
||||||
|
if (isCacheFresh) {
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
claudeUsage: cachedUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id)
|
||||||
|
if (usageData) {
|
||||||
|
await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData)
|
||||||
|
}
|
||||||
|
// 重新读取更新后的数据
|
||||||
|
const updatedAccount = await redis.getClaudeAccount(account.id)
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message)
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Setup Token 账户不调用 usage API,直接返回 null
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(usagePromises)
|
||||||
|
|
||||||
|
// 转换为 { accountId: usage } 映射
|
||||||
|
const usageMap = {}
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
usageMap[result.value.accountId] = result.value.claudeUsage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: usageMap })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to fetch Claude accounts usage:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch usage data', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新的Claude账户
|
||||||
|
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
refreshToken,
|
||||||
|
claudeAiOauth,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
platform = 'claude',
|
||||||
|
priority,
|
||||||
|
groupId,
|
||||||
|
groupIds,
|
||||||
|
autoStopOnWarning,
|
||||||
|
useUnifiedUserAgent,
|
||||||
|
useUnifiedClientId,
|
||||||
|
unifiedClientId,
|
||||||
|
expiresAt,
|
||||||
|
extInfo
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,验证groupId或groupIds
|
||||||
|
if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Group ID or Group IDs are required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性
|
||||||
|
if (
|
||||||
|
priority !== undefined &&
|
||||||
|
(typeof priority !== 'number' || priority < 1 || priority > 100)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await claudeAccountService.createAccount({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
refreshToken,
|
||||||
|
claudeAiOauth,
|
||||||
|
proxy,
|
||||||
|
accountType: accountType || 'shared', // 默认为共享类型
|
||||||
|
platform,
|
||||||
|
priority: priority || 50, // 默认优先级为50
|
||||||
|
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
|
||||||
|
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
|
||||||
|
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||||
|
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||||
|
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||||
|
extInfo: extInfo || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
if (accountType === 'group') {
|
||||||
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
// 使用多分组设置
|
||||||
|
await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform)
|
||||||
|
} else if (groupId) {
|
||||||
|
// 兼容单分组模式
|
||||||
|
await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`)
|
||||||
|
const formattedAccount = formatAccountExpiry(newAccount)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Claude account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to create Claude account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新Claude账户
|
||||||
|
router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【修改】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt(提前到参数验证之前)
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Claude', accountId)
|
||||||
|
|
||||||
|
// 验证priority的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.priority !== undefined &&
|
||||||
|
(typeof mappedUpdates.priority !== 'number' ||
|
||||||
|
mappedUpdates.priority < 1 ||
|
||||||
|
mappedUpdates.priority > 100)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.accountType &&
|
||||||
|
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId或groupIds
|
||||||
|
if (
|
||||||
|
mappedUpdates.accountType === 'group' &&
|
||||||
|
!mappedUpdates.groupId &&
|
||||||
|
(!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Group ID or Group IDs are required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果新类型是分组,添加到新分组
|
||||||
|
if (mappedUpdates.accountType === 'group') {
|
||||||
|
// 处理多分组/单分组的兼容性
|
||||||
|
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||||
|
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||||
|
// 使用多分组设置
|
||||||
|
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
|
||||||
|
} else {
|
||||||
|
// groupIds 为空数组,从所有分组中移除
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||||
|
}
|
||||||
|
} else if (mappedUpdates.groupId) {
|
||||||
|
// 兼容单分组模式
|
||||||
|
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await claudeAccountService.updateAccount(accountId, mappedUpdates)
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Claude account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'Claude account updated successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update Claude account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除Claude账户
|
||||||
|
router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude')
|
||||||
|
|
||||||
|
// 获取账户信息以检查是否在分组中
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (account && account.accountType === 'group') {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const group of groups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await claudeAccountService.deleteAccount(accountId)
|
||||||
|
|
||||||
|
let message = 'Claude账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Claude account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to delete Claude account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新单个Claude账户的Profile信息
|
||||||
|
router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Account profile updated successfully',
|
||||||
|
data: profileInfo
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update account profile:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update account profile', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量更新所有Claude账户的Profile信息
|
||||||
|
router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await claudeAccountService.updateAllAccountProfiles()
|
||||||
|
|
||||||
|
logger.success('✅ Batch profile update completed')
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Batch profile update completed',
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update all account profiles:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update all account profiles', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新Claude账户token
|
||||||
|
router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await claudeAccountService.refreshAccountToken(accountId)
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to refresh Claude account token:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置Claude账户状态(清除所有异常状态)
|
||||||
|
router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await claudeAccountService.resetAccountStatus(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for Claude account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Claude account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换Claude账户调度状态
|
||||||
|
router.put(
|
||||||
|
'/claude-accounts/:accountId/toggle-schedulable',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const accounts = await claudeAccountService.getAllAccounts()
|
||||||
|
const account = accounts.find((acc) => acc.id === accountId)
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, schedulable: newSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
469
src/routes/admin/claudeConsoleAccounts.js
Normal file
469
src/routes/admin/claudeConsoleAccounts.js
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - Claude Console 账户管理
|
||||||
|
* API Key 方式的 Claude Console 账户
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
// 获取所有Claude Console账户
|
||||||
|
router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'claude-console') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
// 转换schedulable为布尔值
|
||||||
|
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`,
|
||||||
|
statsError.message
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
// 转换schedulable为布尔值
|
||||||
|
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get group info for Claude Console account ${account.id}:`,
|
||||||
|
groupError.message
|
||||||
|
)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Console accounts:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get Claude Console accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新的Claude Console账户
|
||||||
|
router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority,
|
||||||
|
supportedModels,
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
groupId,
|
||||||
|
dailyQuota,
|
||||||
|
quotaResetTime,
|
||||||
|
maxConcurrentTasks
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (!name || !apiUrl || !apiKey) {
|
||||||
|
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证maxConcurrentTasks的有效性(非负整数)
|
||||||
|
if (maxConcurrentTasks !== undefined && maxConcurrentTasks !== null) {
|
||||||
|
const concurrent = Number(maxConcurrentTasks)
|
||||||
|
if (!Number.isInteger(concurrent) || concurrent < 0) {
|
||||||
|
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,验证groupId
|
||||||
|
if (accountType === 'group' && !groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await claudeConsoleAccountService.createAccount({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority: priority || 50,
|
||||||
|
supportedModels: supportedModels || [],
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration:
|
||||||
|
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||||
|
proxy,
|
||||||
|
accountType: accountType || 'shared',
|
||||||
|
dailyQuota: dailyQuota || 0,
|
||||||
|
quotaResetTime: quotaResetTime || '00:00',
|
||||||
|
maxConcurrentTasks:
|
||||||
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
|
? Number(maxConcurrentTasks)
|
||||||
|
: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
|
if (accountType === 'group' && groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🎮 Admin created Claude Console account: ${name}`)
|
||||||
|
const formattedAccount = formatAccountExpiry(newAccount)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Claude Console account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to create Claude Console account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新Claude Console账户
|
||||||
|
router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId)
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (
|
||||||
|
mappedUpdates.priority !== undefined &&
|
||||||
|
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证maxConcurrentTasks的有效性(非负整数)
|
||||||
|
if (
|
||||||
|
mappedUpdates.maxConcurrentTasks !== undefined &&
|
||||||
|
mappedUpdates.maxConcurrentTasks !== null
|
||||||
|
) {
|
||||||
|
const concurrent = Number(mappedUpdates.maxConcurrentTasks)
|
||||||
|
if (!Number.isInteger(concurrent) || concurrent < 0) {
|
||||||
|
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
|
||||||
|
}
|
||||||
|
// 转换为数字类型
|
||||||
|
mappedUpdates.maxConcurrentTasks = concurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.accountType &&
|
||||||
|
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId
|
||||||
|
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const oldGroup of oldGroups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果新类型是分组,处理多分组支持
|
||||||
|
if (mappedUpdates.accountType === 'group') {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||||
|
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||||
|
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||||
|
// 设置新的多分组
|
||||||
|
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
|
||||||
|
} else {
|
||||||
|
// groupIds 为空数组,从所有分组中移除
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||||
|
}
|
||||||
|
} else if (mappedUpdates.groupId) {
|
||||||
|
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||||
|
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates)
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Claude Console account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'Claude Console account updated successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude Console account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to update Claude Console account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除Claude Console账户
|
||||||
|
router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console')
|
||||||
|
|
||||||
|
// 获取账户信息以检查是否在分组中
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
if (account && account.accountType === 'group') {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const group of groups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await claudeConsoleAccountService.deleteAccount(accountId)
|
||||||
|
|
||||||
|
let message = 'Claude Console账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys`
|
||||||
|
)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Claude Console account:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to delete Claude Console account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换Claude Console账户状态
|
||||||
|
router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !account.isActive
|
||||||
|
await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus })
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled Claude Console account status: ${accountId} -> ${
|
||||||
|
newStatus ? 'active' : 'inactive'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, isActive: newStatus })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude Console account status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换Claude Console账户调度状态
|
||||||
|
router.put(
|
||||||
|
'/claude-console-accounts/:accountId/toggle-schedulable',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return res.json({ success: true, schedulable: newSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude Console account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取Claude Console账户的使用统计
|
||||||
|
router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId)
|
||||||
|
|
||||||
|
if (!usageStats) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(usageStats)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Console account usage stats:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重置Claude Console账户的每日使用量
|
||||||
|
router.post(
|
||||||
|
'/claude-console-accounts/:accountId/reset-usage',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||||
|
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重置Claude Console账户状态(清除所有异常状态)
|
||||||
|
router.post(
|
||||||
|
'/claude-console-accounts/:accountId/reset-status',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||||
|
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 手动重置所有Claude Console账户的每日使用量
|
||||||
|
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await claudeConsoleAccountService.resetAllDailyUsage()
|
||||||
|
|
||||||
|
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
|
||||||
|
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to reset all daily usage', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
707
src/routes/admin/dashboard.js
Normal file
707
src/routes/admin/dashboard.js
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||||
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const CostCalculator = require('../../utils/costCalculator')
|
||||||
|
const pricingService = require('../../services/pricingService')
|
||||||
|
const config = require('../../../config/config')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 📊 系统统计
|
||||||
|
|
||||||
|
// 获取系统概览
|
||||||
|
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
apiKeys,
|
||||||
|
claudeAccounts,
|
||||||
|
claudeConsoleAccounts,
|
||||||
|
geminiAccounts,
|
||||||
|
bedrockAccountsResult,
|
||||||
|
openaiAccounts,
|
||||||
|
ccrAccounts,
|
||||||
|
openaiResponsesAccounts,
|
||||||
|
droidAccounts,
|
||||||
|
todayStats,
|
||||||
|
systemAverages,
|
||||||
|
realtimeMetrics
|
||||||
|
] = await Promise.all([
|
||||||
|
redis.getSystemStats(),
|
||||||
|
apiKeyService.getAllApiKeys(),
|
||||||
|
claudeAccountService.getAllAccounts(),
|
||||||
|
claudeConsoleAccountService.getAllAccounts(),
|
||||||
|
geminiAccountService.getAllAccounts(),
|
||||||
|
bedrockAccountService.getAllAccounts(),
|
||||||
|
redis.getAllOpenAIAccounts(),
|
||||||
|
ccrAccountService.getAllAccounts(),
|
||||||
|
openaiResponsesAccountService.getAllAccounts(true),
|
||||||
|
droidAccountService.getAllAccounts(),
|
||||||
|
redis.getTodayStats(),
|
||||||
|
redis.getSystemAverages(),
|
||||||
|
redis.getRealtimeSystemMetrics()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 处理Bedrock账户数据
|
||||||
|
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||||
|
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||||
|
const isRateLimitedFlag = (status) => {
|
||||||
|
if (!status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof status === 'string') {
|
||||||
|
return status === 'limited'
|
||||||
|
}
|
||||||
|
if (typeof status === 'object') {
|
||||||
|
return status.isRateLimited === true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
normalizeBoolean(acc.isActive) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
normalizeBoolean(acc.schedulable) &&
|
||||||
|
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||||
|
).length
|
||||||
|
const abnormalDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
!normalizeBoolean(acc.schedulable) &&
|
||||||
|
normalizeBoolean(acc.isActive) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
||||||
|
isRateLimitedFlag(acc.rateLimitStatus)
|
||||||
|
).length
|
||||||
|
|
||||||
|
// 计算使用统计(统一使用allTokens)
|
||||||
|
const totalTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalRequestsUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.requests || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalInputTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalOutputTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalCacheCreateTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalCacheReadTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalAllTokensUsed = apiKeys.reduce(
|
||||||
|
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
|
||||||
|
|
||||||
|
// Claude账户统计 - 根据账户管理页面的判断逻辑
|
||||||
|
const normalClaudeAccounts = claudeAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalClaudeAccounts = claudeAccounts.filter(
|
||||||
|
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedClaudeAccounts = claudeAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.schedulable === false &&
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedClaudeAccounts = claudeAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Claude Console账户统计
|
||||||
|
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||||
|
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.schedulable === false &&
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Gemini账户统计
|
||||||
|
const normalGeminiAccounts = geminiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(
|
||||||
|
acc.rateLimitStatus === 'limited' ||
|
||||||
|
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
const abnormalGeminiAccounts = geminiAccounts.filter(
|
||||||
|
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedGeminiAccounts = geminiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.schedulable === false &&
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedGeminiAccounts = geminiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.rateLimitStatus === 'limited' ||
|
||||||
|
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Bedrock账户统计
|
||||||
|
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalBedrockAccounts = bedrockAccounts.filter(
|
||||||
|
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedBedrockAccounts = bedrockAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.schedulable === false &&
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
// OpenAI账户统计
|
||||||
|
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
||||||
|
const normalOpenAIAccounts = openaiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
(acc.isActive === 'true' ||
|
||||||
|
acc.isActive === true ||
|
||||||
|
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== 'false' &&
|
||||||
|
acc.schedulable !== false && // 包括'true'、true和undefined
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive === 'false' ||
|
||||||
|
acc.isActive === false ||
|
||||||
|
acc.status === 'blocked' ||
|
||||||
|
acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedOpenAIAccounts = openaiAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||||
|
(acc.isActive === 'true' ||
|
||||||
|
acc.isActive === true ||
|
||||||
|
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
// CCR账户统计
|
||||||
|
const normalCcrAccounts = ccrAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalCcrAccounts = ccrAccounts.filter(
|
||||||
|
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedCcrAccounts = ccrAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.schedulable === false &&
|
||||||
|
acc.isActive &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
// OpenAI-Responses账户统计
|
||||||
|
// 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型
|
||||||
|
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
(acc.isActive === 'true' ||
|
||||||
|
acc.isActive === true ||
|
||||||
|
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
acc.schedulable !== 'false' &&
|
||||||
|
acc.schedulable !== false &&
|
||||||
|
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||||
|
).length
|
||||||
|
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
acc.isActive === 'false' ||
|
||||||
|
acc.isActive === false ||
|
||||||
|
acc.status === 'blocked' ||
|
||||||
|
acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||||
|
(acc.isActive === 'true' ||
|
||||||
|
acc.isActive === true ||
|
||||||
|
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||||
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
|
).length
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
overview: {
|
||||||
|
totalApiKeys: apiKeys.length,
|
||||||
|
activeApiKeys,
|
||||||
|
// 总账户统计(所有平台)
|
||||||
|
totalAccounts:
|
||||||
|
claudeAccounts.length +
|
||||||
|
claudeConsoleAccounts.length +
|
||||||
|
geminiAccounts.length +
|
||||||
|
bedrockAccounts.length +
|
||||||
|
openaiAccounts.length +
|
||||||
|
openaiResponsesAccounts.length +
|
||||||
|
ccrAccounts.length,
|
||||||
|
normalAccounts:
|
||||||
|
normalClaudeAccounts +
|
||||||
|
normalClaudeConsoleAccounts +
|
||||||
|
normalGeminiAccounts +
|
||||||
|
normalBedrockAccounts +
|
||||||
|
normalOpenAIAccounts +
|
||||||
|
normalOpenAIResponsesAccounts +
|
||||||
|
normalCcrAccounts,
|
||||||
|
abnormalAccounts:
|
||||||
|
abnormalClaudeAccounts +
|
||||||
|
abnormalClaudeConsoleAccounts +
|
||||||
|
abnormalGeminiAccounts +
|
||||||
|
abnormalBedrockAccounts +
|
||||||
|
abnormalOpenAIAccounts +
|
||||||
|
abnormalOpenAIResponsesAccounts +
|
||||||
|
abnormalCcrAccounts +
|
||||||
|
abnormalDroidAccounts,
|
||||||
|
pausedAccounts:
|
||||||
|
pausedClaudeAccounts +
|
||||||
|
pausedClaudeConsoleAccounts +
|
||||||
|
pausedGeminiAccounts +
|
||||||
|
pausedBedrockAccounts +
|
||||||
|
pausedOpenAIAccounts +
|
||||||
|
pausedOpenAIResponsesAccounts +
|
||||||
|
pausedCcrAccounts +
|
||||||
|
pausedDroidAccounts,
|
||||||
|
rateLimitedAccounts:
|
||||||
|
rateLimitedClaudeAccounts +
|
||||||
|
rateLimitedClaudeConsoleAccounts +
|
||||||
|
rateLimitedGeminiAccounts +
|
||||||
|
rateLimitedBedrockAccounts +
|
||||||
|
rateLimitedOpenAIAccounts +
|
||||||
|
rateLimitedOpenAIResponsesAccounts +
|
||||||
|
rateLimitedCcrAccounts +
|
||||||
|
rateLimitedDroidAccounts,
|
||||||
|
// 各平台详细统计
|
||||||
|
accountsByPlatform: {
|
||||||
|
claude: {
|
||||||
|
total: claudeAccounts.length,
|
||||||
|
normal: normalClaudeAccounts,
|
||||||
|
abnormal: abnormalClaudeAccounts,
|
||||||
|
paused: pausedClaudeAccounts,
|
||||||
|
rateLimited: rateLimitedClaudeAccounts
|
||||||
|
},
|
||||||
|
'claude-console': {
|
||||||
|
total: claudeConsoleAccounts.length,
|
||||||
|
normal: normalClaudeConsoleAccounts,
|
||||||
|
abnormal: abnormalClaudeConsoleAccounts,
|
||||||
|
paused: pausedClaudeConsoleAccounts,
|
||||||
|
rateLimited: rateLimitedClaudeConsoleAccounts
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
total: geminiAccounts.length,
|
||||||
|
normal: normalGeminiAccounts,
|
||||||
|
abnormal: abnormalGeminiAccounts,
|
||||||
|
paused: pausedGeminiAccounts,
|
||||||
|
rateLimited: rateLimitedGeminiAccounts
|
||||||
|
},
|
||||||
|
bedrock: {
|
||||||
|
total: bedrockAccounts.length,
|
||||||
|
normal: normalBedrockAccounts,
|
||||||
|
abnormal: abnormalBedrockAccounts,
|
||||||
|
paused: pausedBedrockAccounts,
|
||||||
|
rateLimited: rateLimitedBedrockAccounts
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
total: openaiAccounts.length,
|
||||||
|
normal: normalOpenAIAccounts,
|
||||||
|
abnormal: abnormalOpenAIAccounts,
|
||||||
|
paused: pausedOpenAIAccounts,
|
||||||
|
rateLimited: rateLimitedOpenAIAccounts
|
||||||
|
},
|
||||||
|
ccr: {
|
||||||
|
total: ccrAccounts.length,
|
||||||
|
normal: normalCcrAccounts,
|
||||||
|
abnormal: abnormalCcrAccounts,
|
||||||
|
paused: pausedCcrAccounts,
|
||||||
|
rateLimited: rateLimitedCcrAccounts
|
||||||
|
},
|
||||||
|
'openai-responses': {
|
||||||
|
total: openaiResponsesAccounts.length,
|
||||||
|
normal: normalOpenAIResponsesAccounts,
|
||||||
|
abnormal: abnormalOpenAIResponsesAccounts,
|
||||||
|
paused: pausedOpenAIResponsesAccounts,
|
||||||
|
rateLimited: rateLimitedOpenAIResponsesAccounts
|
||||||
|
},
|
||||||
|
droid: {
|
||||||
|
total: droidAccounts.length,
|
||||||
|
normal: normalDroidAccounts,
|
||||||
|
abnormal: abnormalDroidAccounts,
|
||||||
|
paused: pausedDroidAccounts,
|
||||||
|
rateLimited: rateLimitedDroidAccounts
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 保留旧字段以兼容
|
||||||
|
activeAccounts:
|
||||||
|
normalClaudeAccounts +
|
||||||
|
normalClaudeConsoleAccounts +
|
||||||
|
normalGeminiAccounts +
|
||||||
|
normalBedrockAccounts +
|
||||||
|
normalOpenAIAccounts +
|
||||||
|
normalOpenAIResponsesAccounts +
|
||||||
|
normalCcrAccounts +
|
||||||
|
normalDroidAccounts,
|
||||||
|
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||||
|
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||||
|
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||||
|
totalGeminiAccounts: geminiAccounts.length,
|
||||||
|
activeGeminiAccounts: normalGeminiAccounts,
|
||||||
|
rateLimitedGeminiAccounts,
|
||||||
|
totalTokensUsed,
|
||||||
|
totalRequestsUsed,
|
||||||
|
totalInputTokensUsed,
|
||||||
|
totalOutputTokensUsed,
|
||||||
|
totalCacheCreateTokensUsed,
|
||||||
|
totalCacheReadTokensUsed,
|
||||||
|
totalAllTokensUsed
|
||||||
|
},
|
||||||
|
recentActivity: {
|
||||||
|
apiKeysCreatedToday: todayStats.apiKeysCreatedToday,
|
||||||
|
requestsToday: todayStats.requestsToday,
|
||||||
|
tokensToday: todayStats.tokensToday,
|
||||||
|
inputTokensToday: todayStats.inputTokensToday,
|
||||||
|
outputTokensToday: todayStats.outputTokensToday,
|
||||||
|
cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0,
|
||||||
|
cacheReadTokensToday: todayStats.cacheReadTokensToday || 0
|
||||||
|
},
|
||||||
|
systemAverages: {
|
||||||
|
rpm: systemAverages.systemRPM,
|
||||||
|
tpm: systemAverages.systemTPM
|
||||||
|
},
|
||||||
|
realtimeMetrics: {
|
||||||
|
rpm: realtimeMetrics.realtimeRPM,
|
||||||
|
tpm: realtimeMetrics.realtimeTPM,
|
||||||
|
windowMinutes: realtimeMetrics.windowMinutes,
|
||||||
|
isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据
|
||||||
|
},
|
||||||
|
systemHealth: {
|
||||||
|
redisConnected: redis.isConnected,
|
||||||
|
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||||
|
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
||||||
|
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||||
|
uptime: process.uptime()
|
||||||
|
},
|
||||||
|
systemTimezone: config.system.timezoneOffset || 8
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: dashboard })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get dashboard data:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取使用统计
|
||||||
|
router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'daily' } = req.query // daily, monthly
|
||||||
|
|
||||||
|
// 获取基础API Key统计
|
||||||
|
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||||
|
|
||||||
|
const stats = apiKeys.map((key) => ({
|
||||||
|
keyId: key.id,
|
||||||
|
keyName: key.name,
|
||||||
|
usage: key.usage
|
||||||
|
}))
|
||||||
|
|
||||||
|
return res.json({ success: true, data: { period, stats } })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get usage stats:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取按模型的使用统计和费用
|
||||||
|
router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围
|
||||||
|
const today = redis.getDateStringInTimezone()
|
||||||
|
const tzDate = redis.getDateInTimezone()
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
|
// 获取所有模型的统计数据
|
||||||
|
let searchPatterns = []
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
// 自定义日期范围,生成多个日期的搜索模式
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
|
||||||
|
// 确保日期范围有效
|
||||||
|
if (start > end) {
|
||||||
|
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最大范围为365天
|
||||||
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
|
if (daysDiff > 365) {
|
||||||
|
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成日期范围内所有日期的搜索模式
|
||||||
|
const currentDate = new Date(start)
|
||||||
|
while (currentDate <= end) {
|
||||||
|
const dateStr = redis.getDateStringInTimezone(currentDate)
|
||||||
|
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
|
||||||
|
} else {
|
||||||
|
// 使用默认的period
|
||||||
|
const pattern =
|
||||||
|
period === 'daily'
|
||||||
|
? `usage:model:daily:*:${today}`
|
||||||
|
: `usage:model:monthly:*:${currentMonth}`
|
||||||
|
searchPatterns = [pattern]
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('📊 Searching patterns:', searchPatterns)
|
||||||
|
|
||||||
|
// 获取所有匹配的keys
|
||||||
|
const allKeys = []
|
||||||
|
for (const pattern of searchPatterns) {
|
||||||
|
const keys = await client.keys(pattern)
|
||||||
|
allKeys.push(...keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
|
||||||
|
|
||||||
|
// 模型名标准化函数(与redis.js保持一致)
|
||||||
|
const normalizeModelName = (model) => {
|
||||||
|
if (!model || model === 'unknown') {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||||
|
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||||
|
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||||
|
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||||
|
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
|
||||||
|
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
|
||||||
|
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于其他模型,去掉常见的版本后缀
|
||||||
|
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚合相同模型的数据
|
||||||
|
const modelStatsMap = new Map()
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawModel = match[1]
|
||||||
|
const normalizedModel = normalizeModelName(rawModel)
|
||||||
|
const data = await client.hgetall(key)
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const stats = modelStatsMap.get(normalizedModel) || {
|
||||||
|
requests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
allTokens: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.requests += parseInt(data.requests) || 0
|
||||||
|
stats.inputTokens += parseInt(data.inputTokens) || 0
|
||||||
|
stats.outputTokens += parseInt(data.outputTokens) || 0
|
||||||
|
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||||
|
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||||
|
stats.allTokens += parseInt(data.allTokens) || 0
|
||||||
|
|
||||||
|
modelStatsMap.set(normalizedModel, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为数组并计算费用
|
||||||
|
const modelStats = []
|
||||||
|
|
||||||
|
for (const [model, stats] of modelStatsMap) {
|
||||||
|
const usage = {
|
||||||
|
input_tokens: stats.inputTokens,
|
||||||
|
output_tokens: stats.outputTokens,
|
||||||
|
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: stats.cacheReadTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算费用
|
||||||
|
const costData = CostCalculator.calculateCost(usage, model)
|
||||||
|
|
||||||
|
modelStats.push({
|
||||||
|
model,
|
||||||
|
period: startDate && endDate ? 'custom' : period,
|
||||||
|
requests: stats.requests,
|
||||||
|
inputTokens: usage.input_tokens,
|
||||||
|
outputTokens: usage.output_tokens,
|
||||||
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
|
allTokens: stats.allTokens,
|
||||||
|
usage: {
|
||||||
|
requests: stats.requests,
|
||||||
|
inputTokens: usage.input_tokens,
|
||||||
|
outputTokens: usage.output_tokens,
|
||||||
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
|
totalTokens:
|
||||||
|
usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
},
|
||||||
|
costs: costData.costs,
|
||||||
|
formatted: costData.formatted,
|
||||||
|
pricing: costData.pricing
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按总费用排序
|
||||||
|
modelStats.sort((a, b) => b.costs.total - a.costs.total)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📊 Returning ${modelStats.length} global model stats for period ${period}:`,
|
||||||
|
modelStats
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: modelStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get model stats:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get model stats', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔧 系统管理
|
||||||
|
|
||||||
|
// 清理过期数据
|
||||||
|
router.post('/cleanup', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [expiredKeys, errorAccounts] = await Promise.all([
|
||||||
|
apiKeyService.cleanupExpiredKeys(),
|
||||||
|
claudeAccountService.cleanupErrorAccounts()
|
||||||
|
])
|
||||||
|
|
||||||
|
await redis.cleanup()
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cleanup completed',
|
||||||
|
data: {
|
||||||
|
expiredKeysRemoved: expiredKeys,
|
||||||
|
errorAccountsReset: errorAccounts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Cleanup failed:', error)
|
||||||
|
return res.status(500).json({ error: 'Cleanup failed', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
527
src/routes/admin/droidAccounts.js
Normal file
527
src/routes/admin/droidAccounts.js
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const {
|
||||||
|
startDeviceAuthorization,
|
||||||
|
pollDeviceAuthorization,
|
||||||
|
WorkOSDeviceAuthError
|
||||||
|
} = require('../../utils/workosOAuthHelper')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// ==================== Droid 账户管理 API ====================
|
||||||
|
|
||||||
|
// 生成 Droid 设备码授权信息
|
||||||
|
router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { proxy } = req.body || {}
|
||||||
|
const deviceAuth = await startDeviceAuthorization(proxy || null)
|
||||||
|
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
|
||||||
|
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
deviceCode: deviceAuth.deviceCode,
|
||||||
|
userCode: deviceAuth.userCode,
|
||||||
|
verificationUri: deviceAuth.verificationUri,
|
||||||
|
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
proxy: proxy || null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
userCode: deviceAuth.userCode,
|
||||||
|
verificationUri: deviceAuth.verificationUri,
|
||||||
|
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||||
|
expiresIn: deviceAuth.expiresIn,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
instructions: [
|
||||||
|
'1. 使用下方验证码进入授权页面并确认访问权限。',
|
||||||
|
'2. 在授权页面登录 Factory / Droid 账户并点击允许。',
|
||||||
|
'3. 回到此处点击"完成授权"完成凭证获取。'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
|
||||||
|
logger.error('❌ 生成 Droid 设备码授权失败:', message)
|
||||||
|
return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 交换 Droid 授权码
|
||||||
|
router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
const { sessionId, proxy } = req.body || {}
|
||||||
|
try {
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'Session ID is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
if (!oauthSession) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oauthSession.deviceCode) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyConfig = proxy || oauthSession.proxy || null
|
||||||
|
const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
|
||||||
|
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
|
logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
|
||||||
|
return res.json({ success: true, data: { tokens } })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WorkOSDeviceAuthError) {
|
||||||
|
if (error.code === 'authorization_pending' || error.code === 'slow_down') {
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
|
||||||
|
const remainingSeconds =
|
||||||
|
expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
|
||||||
|
? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
pending: true,
|
||||||
|
error: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
|
||||||
|
expiresIn: remainingSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'expired_token') {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Device code expired',
|
||||||
|
message: '授权已过期,请重新生成设备码并再次授权'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ Droid 授权失败:', error.message)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to exchange Droid authorization code',
|
||||||
|
message: error.message,
|
||||||
|
errorCode: error.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ 交换 Droid 授权码失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to exchange Droid authorization code',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有 Droid 账户
|
||||||
|
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await droidAccountService.getAllAccounts()
|
||||||
|
const allApiKeys = await redis.getAllApiKeys()
|
||||||
|
|
||||||
|
// 添加使用统计
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||||
|
let groupInfos = []
|
||||||
|
try {
|
||||||
|
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
||||||
|
groupInfos = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = groupInfos.map((group) => group.id)
|
||||||
|
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||||
|
const binding = key.droidAccountId
|
||||||
|
if (!binding) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
if (binding === account.id) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
if (binding.startsWith('group:')) {
|
||||||
|
const groupId = binding.substring('group:'.length)
|
||||||
|
if (groupIds.includes(groupId)) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
schedulable: account.schedulable === 'true',
|
||||||
|
boundApiKeysCount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
boundApiKeysCount: 0,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0 },
|
||||||
|
total: { tokens: 0, requests: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get Droid accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 Droid 账户
|
||||||
|
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body
|
||||||
|
|
||||||
|
const normalizedAccountType = rawAccountType || 'shared'
|
||||||
|
|
||||||
|
if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) {
|
||||||
|
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGroupIds = Array.isArray(groupIds)
|
||||||
|
? groupIds.filter((id) => typeof id === 'string' && id.trim())
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedAccountType === 'group' &&
|
||||||
|
normalizedGroupIds.length === 0 &&
|
||||||
|
(!groupId || typeof groupId !== 'string' || !groupId.trim())
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountPayload = {
|
||||||
|
...req.body,
|
||||||
|
accountType: normalizedAccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
delete accountPayload.groupId
|
||||||
|
delete accountPayload.groupIds
|
||||||
|
|
||||||
|
const account = await droidAccountService.createAccount(accountPayload)
|
||||||
|
|
||||||
|
if (normalizedAccountType === 'group') {
|
||||||
|
try {
|
||||||
|
if (normalizedGroupIds.length > 0) {
|
||||||
|
await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid')
|
||||||
|
} else if (typeof groupId === 'string' && groupId.trim()) {
|
||||||
|
await accountGroupService.addAccountToGroup(account.id, groupId, 'droid')
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to bind Droid account to groups',
|
||||||
|
message: groupError.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Created Droid account: ${account.name} (${account.id})`)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create Droid account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 Droid 账户
|
||||||
|
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const updates = { ...req.body }
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Droid', id)
|
||||||
|
|
||||||
|
const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates
|
||||||
|
|
||||||
|
if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) {
|
||||||
|
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawAccountType === 'group' &&
|
||||||
|
(!groupId || typeof groupId !== 'string' || !groupId.trim()) &&
|
||||||
|
(!Array.isArray(groupIds) || groupIds.length === 0)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAccount = await droidAccountService.getAccount(id)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Droid account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGroupIds = Array.isArray(groupIds)
|
||||||
|
? groupIds.filter((gid) => typeof gid === 'string' && gid.trim())
|
||||||
|
: []
|
||||||
|
const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')
|
||||||
|
const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId')
|
||||||
|
const targetAccountType = rawAccountType || currentAccount.accountType || 'shared'
|
||||||
|
|
||||||
|
delete mappedUpdates.groupId
|
||||||
|
delete mappedUpdates.groupIds
|
||||||
|
|
||||||
|
if (rawAccountType) {
|
||||||
|
mappedUpdates.accountType = targetAccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await droidAccountService.updateAccount(id, mappedUpdates)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentAccount.accountType === 'group' && targetAccountType !== 'group') {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(id)
|
||||||
|
} else if (targetAccountType === 'group') {
|
||||||
|
if (hasGroupIdsField) {
|
||||||
|
if (normalizedGroupIds.length > 0) {
|
||||||
|
await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid')
|
||||||
|
} else {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(id)
|
||||||
|
}
|
||||||
|
} else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) {
|
||||||
|
await accountGroupService.setAccountGroups(id, [groupId], 'droid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.error(`Failed to update Droid account ${id} groups:`, groupError)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update Droid account groups',
|
||||||
|
message: groupError.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetAccountType === 'group') {
|
||||||
|
try {
|
||||||
|
account.groupInfos = await accountGroupService.getAccountGroups(id)
|
||||||
|
} catch (groupFetchError) {
|
||||||
|
logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: account })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 Droid 账户调度状态
|
||||||
|
router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await droidAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Droid account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSchedulable = account.schedulable === true || account.schedulable === 'true'
|
||||||
|
const newSchedulable = !currentSchedulable
|
||||||
|
|
||||||
|
await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' })
|
||||||
|
|
||||||
|
const updatedAccount = await droidAccountService.getAccount(id)
|
||||||
|
const actualSchedulable = updatedAccount
|
||||||
|
? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true'
|
||||||
|
: newSchedulable
|
||||||
|
|
||||||
|
if (!actualSchedulable) {
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name || 'Droid Account',
|
||||||
|
platform: 'droid',
|
||||||
|
status: 'disabled',
|
||||||
|
errorCode: 'DROID_MANUALLY_DISABLED',
|
||||||
|
reason: '账号已被管理员手动禁用调度',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🔄 Admin toggled Droid account schedulable status: ${id} -> ${
|
||||||
|
actualSchedulable ? 'schedulable' : 'not schedulable'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, schedulable: actualSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Droid account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个 Droid 账户详细信息
|
||||||
|
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
// 获取账户基本信息
|
||||||
|
const account = await droidAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Droid account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用统计信息
|
||||||
|
let usageStats
|
||||||
|
try {
|
||||||
|
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
|
||||||
|
usageStats = {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分组信息
|
||||||
|
let groupInfos = []
|
||||||
|
try {
|
||||||
|
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
|
||||||
|
groupInfos = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取绑定的 API Key 数量
|
||||||
|
const allApiKeys = await redis.getAllApiKeys()
|
||||||
|
const groupIds = groupInfos.map((group) => group.id)
|
||||||
|
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||||
|
const binding = key.droidAccountId
|
||||||
|
if (!binding) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
if (binding === account.id) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
if (binding.startsWith('group:')) {
|
||||||
|
const groupId = binding.substring('group:'.length)
|
||||||
|
if (groupIds.includes(groupId)) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
// 获取解密的 API Keys(用于管理界面)
|
||||||
|
let decryptedApiKeys = []
|
||||||
|
try {
|
||||||
|
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
|
||||||
|
decryptedApiKeys = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回完整的账户信息,包含实际的 API Keys
|
||||||
|
const accountDetails = {
|
||||||
|
...account,
|
||||||
|
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
|
||||||
|
expiresAt: account.subscriptionExpiresAt || null,
|
||||||
|
schedulable: account.schedulable === 'true',
|
||||||
|
boundApiKeysCount,
|
||||||
|
groupInfos,
|
||||||
|
// 包含实际的 API Keys(用于管理界面)
|
||||||
|
apiKeys: decryptedApiKeys.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
id: entry.id,
|
||||||
|
usageCount: entry.usageCount || 0,
|
||||||
|
lastUsedAt: entry.lastUsedAt || null,
|
||||||
|
status: entry.status || 'active', // 使用实际的状态,默认为 active
|
||||||
|
errorMessage: entry.errorMessage || '', // 包含错误信息
|
||||||
|
createdAt: entry.createdAt || null
|
||||||
|
})),
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: accountDetails
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get Droid account',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 Droid 账户
|
||||||
|
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
await droidAccountService.deleteAccount(id)
|
||||||
|
return res.json({ success: true, message: 'Droid account deleted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新 Droid 账户 token
|
||||||
|
router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const result = await droidAccountService.refreshAccessToken(id)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
477
src/routes/admin/geminiAccounts.js
Normal file
477
src/routes/admin/geminiAccounts.js
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 🤖 Gemini OAuth 账户管理
|
||||||
|
|
||||||
|
// 生成 Gemini OAuth 授权 URL
|
||||||
|
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { state, proxy } = req.body // 接收代理配置
|
||||||
|
|
||||||
|
// 使用新的 codeassist.google.com 回调地址
|
||||||
|
const redirectUri = 'https://codeassist.google.com/authcode'
|
||||||
|
|
||||||
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||||
|
|
||||||
|
const {
|
||||||
|
authUrl,
|
||||||
|
state: authState,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri: finalRedirectUri
|
||||||
|
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
||||||
|
|
||||||
|
// 创建 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to generate Gemini auth URL:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 轮询 Gemini OAuth 授权状态
|
||||||
|
router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.body
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'Session ID is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||||
|
return res.json({ success: true, data: { tokens: result.tokens } })
|
||||||
|
} else {
|
||||||
|
return res.json({ success: false, error: result.error })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to poll Gemini auth status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to poll auth status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 交换 Gemini 授权码
|
||||||
|
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, sessionId, proxy: requestProxy } = req.body
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ error: 'Authorization code is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
proxy
|
||||||
|
} = sessionData
|
||||||
|
redirectUri = sessionRedirectUri || redirectUri
|
||||||
|
codeVerifier = sessionCodeVerifier
|
||||||
|
proxyConfig = proxy // 获取代理配置
|
||||||
|
logger.info(
|
||||||
|
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果请求体中直接提供了代理配置,优先使用它
|
||||||
|
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) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||||
|
return res.json({ success: true, data: { tokens } })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有 Gemini 账户
|
||||||
|
router.get('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await geminiAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'gemini') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息(与Claude账户相同的逻辑)
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get usage stats for Gemini account ${account.id}:`,
|
||||||
|
statsError.message
|
||||||
|
)
|
||||||
|
// 如果获取统计失败,返回空统计
|
||||||
|
try {
|
||||||
|
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to get group info for account ${account.id}:`,
|
||||||
|
groupError.message
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Gemini accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建新的 Gemini 账户
|
||||||
|
router.post('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accountData = req.body
|
||||||
|
|
||||||
|
// 输入验证
|
||||||
|
if (!accountData.name) {
|
||||||
|
return res.status(400).json({ error: 'Account name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (
|
||||||
|
accountData.accountType &&
|
||||||
|
!['shared', 'dedicated', 'group'].includes(accountData.accountType)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,验证groupId
|
||||||
|
if (accountData.accountType === 'group' && !accountData.groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await geminiAccountService.createAccount(accountData)
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
if (accountData.accountType === 'group' && accountData.groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
|
||||||
|
const formattedAccount = formatAccountExpiry(newAccount)
|
||||||
|
return res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Gemini account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to create account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 Gemini 账户
|
||||||
|
router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId
|
||||||
|
if (updates.accountType === 'group' && !updates.groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId)
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const oldGroup of oldGroups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果新类型是分组,处理多分组支持
|
||||||
|
if (mappedUpdates.accountType === 'group') {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
|
||||||
|
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||||
|
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
|
||||||
|
// 设置新的多分组
|
||||||
|
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini')
|
||||||
|
} else {
|
||||||
|
// groupIds 为空数组,从所有分组中移除
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||||
|
}
|
||||||
|
} else if (mappedUpdates.groupId) {
|
||||||
|
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||||
|
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates)
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Gemini account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: updatedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Gemini account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 Gemini 账户
|
||||||
|
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini')
|
||||||
|
|
||||||
|
// 获取账户信息以检查是否在分组中
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (account && account.accountType === 'group') {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
for (const group of groups) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await geminiAccountService.deleteAccount(accountId)
|
||||||
|
|
||||||
|
let message = 'Gemini账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Gemini account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新 Gemini 账户 token
|
||||||
|
router.post('/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await geminiAccountService.refreshAccountToken(accountId)
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to refresh Gemini account token:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 Gemini 账户调度状态
|
||||||
|
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 现在 account.schedulable 已经是布尔值了,直接取反即可
|
||||||
|
const newSchedulable = !account.schedulable
|
||||||
|
|
||||||
|
await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) })
|
||||||
|
|
||||||
|
// 验证更新是否成功,重新获取账户信息
|
||||||
|
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'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回实际的数据库值,确保前端状态与后端一致
|
||||||
|
return res.json({ success: true, schedulable: actualSchedulable })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Gemini account schedulable status:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 Gemini OAuth 账户限流状态
|
||||||
|
router.post('/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
await geminiAccountService.updateAccount(id, {
|
||||||
|
rateLimitedAt: '',
|
||||||
|
rateLimitStatus: '',
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`🔄 Admin manually reset rate limit for Gemini account ${id}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rate limit reset successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reset Gemini account rate limit:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 Gemini OAuth 账户状态(清除所有异常状态)
|
||||||
|
router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const result = await geminiAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Gemini account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
398
src/routes/admin/geminiApiAccounts.js
Normal file
398
src/routes/admin/geminiApiAccounts.js
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 获取所有 Gemini-API 账户
|
||||||
|
router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await geminiApiAccountService.getAllAccounts(true)
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'gemini-api') {
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分组ID筛选
|
||||||
|
if (groupId) {
|
||||||
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
|
if (group && group.platform === 'gemini' && group.memberIds && group.memberIds.length > 0) {
|
||||||
|
accounts = accounts.filter((account) => group.memberIds.includes(account.id))
|
||||||
|
} else {
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理使用统计和绑定的 API Key 数量
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
// 检查并清除过期的限流状态
|
||||||
|
await geminiApiAccountService.checkAndClearRateLimit(account.id)
|
||||||
|
|
||||||
|
// 获取使用统计信息
|
||||||
|
let usageStats
|
||||||
|
try {
|
||||||
|
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
|
||||||
|
usageStats = {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算绑定的API Key数量(支持 api: 前缀)
|
||||||
|
const allKeys = await redis.getAllApiKeys()
|
||||||
|
let boundCount = 0
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (key.geminiAccountId) {
|
||||||
|
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
|
||||||
|
if (key.geminiAccountId === `api:${account.id}`) {
|
||||||
|
boundCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages || usageStats.monthly
|
||||||
|
},
|
||||||
|
boundApiKeys: boundCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get Gemini-API accounts:', error)
|
||||||
|
res.status(500).json({ success: false, message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 Gemini-API 账户
|
||||||
|
router.post('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountType, groupId, groupIds } = req.body
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,验证groupId或groupIds
|
||||||
|
if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Group ID or Group IDs are required for group type accounts'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await geminiApiAccountService.createAccount(req.body)
|
||||||
|
|
||||||
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
if (accountType === 'group') {
|
||||||
|
if (groupIds && groupIds.length > 0) {
|
||||||
|
// 使用多分组设置
|
||||||
|
await accountGroupService.setAccountGroups(account.id, groupIds, 'gemini')
|
||||||
|
} else if (groupId) {
|
||||||
|
// 兼容单分组模式
|
||||||
|
await accountGroupService.addAccountToGroup(account.id, groupId, 'gemini')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`🏢 Admin created new Gemini-API account: ${account.name} (${accountType || 'shared'})`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ success: true, data: account })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create Gemini-API account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个 Gemini-API 账户
|
||||||
|
router.get('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const account = await geminiApiAccountService.getAccount(id)
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏敏感信息
|
||||||
|
account.apiKey = '***'
|
||||||
|
|
||||||
|
res.json({ success: true, data: account })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get Gemini-API account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 Gemini-API 账户
|
||||||
|
router.put('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (updates.priority !== undefined) {
|
||||||
|
const priority = parseInt(updates.priority)
|
||||||
|
if (isNaN(priority) || priority < 1 || priority > 100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Priority must be a number between 1 and 100'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId或groupIds
|
||||||
|
if (
|
||||||
|
updates.accountType === 'group' &&
|
||||||
|
!updates.groupId &&
|
||||||
|
(!updates.groupIds || updates.groupIds.length === 0)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Group ID or Group IDs are required for group type accounts'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await geminiApiAccountService.getAccount(id)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (updates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
await accountGroupService.removeAccountFromAllGroups(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果新类型是分组,添加到新分组
|
||||||
|
if (updates.accountType === 'group') {
|
||||||
|
// 处理多分组/单分组的兼容性
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) {
|
||||||
|
if (updates.groupIds && updates.groupIds.length > 0) {
|
||||||
|
// 使用多分组设置
|
||||||
|
await accountGroupService.setAccountGroups(id, updates.groupIds, 'gemini')
|
||||||
|
}
|
||||||
|
} else if (updates.groupId) {
|
||||||
|
// 兼容单分组模式
|
||||||
|
await accountGroupService.addAccountToGroup(id, updates.groupId, 'gemini')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await geminiApiAccountService.updateAccount(id, updates)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Gemini-API account: ${currentAccount.name}`)
|
||||||
|
|
||||||
|
res.json({ success: true, ...result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update Gemini-API account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 Gemini-API 账户
|
||||||
|
router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await geminiApiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys(支持 api: 前缀)
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'gemini-api')
|
||||||
|
|
||||||
|
// 检查是否在分组中
|
||||||
|
const groups = await accountGroupService.getAllGroups()
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.platform === 'gemini' && group.memberIds && group.memberIds.includes(id)) {
|
||||||
|
await accountGroupService.removeMemberFromGroup(group.id, id)
|
||||||
|
logger.info(`Removed Gemini-API account ${id} from group ${group.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await geminiApiAccountService.deleteAccount(id)
|
||||||
|
|
||||||
|
let message = 'Gemini-API账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`✅ ${message}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete Gemini-API account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 Gemini-API 账户调度状态
|
||||||
|
router.put('/gemini-api-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const result = await geminiApiAccountService.toggleSchedulable(id)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在停止调度时发送通知
|
||||||
|
if (!result.schedulable) {
|
||||||
|
await webhookNotifier.sendAccountEvent('account.status_changed', {
|
||||||
|
accountId: id,
|
||||||
|
platform: 'gemini-api',
|
||||||
|
schedulable: result.schedulable,
|
||||||
|
changedBy: 'admin',
|
||||||
|
action: 'stopped_scheduling'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle Gemini-API account schedulable status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 Gemini-API 账户激活状态
|
||||||
|
router.put('/gemini-api-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await geminiApiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||||
|
await geminiApiAccountService.updateAccount(id, {
|
||||||
|
isActive: newActiveStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isActive: newActiveStatus === 'true'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle Gemini-API account status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 Gemini-API 账户限流状态
|
||||||
|
router.post('/gemini-api-accounts/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
await geminiApiAccountService.updateAccount(id, {
|
||||||
|
rateLimitedAt: '',
|
||||||
|
rateLimitStatus: '',
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`🔄 Admin manually reset rate limit for Gemini-API account ${id}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rate limit reset successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reset Gemini-API account rate limit:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 Gemini-API 账户状态(清除所有异常状态)
|
||||||
|
router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const result = await geminiApiAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
46
src/routes/admin/index.js
Normal file
46
src/routes/admin/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - 主入口文件
|
||||||
|
* 导入并挂载所有子路由模块
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 导入所有子路由
|
||||||
|
const apiKeysRoutes = require('./apiKeys')
|
||||||
|
const accountGroupsRoutes = require('./accountGroups')
|
||||||
|
const claudeAccountsRoutes = require('./claudeAccounts')
|
||||||
|
const claudeConsoleAccountsRoutes = require('./claudeConsoleAccounts')
|
||||||
|
const ccrAccountsRoutes = require('./ccrAccounts')
|
||||||
|
const bedrockAccountsRoutes = require('./bedrockAccounts')
|
||||||
|
const geminiAccountsRoutes = require('./geminiAccounts')
|
||||||
|
const geminiApiAccountsRoutes = require('./geminiApiAccounts')
|
||||||
|
const openaiAccountsRoutes = require('./openaiAccounts')
|
||||||
|
const azureOpenaiAccountsRoutes = require('./azureOpenaiAccounts')
|
||||||
|
const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
||||||
|
const droidAccountsRoutes = require('./droidAccounts')
|
||||||
|
const dashboardRoutes = require('./dashboard')
|
||||||
|
const usageStatsRoutes = require('./usageStats')
|
||||||
|
const systemRoutes = require('./system')
|
||||||
|
|
||||||
|
// 挂载所有子路由
|
||||||
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
|
router.use('/', apiKeysRoutes)
|
||||||
|
router.use('/', claudeAccountsRoutes)
|
||||||
|
router.use('/', claudeConsoleAccountsRoutes)
|
||||||
|
router.use('/', geminiApiAccountsRoutes)
|
||||||
|
router.use('/', azureOpenaiAccountsRoutes)
|
||||||
|
router.use('/', openaiResponsesAccountsRoutes)
|
||||||
|
router.use('/', droidAccountsRoutes)
|
||||||
|
router.use('/', dashboardRoutes)
|
||||||
|
router.use('/', usageStatsRoutes)
|
||||||
|
router.use('/', systemRoutes)
|
||||||
|
|
||||||
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
router.use('/ccr-accounts', ccrAccountsRoutes)
|
||||||
|
router.use('/bedrock-accounts', bedrockAccountsRoutes)
|
||||||
|
router.use('/gemini-accounts', geminiAccountsRoutes)
|
||||||
|
router.use('/openai-accounts', openaiAccountsRoutes)
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
805
src/routes/admin/openaiAccounts.js
Normal file
805
src/routes/admin/openaiAccounts.js
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - OpenAI 账户管理
|
||||||
|
* 处理 OpenAI 账户的 CRUD 操作和 OAuth 授权流程
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const axios = require('axios')
|
||||||
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const ProxyHelper = require('../../utils/proxyHelper')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// OpenAI OAuth 配置
|
||||||
|
const OPENAI_CONFIG = {
|
||||||
|
BASE_URL: 'https://auth.openai.com',
|
||||||
|
CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||||||
|
REDIRECT_URI: 'http://localhost:1455/auth/callback',
|
||||||
|
SCOPE: 'openid profile email offline_access'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 PKCE 参数
|
||||||
|
* @returns {Object} 包含 codeVerifier 和 codeChallenge 的对象
|
||||||
|
*/
|
||||||
|
function generateOpenAIPKCE() {
|
||||||
|
const codeVerifier = crypto.randomBytes(64).toString('hex')
|
||||||
|
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
|
||||||
|
|
||||||
|
return {
|
||||||
|
codeVerifier,
|
||||||
|
codeChallenge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 OpenAI OAuth 授权 URL
|
||||||
|
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { proxy } = req.body
|
||||||
|
|
||||||
|
// 生成 PKCE 参数
|
||||||
|
const pkce = generateOpenAIPKCE()
|
||||||
|
|
||||||
|
// 生成随机 state
|
||||||
|
const state = crypto.randomBytes(32).toString('hex')
|
||||||
|
|
||||||
|
// 创建会话 ID
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
|
||||||
|
// 将 PKCE 参数和代理配置存储到 Redis
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
codeVerifier: pkce.codeVerifier,
|
||||||
|
codeChallenge: pkce.codeChallenge,
|
||||||
|
state,
|
||||||
|
proxy: proxy || null,
|
||||||
|
platform: 'openai',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建授权 URL 参数
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: OPENAI_CONFIG.CLIENT_ID,
|
||||||
|
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
|
||||||
|
scope: OPENAI_CONFIG.SCOPE,
|
||||||
|
code_challenge: pkce.codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
id_token_add_organizations: 'true',
|
||||||
|
codex_cli_simplified_flow: 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
||||||
|
|
||||||
|
logger.success('🔗 Generated OpenAI OAuth authorization URL')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
authUrl,
|
||||||
|
sessionId,
|
||||||
|
instructions: [
|
||||||
|
'1. 复制上面的链接到浏览器中打开',
|
||||||
|
'2. 登录您的 OpenAI 账户',
|
||||||
|
'3. 同意应用权限',
|
||||||
|
'4. 复制浏览器地址栏中的完整 URL(包含 code 参数)',
|
||||||
|
'5. 在添加账户表单中粘贴完整的回调 URL'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('生成 OpenAI OAuth URL 失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '生成授权链接失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 交换 OpenAI 授权码
|
||||||
|
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, sessionId } = req.body
|
||||||
|
|
||||||
|
if (!code || !sessionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '缺少必要参数'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Redis 获取会话数据
|
||||||
|
const sessionData = await redis.getOAuthSession(sessionId)
|
||||||
|
if (!sessionData) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '会话已过期或无效'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备 token 交换请求
|
||||||
|
const tokenData = {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code.trim(),
|
||||||
|
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
|
||||||
|
client_id: OPENAI_CONFIG.CLIENT_ID,
|
||||||
|
code_verifier: sessionData.codeVerifier
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Exchanging OpenAI authorization code:', {
|
||||||
|
sessionId,
|
||||||
|
codeLength: code.length,
|
||||||
|
hasCodeVerifier: !!sessionData.codeVerifier
|
||||||
|
})
|
||||||
|
|
||||||
|
// 配置代理(如果有)
|
||||||
|
const axiosConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理(如果有)
|
||||||
|
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
|
||||||
|
if (proxyAgent) {
|
||||||
|
axiosConfig.httpAgent = proxyAgent
|
||||||
|
axiosConfig.httpsAgent = proxyAgent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换 authorization code 获取 tokens
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
`${OPENAI_CONFIG.BASE_URL}/oauth/token`,
|
||||||
|
new URLSearchParams(tokenData).toString(),
|
||||||
|
axiosConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data
|
||||||
|
|
||||||
|
// 解析 ID token 获取用户信息
|
||||||
|
const idTokenParts = id_token.split('.')
|
||||||
|
if (idTokenParts.length !== 3) {
|
||||||
|
throw new Error('Invalid ID token format')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码 JWT payload
|
||||||
|
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString())
|
||||||
|
|
||||||
|
// 获取 OpenAI 特定的声明
|
||||||
|
const authClaims = payload['https://api.openai.com/auth'] || {}
|
||||||
|
const accountId = authClaims.chatgpt_account_id || ''
|
||||||
|
const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || ''
|
||||||
|
const planType = authClaims.chatgpt_plan_type || ''
|
||||||
|
|
||||||
|
// 获取组织信息
|
||||||
|
const organizations = authClaims.organizations || []
|
||||||
|
const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {}
|
||||||
|
const organizationId = defaultOrg.id || ''
|
||||||
|
const organizationRole = defaultOrg.role || ''
|
||||||
|
const organizationTitle = defaultOrg.title || ''
|
||||||
|
|
||||||
|
// 清理 Redis 会话
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
|
logger.success('✅ OpenAI OAuth token exchange successful')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tokens: {
|
||||||
|
idToken: id_token,
|
||||||
|
accessToken: access_token,
|
||||||
|
refreshToken: refresh_token,
|
||||||
|
expires_in
|
||||||
|
},
|
||||||
|
accountInfo: {
|
||||||
|
accountId,
|
||||||
|
chatgptUserId,
|
||||||
|
organizationId,
|
||||||
|
organizationRole,
|
||||||
|
organizationTitle,
|
||||||
|
planType,
|
||||||
|
email: payload.email || '',
|
||||||
|
name: payload.name || '',
|
||||||
|
emailVerified: payload.email_verified || false,
|
||||||
|
organizations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OpenAI OAuth token exchange failed:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '交换授权码失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有 OpenAI 账户
|
||||||
|
router.get('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await openaiAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 缓存账户所属分组,避免重复查询
|
||||||
|
const accountGroupCache = new Map()
|
||||||
|
const fetchAccountGroups = async (accountId) => {
|
||||||
|
if (!accountGroupCache.has(accountId)) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
accountGroupCache.set(accountId, groups || [])
|
||||||
|
}
|
||||||
|
return accountGroupCache.get(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'all' && platform !== 'openai') {
|
||||||
|
// 如果指定了其他平台,返回空数组
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了分组筛选
|
||||||
|
if (groupId && groupId !== 'all') {
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
// 筛选未分组账户
|
||||||
|
const filteredAccounts = []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const groups = await fetchAccountGroups(account.id)
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
filteredAccounts.push(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = filteredAccounts
|
||||||
|
} else {
|
||||||
|
// 筛选特定分组的账户
|
||||||
|
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await fetchAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
monthly: usageStats.monthly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
|
||||||
|
const groupInfos = await fetchAccountGroups(account.id)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: accountsWithStats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取 OpenAI 账户列表失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '获取账户列表失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 OpenAI 账户
|
||||||
|
router.post('/', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
openaiOauth,
|
||||||
|
accountInfo,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
groupId,
|
||||||
|
rateLimitDuration,
|
||||||
|
priority,
|
||||||
|
needsImmediateRefresh, // 是否需要立即刷新
|
||||||
|
requireRefreshSuccess // 是否必须刷新成功才能创建
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '账户名称不能为空'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备账户数据
|
||||||
|
const accountData = {
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
accountType: accountType || 'shared',
|
||||||
|
priority: priority || 50,
|
||||||
|
rateLimitDuration:
|
||||||
|
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||||
|
openaiOauth: openaiOauth || {},
|
||||||
|
accountInfo: accountInfo || {},
|
||||||
|
proxy: proxy || null,
|
||||||
|
isActive: true,
|
||||||
|
schedulable: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要立即刷新且必须成功(OpenAI 手动模式)
|
||||||
|
if (needsImmediateRefresh && requireRefreshSuccess) {
|
||||||
|
// 先创建临时账户以测试刷新
|
||||||
|
const tempAccount = await openaiAccountService.createAccount(accountData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`)
|
||||||
|
|
||||||
|
// 尝试刷新 token(会自动使用账户配置的代理)
|
||||||
|
await openaiAccountService.refreshAccountToken(tempAccount.id)
|
||||||
|
|
||||||
|
// 刷新成功,获取更新后的账户信息
|
||||||
|
const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id)
|
||||||
|
|
||||||
|
// 检查是否获取到了 ID Token
|
||||||
|
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
|
||||||
|
// 没有获取到 ID Token,删除账户
|
||||||
|
await openaiAccountService.deleteAccount(tempAccount.id)
|
||||||
|
throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是分组类型,添加到分组
|
||||||
|
if (accountType === 'group' && groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除敏感信息后返回
|
||||||
|
delete refreshedAccount.idToken
|
||||||
|
delete refreshedAccount.accessToken
|
||||||
|
delete refreshedAccount.refreshToken
|
||||||
|
|
||||||
|
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: refreshedAccount,
|
||||||
|
message: '账户创建成功,并已获取完整 token 信息'
|
||||||
|
})
|
||||||
|
} catch (refreshError) {
|
||||||
|
// 刷新失败,删除临时创建的账户
|
||||||
|
logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`)
|
||||||
|
await openaiAccountService.deleteAccount(tempAccount.id)
|
||||||
|
|
||||||
|
// 构建详细的错误信息
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
message: '账户创建失败',
|
||||||
|
error: refreshError.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加更详细的错误信息
|
||||||
|
if (refreshError.status) {
|
||||||
|
errorResponse.errorCode = refreshError.status
|
||||||
|
}
|
||||||
|
if (refreshError.details) {
|
||||||
|
errorResponse.errorDetails = refreshError.details
|
||||||
|
}
|
||||||
|
if (refreshError.code) {
|
||||||
|
errorResponse.networkError = refreshError.code
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供更友好的错误提示
|
||||||
|
if (refreshError.message.includes('Refresh Token 无效')) {
|
||||||
|
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
|
||||||
|
} else if (refreshError.message.includes('代理')) {
|
||||||
|
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
|
||||||
|
} else if (refreshError.message.includes('过于频繁')) {
|
||||||
|
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
|
||||||
|
} else if (refreshError.message.includes('连接')) {
|
||||||
|
errorResponse.suggestion = '请检查网络连接和代理设置'
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要强制刷新的情况(OAuth 模式或其他平台)
|
||||||
|
const createdAccount = await openaiAccountService.createAccount(accountData)
|
||||||
|
|
||||||
|
// 如果是分组类型,添加到分组
|
||||||
|
if (accountType === 'group' && groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息)
|
||||||
|
if (needsImmediateRefresh && !requireRefreshSuccess) {
|
||||||
|
try {
|
||||||
|
logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`)
|
||||||
|
await openaiAccountService.refreshAccountToken(createdAccount.id)
|
||||||
|
logger.info(`✅ 刷新成功`)
|
||||||
|
} catch (refreshError) {
|
||||||
|
logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: createdAccount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('创建 OpenAI 账户失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '创建账户失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 OpenAI 账户
|
||||||
|
router.put('/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'OpenAI', id)
|
||||||
|
|
||||||
|
const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (
|
||||||
|
mappedUpdates.accountType &&
|
||||||
|
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新为分组类型,验证groupId
|
||||||
|
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
|
||||||
|
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户当前信息以处理分组变更
|
||||||
|
const currentAccount = await openaiAccountService.getAccount(id)
|
||||||
|
if (!currentAccount) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新了 Refresh Token,需要验证其有效性
|
||||||
|
if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) {
|
||||||
|
// 先更新 token 信息
|
||||||
|
const tempUpdateData = {}
|
||||||
|
if (mappedUpdates.openaiOauth.refreshToken) {
|
||||||
|
tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
|
||||||
|
}
|
||||||
|
if (mappedUpdates.openaiOauth.accessToken) {
|
||||||
|
tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken
|
||||||
|
}
|
||||||
|
// 更新代理配置(如果有)
|
||||||
|
if (mappedUpdates.proxy !== undefined) {
|
||||||
|
tempUpdateData.proxy = mappedUpdates.proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时更新账户以测试新的 token
|
||||||
|
await openaiAccountService.updateAccount(id, tempUpdateData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`)
|
||||||
|
|
||||||
|
// 尝试刷新 token(会使用账户配置的代理)
|
||||||
|
await openaiAccountService.refreshAccountToken(id)
|
||||||
|
|
||||||
|
// 获取刷新后的账户信息
|
||||||
|
const refreshedAccount = await openaiAccountService.getAccount(id)
|
||||||
|
|
||||||
|
// 检查是否获取到了 ID Token
|
||||||
|
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
|
||||||
|
// 恢复原始 token
|
||||||
|
await openaiAccountService.updateAccount(id, {
|
||||||
|
refreshToken: currentAccount.refreshToken,
|
||||||
|
accessToken: currentAccount.accessToken,
|
||||||
|
idToken: currentAccount.idToken
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '无法获取 ID Token,请检查 Refresh Token 是否有效',
|
||||||
|
error: 'Invalid refresh token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`✅ Token 验证成功,继续更新账户信息`)
|
||||||
|
} catch (refreshError) {
|
||||||
|
// 刷新失败,恢复原始 token
|
||||||
|
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
||||||
|
await openaiAccountService.updateAccount(id, {
|
||||||
|
refreshToken: currentAccount.refreshToken,
|
||||||
|
accessToken: currentAccount.accessToken,
|
||||||
|
idToken: currentAccount.idToken,
|
||||||
|
proxy: currentAccount.proxy
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建详细的错误信息
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
message: '更新失败',
|
||||||
|
error: refreshError.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加更详细的错误信息
|
||||||
|
if (refreshError.status) {
|
||||||
|
errorResponse.errorCode = refreshError.status
|
||||||
|
}
|
||||||
|
if (refreshError.details) {
|
||||||
|
errorResponse.errorDetails = refreshError.details
|
||||||
|
}
|
||||||
|
if (refreshError.code) {
|
||||||
|
errorResponse.networkError = refreshError.code
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供更友好的错误提示
|
||||||
|
if (refreshError.message.includes('Refresh Token 无效')) {
|
||||||
|
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
|
||||||
|
} else if (refreshError.message.includes('代理')) {
|
||||||
|
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
|
||||||
|
} else if (refreshError.message.includes('过于频繁')) {
|
||||||
|
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
|
||||||
|
} else if (refreshError.message.includes('连接')) {
|
||||||
|
errorResponse.suggestion = '请检查网络连接和代理设置'
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组的变更
|
||||||
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
|
// 如果之前是分组类型,需要从原分组中移除
|
||||||
|
if (currentAccount.accountType === 'group') {
|
||||||
|
const oldGroup = await accountGroupService.getAccountGroup(id)
|
||||||
|
if (oldGroup) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果新类型是分组,添加到新分组
|
||||||
|
if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) {
|
||||||
|
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备更新数据
|
||||||
|
const updateData = { ...mappedUpdates }
|
||||||
|
|
||||||
|
// 处理敏感数据加密
|
||||||
|
if (mappedUpdates.openaiOauth) {
|
||||||
|
updateData.openaiOauth = mappedUpdates.openaiOauth
|
||||||
|
// 编辑时不允许直接输入 ID Token,只能通过刷新获取
|
||||||
|
if (mappedUpdates.openaiOauth.accessToken) {
|
||||||
|
updateData.accessToken = mappedUpdates.openaiOauth.accessToken
|
||||||
|
}
|
||||||
|
if (mappedUpdates.openaiOauth.refreshToken) {
|
||||||
|
updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
|
||||||
|
}
|
||||||
|
if (mappedUpdates.openaiOauth.expires_in) {
|
||||||
|
updateData.expiresAt = new Date(
|
||||||
|
Date.now() + mappedUpdates.openaiOauth.expires_in * 1000
|
||||||
|
).toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户信息
|
||||||
|
if (mappedUpdates.accountInfo) {
|
||||||
|
updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId
|
||||||
|
updateData.chatgptUserId =
|
||||||
|
mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
|
||||||
|
updateData.organizationId =
|
||||||
|
mappedUpdates.accountInfo.organizationId || currentAccount.organizationId
|
||||||
|
updateData.organizationRole =
|
||||||
|
mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole
|
||||||
|
updateData.organizationTitle =
|
||||||
|
mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle
|
||||||
|
updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType
|
||||||
|
updateData.email = mappedUpdates.accountInfo.email || currentAccount.email
|
||||||
|
updateData.emailVerified =
|
||||||
|
mappedUpdates.accountInfo.emailVerified !== undefined
|
||||||
|
? mappedUpdates.accountInfo.emailVerified
|
||||||
|
: currentAccount.emailVerified
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
|
||||||
|
|
||||||
|
// 如果需要刷新但不强制成功(非关键更新)
|
||||||
|
if (needsImmediateRefresh && !requireRefreshSuccess) {
|
||||||
|
try {
|
||||||
|
logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`)
|
||||||
|
await openaiAccountService.refreshAccountToken(id)
|
||||||
|
logger.info(`✅ 刷新成功`)
|
||||||
|
} catch (refreshError) {
|
||||||
|
logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated OpenAI account: ${id}`)
|
||||||
|
return res.json({ success: true, data: updatedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update OpenAI account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 OpenAI 账户
|
||||||
|
router.delete('/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await openaiAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '账户不存在'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai')
|
||||||
|
|
||||||
|
// 如果账户在分组中,从分组中移除
|
||||||
|
if (account.accountType === 'group') {
|
||||||
|
const group = await accountGroupService.getAccountGroup(id)
|
||||||
|
if (group) {
|
||||||
|
await accountGroupService.removeAccountFromGroup(id, group.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await openaiAccountService.deleteAccount(id)
|
||||||
|
|
||||||
|
let message = 'OpenAI账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('删除 OpenAI 账户失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '删除账户失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 OpenAI 账户状态
|
||||||
|
router.put('/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await redis.getOpenAiAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '账户不存在'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换启用状态
|
||||||
|
account.enabled = !account.enabled
|
||||||
|
account.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
// TODO: 更新方法
|
||||||
|
// await redis.updateOpenAiAccount(id, account)
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: account
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('切换 OpenAI 账户状态失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '切换账户状态失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 OpenAI 账户状态(清除所有异常状态)
|
||||||
|
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 OpenAI 账户调度状态
|
||||||
|
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await openaiAccountService.toggleSchedulable(accountId)
|
||||||
|
|
||||||
|
// 如果账号被禁用,发送webhook通知
|
||||||
|
if (!result.schedulable) {
|
||||||
|
// 获取账号信息
|
||||||
|
const account = await redis.getOpenAiAccount(accountId)
|
||||||
|
if (account) {
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name || 'OpenAI Account',
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'disabled',
|
||||||
|
errorCode: 'OPENAI_MANUALLY_DISABLED',
|
||||||
|
reason: '账号已被管理员手动禁用调度',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
schedulable: result.schedulable,
|
||||||
|
message: result.schedulable ? '已启用调度' : '已禁用调度'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('切换 OpenAI 账户调度状态失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '切换调度状态失败',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
370
src/routes/admin/openaiResponsesAccounts.js
Normal file
370
src/routes/admin/openaiResponsesAccounts.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - OpenAI-Responses 账户管理
|
||||||
|
* 处理 OpenAI-Responses 账户的增删改查和状态管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||||
|
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// ==================== OpenAI-Responses 账户管理 API ====================
|
||||||
|
|
||||||
|
// 获取所有 OpenAI-Responses 账户
|
||||||
|
router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform, groupId } = req.query
|
||||||
|
let accounts = await openaiResponsesAccountService.getAllAccounts(true)
|
||||||
|
|
||||||
|
// 根据查询参数进行筛选
|
||||||
|
if (platform && platform !== 'openai-responses') {
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分组ID筛选
|
||||||
|
if (groupId) {
|
||||||
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
|
if (group && group.platform === 'openai' && group.memberIds && group.memberIds.length > 0) {
|
||||||
|
accounts = accounts.filter((account) => group.memberIds.includes(account.id))
|
||||||
|
} else {
|
||||||
|
accounts = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
// 检查是否需要重置额度
|
||||||
|
const today = redis.getDateStringInTimezone()
|
||||||
|
if (account.lastResetDate !== today) {
|
||||||
|
// 今天还没重置过,需要重置
|
||||||
|
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||||
|
dailyUsage: '0',
|
||||||
|
lastResetDate: today,
|
||||||
|
quotaStoppedAt: ''
|
||||||
|
})
|
||||||
|
account.dailyUsage = '0'
|
||||||
|
account.lastResetDate = today
|
||||||
|
account.quotaStoppedAt = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并清除过期的限流状态
|
||||||
|
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
|
||||||
|
|
||||||
|
// 获取使用统计信息
|
||||||
|
let usageStats
|
||||||
|
try {
|
||||||
|
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
usageStats = {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算绑定的API Key数量(支持 responses: 前缀)
|
||||||
|
const allKeys = await redis.getAllApiKeys()
|
||||||
|
let boundCount = 0
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
// 检查是否绑定了该账户(包括 responses: 前缀)
|
||||||
|
if (
|
||||||
|
key.openaiAccountId === account.id ||
|
||||||
|
key.openaiAccountId === `responses:${account.id}`
|
||||||
|
) {
|
||||||
|
boundCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试日志:检查绑定计数
|
||||||
|
if (boundCount > 0) {
|
||||||
|
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
boundApiKeysCount: boundCount,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
monthly: usageStats.monthly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
return {
|
||||||
|
...formattedAccount,
|
||||||
|
boundApiKeysCount: 0,
|
||||||
|
usage: {
|
||||||
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get OpenAI-Responses accounts:', error)
|
||||||
|
res.status(500).json({ success: false, message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 OpenAI-Responses 账户
|
||||||
|
router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = await openaiResponsesAccountService.createAccount(req.body)
|
||||||
|
const formattedAccount = formatAccountExpiry(account)
|
||||||
|
res.json({ success: true, data: formattedAccount })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create OpenAI-Responses account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 OpenAI-Responses 账户
|
||||||
|
router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
|
||||||
|
const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id)
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (mappedUpdates.priority !== undefined) {
|
||||||
|
const priority = parseInt(mappedUpdates.priority)
|
||||||
|
if (isNaN(priority) || priority < 1 || priority > 100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Priority must be a number between 1 and 100'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mappedUpdates.priority = priority.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, ...result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update OpenAI-Responses account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 OpenAI-Responses 账户
|
||||||
|
router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await openaiResponsesAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动解绑所有绑定的 API Keys
|
||||||
|
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses')
|
||||||
|
|
||||||
|
// 检查是否在分组中
|
||||||
|
const groups = await accountGroupService.getAllGroups()
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.platform === 'openai' && group.memberIds && group.memberIds.includes(id)) {
|
||||||
|
await accountGroupService.removeMemberFromGroup(group.id, id)
|
||||||
|
logger.info(`Removed OpenAI-Responses account ${id} from group ${group.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await openaiResponsesAccountService.deleteAccount(id)
|
||||||
|
|
||||||
|
let message = 'OpenAI-Responses账号已成功删除'
|
||||||
|
if (unboundCount > 0) {
|
||||||
|
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
message,
|
||||||
|
unboundKeys: unboundCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete OpenAI-Responses account:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换 OpenAI-Responses 账户调度状态
|
||||||
|
router.put(
|
||||||
|
'/openai-responses-accounts/:id/toggle-schedulable',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const result = await openaiResponsesAccountService.toggleSchedulable(id)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在停止调度时发送通知
|
||||||
|
if (!result.schedulable) {
|
||||||
|
await webhookNotifier.sendAccountEvent('account.status_changed', {
|
||||||
|
accountId: id,
|
||||||
|
platform: 'openai-responses',
|
||||||
|
schedulable: result.schedulable,
|
||||||
|
changedBy: 'admin',
|
||||||
|
action: 'stopped_scheduling'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换 OpenAI-Responses 账户激活状态
|
||||||
|
router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const account = await openaiResponsesAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
|
||||||
|
await openaiResponsesAccountService.updateAccount(id, {
|
||||||
|
isActive: newActiveStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isActive: newActiveStatus === 'true'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle OpenAI-Responses account status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置 OpenAI-Responses 账户限流状态
|
||||||
|
router.post(
|
||||||
|
'/openai-responses-accounts/:id/reset-rate-limit',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
await openaiResponsesAccountService.updateAccount(id, {
|
||||||
|
rateLimitedAt: '',
|
||||||
|
rateLimitStatus: '',
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rate limit reset successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reset OpenAI-Responses account rate limit:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重置 OpenAI-Responses 账户状态(清除所有异常状态)
|
||||||
|
router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重置 OpenAI-Responses 账户的每日使用量
|
||||||
|
router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
await openaiResponsesAccountService.updateAccount(id, {
|
||||||
|
dailyUsage: '0',
|
||||||
|
lastResetDate: redis.getDateStringInTimezone(),
|
||||||
|
quotaStoppedAt: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Daily usage reset successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reset OpenAI-Responses account usage:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
401
src/routes/admin/system.js
Normal file
401
src/routes/admin/system.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const axios = require('axios')
|
||||||
|
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||||||
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const config = require('../../../config/config')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// ==================== Claude Code Headers 管理 ====================
|
||||||
|
|
||||||
|
// 获取所有 Claude Code headers
|
||||||
|
router.get('/claude-code-headers', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders()
|
||||||
|
|
||||||
|
// 获取所有 Claude 账号信息
|
||||||
|
const accounts = await claudeAccountService.getAllAccounts()
|
||||||
|
const accountMap = {}
|
||||||
|
accounts.forEach((account) => {
|
||||||
|
accountMap[account.id] = account.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化输出
|
||||||
|
const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({
|
||||||
|
accountId,
|
||||||
|
accountName: accountMap[accountId] || 'Unknown',
|
||||||
|
version: data.version,
|
||||||
|
userAgent: data.headers['user-agent'],
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
headers: data.headers
|
||||||
|
}))
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: formattedData
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Code headers:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get Claude Code headers', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🗑️ 清除指定账号的 Claude Code headers
|
||||||
|
router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
await claudeCodeHeadersService.clearAccountHeaders(accountId)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Claude Code headers cleared for account ${accountId}`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to clear Claude Code headers:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to clear Claude Code headers', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 系统更新检查 ====================
|
||||||
|
|
||||||
|
// 版本比较函数
|
||||||
|
function compareVersions(current, latest) {
|
||||||
|
const parseVersion = (v) => {
|
||||||
|
const parts = v.split('.').map(Number)
|
||||||
|
return {
|
||||||
|
major: parts[0] || 0,
|
||||||
|
minor: parts[1] || 0,
|
||||||
|
patch: parts[2] || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentV = parseVersion(current)
|
||||||
|
const latestV = parseVersion(latest)
|
||||||
|
|
||||||
|
if (currentV.major !== latestV.major) {
|
||||||
|
return currentV.major - latestV.major
|
||||||
|
}
|
||||||
|
if (currentV.minor !== latestV.minor) {
|
||||||
|
return currentV.minor - latestV.minor
|
||||||
|
}
|
||||||
|
return currentV.patch - latestV.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/check-updates', authenticateAdmin, async (req, res) => {
|
||||||
|
// 读取当前版本
|
||||||
|
const versionPath = path.join(__dirname, '../../../VERSION')
|
||||||
|
let currentVersion = '1.0.0'
|
||||||
|
try {
|
||||||
|
currentVersion = fs.readFileSync(versionPath, 'utf8').trim()
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('⚠️ Could not read VERSION file:', err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从缓存获取
|
||||||
|
const cacheKey = 'version_check_cache'
|
||||||
|
const cached = await redis.getClient().get(cacheKey)
|
||||||
|
|
||||||
|
if (cached && !req.query.force) {
|
||||||
|
const cachedData = JSON.parse(cached)
|
||||||
|
const cacheAge = Date.now() - cachedData.timestamp
|
||||||
|
|
||||||
|
// 缓存有效期1小时
|
||||||
|
if (cacheAge < 3600000) {
|
||||||
|
// 实时计算 hasUpdate,不使用缓存的值
|
||||||
|
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: cachedData.latest,
|
||||||
|
hasUpdate, // 实时计算,不用缓存
|
||||||
|
releaseInfo: cachedData.releaseInfo,
|
||||||
|
cached: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求 GitHub API
|
||||||
|
const githubRepo = 'wei-shaw/claude-relay-service'
|
||||||
|
const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'Claude-Relay-Service'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
const release = response.data
|
||||||
|
const latestVersion = release.tag_name.replace(/^v/, '')
|
||||||
|
|
||||||
|
// 比较版本
|
||||||
|
const hasUpdate = compareVersions(currentVersion, latestVersion) < 0
|
||||||
|
|
||||||
|
const releaseInfo = {
|
||||||
|
name: release.name,
|
||||||
|
body: release.body,
|
||||||
|
publishedAt: release.published_at,
|
||||||
|
htmlUrl: release.html_url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存结果(不缓存 hasUpdate,因为它应该实时计算)
|
||||||
|
await redis.getClient().set(
|
||||||
|
cacheKey,
|
||||||
|
JSON.stringify({
|
||||||
|
latest: latestVersion,
|
||||||
|
releaseInfo,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}),
|
||||||
|
'EX',
|
||||||
|
3600
|
||||||
|
) // 1小时过期
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: latestVersion,
|
||||||
|
hasUpdate,
|
||||||
|
releaseInfo,
|
||||||
|
cached: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 改进错误日志记录
|
||||||
|
const errorDetails = {
|
||||||
|
message: error.message || 'Unknown error',
|
||||||
|
code: error.code,
|
||||||
|
response: error.response
|
||||||
|
? {
|
||||||
|
status: error.response.status,
|
||||||
|
statusText: error.response.statusText,
|
||||||
|
data: error.response.data
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
request: error.request ? 'Request was made but no response received' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ Failed to check for updates:', errorDetails.message)
|
||||||
|
|
||||||
|
// 处理 404 错误 - 仓库或版本不存在
|
||||||
|
if (error.response && error.response.status === 404) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
releaseInfo: {
|
||||||
|
name: 'No releases found',
|
||||||
|
body: 'The GitHub repository has no releases yet.',
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
htmlUrl: '#'
|
||||||
|
},
|
||||||
|
warning: 'GitHub repository has no releases'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是网络错误,尝试返回缓存的数据
|
||||||
|
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||||
|
const cacheKey = 'version_check_cache'
|
||||||
|
const cached = await redis.getClient().get(cacheKey)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const cachedData = JSON.parse(cached)
|
||||||
|
// 实时计算 hasUpdate
|
||||||
|
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: cachedData.latest,
|
||||||
|
hasUpdate, // 实时计算
|
||||||
|
releaseInfo: cachedData.releaseInfo,
|
||||||
|
cached: true,
|
||||||
|
warning: 'Using cached data due to network error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误返回当前版本信息
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
releaseInfo: {
|
||||||
|
name: 'Update check failed',
|
||||||
|
body: `Unable to check for updates: ${error.message || 'Unknown error'}`,
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
htmlUrl: '#'
|
||||||
|
},
|
||||||
|
error: true,
|
||||||
|
warning: error.message || 'Failed to check for updates'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== OEM 设置管理 ====================
|
||||||
|
|
||||||
|
// 获取OEM设置(公开接口,用于显示)
|
||||||
|
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||||||
|
router.get('/oem-settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
const oemSettings = await client.get('oem:settings')
|
||||||
|
|
||||||
|
// 默认设置
|
||||||
|
const defaultSettings = {
|
||||||
|
siteName: 'Claude Relay Service',
|
||||||
|
siteIcon: '',
|
||||||
|
siteIconData: '', // Base64编码的图标数据
|
||||||
|
showAdminButton: true, // 是否显示管理后台按钮
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = defaultSettings
|
||||||
|
if (oemSettings) {
|
||||||
|
try {
|
||||||
|
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 LDAP 启用状态到响应中
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...settings,
|
||||||
|
ldapEnabled: config.ldap && config.ldap.enabled === true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get OEM settings:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新OEM设置
|
||||||
|
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Site name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteName.length > 100) {
|
||||||
|
return res.status(400).json({ error: 'Site name must be less than 100 characters' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证图标数据大小(如果是base64)
|
||||||
|
if (siteIconData && siteIconData.length > 500000) {
|
||||||
|
// 约375KB
|
||||||
|
return res.status(400).json({ error: 'Icon file must be less than 350KB' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证图标URL(如果提供)
|
||||||
|
if (siteIcon && !siteIconData) {
|
||||||
|
// 简单验证URL格式
|
||||||
|
try {
|
||||||
|
new URL(siteIcon)
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: 'Invalid icon URL format' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
siteName: siteName.trim(),
|
||||||
|
siteIcon: (siteIcon || '').trim(),
|
||||||
|
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||||
|
showAdminButton: showAdminButton !== false, // 默认为true
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClient()
|
||||||
|
await client.set('oem:settings', JSON.stringify(settings))
|
||||||
|
|
||||||
|
logger.info(`✅ OEM settings updated: ${siteName}`)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'OEM settings updated successfully',
|
||||||
|
data: settings
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update OEM settings:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== Claude Code 版本管理 ====================
|
||||||
|
|
||||||
|
router.get('/claude-code-version', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||||||
|
|
||||||
|
// 获取缓存的统一User-Agent
|
||||||
|
const unifiedUserAgent = await redis.client.get(CACHE_KEY)
|
||||||
|
const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
userAgent: unifiedUserAgent,
|
||||||
|
isActive: !!unifiedUserAgent,
|
||||||
|
ttlSeconds: ttl,
|
||||||
|
lastUpdated: unifiedUserAgent ? new Date().toISOString() : null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Get unified Claude Code User-Agent error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to get User-Agent information',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🗑️ 清除统一Claude Code User-Agent缓存
|
||||||
|
router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const CACHE_KEY = 'claude_code_user_agent:daily'
|
||||||
|
|
||||||
|
// 删除缓存的统一User-Agent
|
||||||
|
await redis.client.del(CACHE_KEY)
|
||||||
|
|
||||||
|
logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Unified User-Agent cache cleared successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Clear unified User-Agent cache error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to clear cache',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
1821
src/routes/admin/usageStats.js
Normal file
1821
src/routes/admin/usageStats.js
Normal file
File diff suppressed because it is too large
Load Diff
76
src/routes/admin/utils.js
Normal file
76
src/routes/admin/utils.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Admin Routes - 共享工具函数
|
||||||
|
* 供各个子路由模块导入使用
|
||||||
|
*/
|
||||||
|
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理可为空的时间字段
|
||||||
|
* @param {*} value - 输入值
|
||||||
|
* @returns {string|null} 规范化后的值
|
||||||
|
*/
|
||||||
|
function normalizeNullableDate(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed === '' ? null : trimmed
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段
|
||||||
|
* @param {Object} updates - 更新对象
|
||||||
|
* @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等)
|
||||||
|
* @param {string} accountId - 账户 ID
|
||||||
|
* @returns {Object} 映射后的更新对象
|
||||||
|
*/
|
||||||
|
function mapExpiryField(updates, accountType, accountId) {
|
||||||
|
const mappedUpdates = { ...updates }
|
||||||
|
if ('expiresAt' in mappedUpdates) {
|
||||||
|
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
|
||||||
|
delete mappedUpdates.expiresAt
|
||||||
|
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
|
||||||
|
}
|
||||||
|
return mappedUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化账户数据,确保前端获取正确的过期时间字段
|
||||||
|
* 将 subscriptionExpiresAt(订阅过期时间)映射到 expiresAt 供前端使用
|
||||||
|
* 保留原始的 tokenExpiresAt(OAuth token过期时间)供内部使用
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {Object} 格式化后的账户对象
|
||||||
|
*/
|
||||||
|
function formatAccountExpiry(account) {
|
||||||
|
if (!account || typeof account !== 'object') {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt')
|
||||||
|
? account.subscriptionExpiresAt
|
||||||
|
: null
|
||||||
|
|
||||||
|
const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt')
|
||||||
|
? account.tokenExpiresAt
|
||||||
|
: account.expiresAt
|
||||||
|
|
||||||
|
const subscriptionExpiresAt = normalizeNullableDate(rawSubscription)
|
||||||
|
const tokenExpiresAt = normalizeNullableDate(rawToken)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
subscriptionExpiresAt,
|
||||||
|
tokenExpiresAt,
|
||||||
|
expiresAt: subscriptionExpiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeNullableDate,
|
||||||
|
mapExpiryField,
|
||||||
|
formatAccountExpiry
|
||||||
|
}
|
||||||
@@ -213,7 +213,9 @@ class ApiKeyService {
|
|||||||
|
|
||||||
if (!keyData) {
|
if (!keyData) {
|
||||||
// ⚠️ 警告:映射表查找失败,可能是竞态条件或映射表损坏
|
// ⚠️ 警告:映射表查找失败,可能是竞态条件或映射表损坏
|
||||||
logger.warn(`⚠️ API key not found in hash map: ${hashedKey.substring(0, 16)}... (possible race condition or corrupted hash map)`)
|
logger.warn(
|
||||||
|
`⚠️ API key not found in hash map: ${hashedKey.substring(0, 16)}... (possible race condition or corrupted hash map)`
|
||||||
|
)
|
||||||
return { valid: false, error: 'API key not found' }
|
return { valid: false, error: 'API key not found' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user