mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
497 lines
18 KiB
JavaScript
497 lines
18 KiB
JavaScript
/**
|
||
* Admin Routes - Claude Console 账户管理
|
||
* API Key 方式的 Claude Console 账户
|
||
*/
|
||
|
||
const express = require('express')
|
||
const router = express.Router()
|
||
|
||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService')
|
||
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,
|
||
disableAutoProtection
|
||
} = 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' })
|
||
}
|
||
}
|
||
|
||
// 校验上游错误自动防护开关
|
||
const normalizedDisableAutoProtection =
|
||
disableAutoProtection === true || disableAutoProtection === 'true'
|
||
|
||
// 验证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,
|
||
disableAutoProtection: normalizedDisableAutoProtection
|
||
})
|
||
|
||
// 如果是分组类型,将账户添加到分组(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.disableAutoProtection !== undefined) {
|
||
mappedUpdates.disableAutoProtection =
|
||
mappedUpdates.disableAutoProtection === true ||
|
||
mappedUpdates.disableAutoProtection === 'true'
|
||
}
|
||
|
||
// 处理分组的变更
|
||
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 })
|
||
}
|
||
})
|
||
|
||
// 测试Claude Console账户连通性(流式响应)- 复用 claudeConsoleRelayService
|
||
router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||
const { accountId } = req.params
|
||
|
||
try {
|
||
// 直接调用服务层的测试方法
|
||
await claudeConsoleRelayService.testAccountConnection(accountId, res)
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to test Claude Console account:`, error)
|
||
// 错误已在服务层处理,这里仅做日志记录
|
||
}
|
||
})
|
||
|
||
module.exports = router
|