Files
claude-relay-service/src/routes/admin/ccrAccounts.js
2025-11-27 22:16:45 +08:00

417 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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