mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
2349 lines
72 KiB
JavaScript
2349 lines
72 KiB
JavaScript
const express = require('express')
|
||
const apiKeyService = require('../../services/apiKeyService')
|
||
const redis = require('../../models/redis')
|
||
const { authenticateAdmin } = require('../../middleware/auth')
|
||
const logger = require('../../utils/logger')
|
||
const CostCalculator = require('../../utils/costCalculator')
|
||
const config = require('../../../config/config')
|
||
|
||
const router = express.Router()
|
||
|
||
// 👥 用户管理 (用于API Key分配)
|
||
|
||
// 获取所有用户列表(用于API Key分配)
|
||
router.get('/users', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const userService = require('../../services/userService')
|
||
|
||
// Extract query parameters for filtering
|
||
const { role, isActive } = req.query
|
||
const options = { limit: 1000 }
|
||
|
||
// Apply role filter if provided
|
||
if (role) {
|
||
options.role = role
|
||
}
|
||
|
||
// Apply isActive filter if provided, otherwise default to active users only
|
||
if (isActive !== undefined) {
|
||
options.isActive = isActive === 'true'
|
||
} else {
|
||
options.isActive = true // Default to active users for backwards compatibility
|
||
}
|
||
|
||
const result = await userService.getAllUsers(options)
|
||
|
||
// Extract users array from the paginated result
|
||
const allUsers = result.users || []
|
||
|
||
// Map to the format needed for the dropdown
|
||
const activeUsers = allUsers.map((user) => ({
|
||
id: user.id,
|
||
username: user.username,
|
||
displayName: user.displayName || user.username,
|
||
email: user.email,
|
||
role: user.role
|
||
}))
|
||
|
||
// 添加Admin选项作为第一个
|
||
const usersWithAdmin = [
|
||
{
|
||
id: 'admin',
|
||
username: 'admin',
|
||
displayName: 'Admin',
|
||
email: '',
|
||
role: 'admin'
|
||
},
|
||
...activeUsers
|
||
]
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: usersWithAdmin
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get users list:', error)
|
||
return res.status(500).json({
|
||
error: 'Failed to get users list',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 🔑 API Keys 管理
|
||
|
||
// 调试:获取API Key费用详情
|
||
router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
const costStats = await redis.getCostStats(keyId)
|
||
const dailyCost = await redis.getDailyCost(keyId)
|
||
const today = redis.getDateStringInTimezone()
|
||
const client = redis.getClientSafe()
|
||
|
||
// 获取所有相关的Redis键
|
||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`)
|
||
const keyValues = {}
|
||
|
||
for (const key of costKeys) {
|
||
keyValues[key] = await client.get(key)
|
||
}
|
||
|
||
return res.json({
|
||
keyId,
|
||
today,
|
||
dailyCost,
|
||
costStats,
|
||
redisKeys: keyValues,
|
||
timezone: config.system.timezoneOffset || 8
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get cost debug info:', error)
|
||
return res.status(500).json({ error: 'Failed to get cost debug info', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 获取所有被使用过的模型列表
|
||
router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const models = await redis.getAllUsedModels()
|
||
return res.json({ success: true, data: models })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get used models:', error)
|
||
return res.status(500).json({ error: 'Failed to get used models', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 获取所有API Keys
|
||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const {
|
||
// 分页参数
|
||
page = 1,
|
||
pageSize = 20,
|
||
// 搜索参数
|
||
searchMode = 'apiKey',
|
||
search = '',
|
||
// 筛选参数
|
||
tag = '',
|
||
isActive = '',
|
||
models = '', // 模型筛选(逗号分隔)
|
||
// 排序参数
|
||
sortBy = 'createdAt',
|
||
sortOrder = 'desc',
|
||
// 费用排序参数
|
||
costTimeRange = '7days', // 费用排序的时间范围
|
||
costStartDate = '', // custom 时间范围的开始日期
|
||
costEndDate = '', // custom 时间范围的结束日期
|
||
// 兼容旧参数(不再用于费用计算,仅标记)
|
||
timeRange = 'all'
|
||
} = req.query
|
||
|
||
// 解析模型筛选参数
|
||
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
|
||
|
||
// 验证分页参数
|
||
const pageNum = Math.max(1, parseInt(page) || 1)
|
||
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
|
||
|
||
// 验证排序参数(新增 cost 排序)
|
||
const validSortFields = [
|
||
'name',
|
||
'createdAt',
|
||
'expiresAt',
|
||
'lastUsedAt',
|
||
'isActive',
|
||
'status',
|
||
'cost'
|
||
]
|
||
const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt'
|
||
const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'
|
||
|
||
// 获取用户服务来补充owner信息
|
||
const userService = require('../../services/userService')
|
||
|
||
// 如果是绑定账号搜索模式,先刷新账户名称缓存
|
||
if (searchMode === 'bindingAccount' && search) {
|
||
const accountNameCacheService = require('../../services/accountNameCacheService')
|
||
await accountNameCacheService.refreshIfNeeded()
|
||
}
|
||
|
||
let result
|
||
let costSortStatus = null
|
||
|
||
// 如果是费用排序
|
||
if (validSortBy === 'cost') {
|
||
const costRankService = require('../../services/costRankService')
|
||
|
||
// 验证费用排序的时间范围
|
||
const validCostTimeRanges = ['today', '7days', '30days', 'all', 'custom']
|
||
const effectiveCostTimeRange = validCostTimeRanges.includes(costTimeRange)
|
||
? costTimeRange
|
||
: '7days'
|
||
|
||
// 如果是 custom 时间范围,使用实时计算
|
||
if (effectiveCostTimeRange === 'custom') {
|
||
// 验证日期参数
|
||
if (!costStartDate || !costEndDate) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'INVALID_DATE_RANGE',
|
||
message: '自定义时间范围需要提供 costStartDate 和 costEndDate 参数'
|
||
})
|
||
}
|
||
|
||
const start = new Date(costStartDate)
|
||
const end = new Date(costEndDate)
|
||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'INVALID_DATE_FORMAT',
|
||
message: '日期格式无效'
|
||
})
|
||
}
|
||
|
||
if (start > end) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'INVALID_DATE_RANGE',
|
||
message: '开始日期不能晚于结束日期'
|
||
})
|
||
}
|
||
|
||
// 限制最大范围为 365 天
|
||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||
if (daysDiff > 365) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'DATE_RANGE_TOO_LARGE',
|
||
message: '日期范围不能超过365天'
|
||
})
|
||
}
|
||
|
||
logger.info(`📊 Cost sort with custom range: ${costStartDate} to ${costEndDate}`)
|
||
|
||
// 实时计算费用排序
|
||
result = await getApiKeysSortedByCostCustom({
|
||
page: pageNum,
|
||
pageSize: pageSizeNum,
|
||
sortOrder: validSortOrder,
|
||
startDate: costStartDate,
|
||
endDate: costEndDate,
|
||
search,
|
||
searchMode,
|
||
tag,
|
||
isActive,
|
||
modelFilter
|
||
})
|
||
|
||
costSortStatus = {
|
||
status: 'ready',
|
||
isRealTimeCalculation: true
|
||
}
|
||
} else {
|
||
// 使用预计算索引
|
||
const rankStatus = await costRankService.getRankStatus()
|
||
costSortStatus = rankStatus[effectiveCostTimeRange]
|
||
|
||
// 检查索引是否就绪
|
||
if (!costSortStatus || costSortStatus.status !== 'ready') {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: 'RANK_NOT_READY',
|
||
message: `费用排序索引 (${effectiveCostTimeRange}) 正在更新中,请稍后重试`,
|
||
costSortStatus: costSortStatus || { status: 'unknown' }
|
||
})
|
||
}
|
||
|
||
logger.info(`📊 Cost sort using precomputed index: ${effectiveCostTimeRange}`)
|
||
|
||
// 使用预计算索引排序
|
||
result = await getApiKeysSortedByCostPrecomputed({
|
||
page: pageNum,
|
||
pageSize: pageSizeNum,
|
||
sortOrder: validSortOrder,
|
||
costTimeRange: effectiveCostTimeRange,
|
||
search,
|
||
searchMode,
|
||
tag,
|
||
isActive,
|
||
modelFilter
|
||
})
|
||
|
||
costSortStatus.isRealTimeCalculation = false
|
||
}
|
||
} else {
|
||
// 原有的非费用排序逻辑
|
||
result = await redis.getApiKeysPaginated({
|
||
page: pageNum,
|
||
pageSize: pageSizeNum,
|
||
searchMode,
|
||
search,
|
||
tag,
|
||
isActive,
|
||
sortBy: validSortBy,
|
||
sortOrder: validSortOrder,
|
||
modelFilter
|
||
})
|
||
}
|
||
|
||
// 为每个API Key添加owner的displayName
|
||
for (const apiKey of result.items) {
|
||
if (apiKey.userId) {
|
||
try {
|
||
const user = await userService.getUserById(apiKey.userId, false)
|
||
if (user) {
|
||
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||
} else {
|
||
apiKey.ownerDisplayName = 'Unknown User'
|
||
}
|
||
} catch (error) {
|
||
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
|
||
apiKey.ownerDisplayName = 'Unknown User'
|
||
}
|
||
} else {
|
||
apiKey.ownerDisplayName =
|
||
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
||
}
|
||
|
||
// 初始化空的 usage 对象(费用通过 batch-stats 接口获取)
|
||
if (!apiKey.usage) {
|
||
apiKey.usage = { total: { requests: 0, tokens: 0, cost: 0, formattedCost: '$0.00' } }
|
||
}
|
||
}
|
||
|
||
// 返回分页数据
|
||
const responseData = {
|
||
success: true,
|
||
data: {
|
||
items: result.items,
|
||
pagination: result.pagination,
|
||
availableTags: result.availableTags
|
||
},
|
||
// 标记当前请求的时间范围(供前端参考)
|
||
timeRange
|
||
}
|
||
|
||
// 如果是费用排序,附加排序状态
|
||
if (costSortStatus) {
|
||
responseData.data.costSortStatus = costSortStatus
|
||
}
|
||
|
||
return res.json(responseData)
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get API keys:', error)
|
||
return res.status(500).json({ error: 'Failed to get API keys', message: error.message })
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 使用预计算索引进行费用排序的分页查询
|
||
*/
|
||
async function getApiKeysSortedByCostPrecomputed(options) {
|
||
const {
|
||
page,
|
||
pageSize,
|
||
sortOrder,
|
||
costTimeRange,
|
||
search,
|
||
searchMode,
|
||
tag,
|
||
isActive,
|
||
modelFilter = []
|
||
} = options
|
||
const costRankService = require('../../services/costRankService')
|
||
|
||
// 1. 获取排序后的全量 keyId 列表
|
||
const rankedKeyIds = await costRankService.getSortedKeyIds(costTimeRange, sortOrder)
|
||
|
||
if (rankedKeyIds.length === 0) {
|
||
return {
|
||
items: [],
|
||
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||
availableTags: []
|
||
}
|
||
}
|
||
|
||
// 2. 批量获取 API Key 基础数据
|
||
const allKeys = await redis.batchGetApiKeys(rankedKeyIds)
|
||
|
||
// 3. 保持排序顺序(使用 Map 优化查找)
|
||
const keyMap = new Map(allKeys.map((k) => [k.id, k]))
|
||
let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted)
|
||
|
||
// 4. 应用筛选条件
|
||
// 状态筛选
|
||
if (isActive !== '' && isActive !== undefined && isActive !== null) {
|
||
const activeValue = isActive === 'true' || isActive === true
|
||
orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue)
|
||
}
|
||
|
||
// 标签筛选
|
||
if (tag) {
|
||
orderedKeys = orderedKeys.filter((k) => {
|
||
const tags = Array.isArray(k.tags) ? k.tags : []
|
||
return tags.includes(tag)
|
||
})
|
||
}
|
||
|
||
// 搜索筛选
|
||
if (search) {
|
||
const lowerSearch = search.toLowerCase().trim()
|
||
if (searchMode === 'apiKey') {
|
||
orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch))
|
||
} else if (searchMode === 'bindingAccount') {
|
||
const accountNameCacheService = require('../../services/accountNameCacheService')
|
||
orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch)
|
||
}
|
||
}
|
||
|
||
// 模型筛选
|
||
if (modelFilter.length > 0) {
|
||
const keyIdsWithModels = await redis.getKeyIdsWithModels(
|
||
orderedKeys.map((k) => k.id),
|
||
modelFilter
|
||
)
|
||
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
|
||
}
|
||
|
||
// 5. 收集所有可用标签
|
||
const allTags = new Set()
|
||
for (const key of allKeys) {
|
||
if (!key.isDeleted) {
|
||
const tags = Array.isArray(key.tags) ? key.tags : []
|
||
tags.forEach((t) => allTags.add(t))
|
||
}
|
||
}
|
||
const availableTags = [...allTags].sort()
|
||
|
||
// 6. 分页
|
||
const total = orderedKeys.length
|
||
const totalPages = Math.ceil(total / pageSize) || 1
|
||
const validPage = Math.min(Math.max(1, page), totalPages)
|
||
const start = (validPage - 1) * pageSize
|
||
const items = orderedKeys.slice(start, start + pageSize)
|
||
|
||
// 7. 为当前页的 Keys 附加费用数据
|
||
const keyCosts = await costRankService.getBatchKeyCosts(
|
||
costTimeRange,
|
||
items.map((k) => k.id)
|
||
)
|
||
for (const key of items) {
|
||
key._cost = keyCosts.get(key.id) || 0
|
||
}
|
||
|
||
return {
|
||
items,
|
||
pagination: {
|
||
page: validPage,
|
||
pageSize,
|
||
total,
|
||
totalPages
|
||
},
|
||
availableTags
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用实时计算进行 custom 时间范围的费用排序
|
||
*/
|
||
async function getApiKeysSortedByCostCustom(options) {
|
||
const {
|
||
page,
|
||
pageSize,
|
||
sortOrder,
|
||
startDate,
|
||
endDate,
|
||
search,
|
||
searchMode,
|
||
tag,
|
||
isActive,
|
||
modelFilter = []
|
||
} = options
|
||
const costRankService = require('../../services/costRankService')
|
||
|
||
// 1. 实时计算所有 Keys 的费用
|
||
const costs = await costRankService.calculateCustomRangeCosts(startDate, endDate)
|
||
|
||
if (costs.size === 0) {
|
||
return {
|
||
items: [],
|
||
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||
availableTags: []
|
||
}
|
||
}
|
||
|
||
// 2. 转换为数组并排序
|
||
const sortedEntries = [...costs.entries()].sort((a, b) =>
|
||
sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
|
||
)
|
||
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
|
||
|
||
// 3. 批量获取 API Key 基础数据
|
||
const allKeys = await redis.batchGetApiKeys(rankedKeyIds)
|
||
|
||
// 4. 保持排序顺序
|
||
const keyMap = new Map(allKeys.map((k) => [k.id, k]))
|
||
let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted)
|
||
|
||
// 5. 应用筛选条件
|
||
// 状态筛选
|
||
if (isActive !== '' && isActive !== undefined && isActive !== null) {
|
||
const activeValue = isActive === 'true' || isActive === true
|
||
orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue)
|
||
}
|
||
|
||
// 标签筛选
|
||
if (tag) {
|
||
orderedKeys = orderedKeys.filter((k) => {
|
||
const tags = Array.isArray(k.tags) ? k.tags : []
|
||
return tags.includes(tag)
|
||
})
|
||
}
|
||
|
||
// 搜索筛选
|
||
if (search) {
|
||
const lowerSearch = search.toLowerCase().trim()
|
||
if (searchMode === 'apiKey') {
|
||
orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch))
|
||
} else if (searchMode === 'bindingAccount') {
|
||
const accountNameCacheService = require('../../services/accountNameCacheService')
|
||
orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch)
|
||
}
|
||
}
|
||
|
||
// 模型筛选
|
||
if (modelFilter.length > 0) {
|
||
const keyIdsWithModels = await redis.getKeyIdsWithModels(
|
||
orderedKeys.map((k) => k.id),
|
||
modelFilter
|
||
)
|
||
orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id))
|
||
}
|
||
|
||
// 6. 收集所有可用标签
|
||
const allTags = new Set()
|
||
for (const key of allKeys) {
|
||
if (!key.isDeleted) {
|
||
const tags = Array.isArray(key.tags) ? key.tags : []
|
||
tags.forEach((t) => allTags.add(t))
|
||
}
|
||
}
|
||
const availableTags = [...allTags].sort()
|
||
|
||
// 7. 分页
|
||
const total = orderedKeys.length
|
||
const totalPages = Math.ceil(total / pageSize) || 1
|
||
const validPage = Math.min(Math.max(1, page), totalPages)
|
||
const start = (validPage - 1) * pageSize
|
||
const items = orderedKeys.slice(start, start + pageSize)
|
||
|
||
// 8. 为当前页的 Keys 附加费用数据
|
||
for (const key of items) {
|
||
key._cost = costs.get(key.id) || 0
|
||
}
|
||
|
||
return {
|
||
items,
|
||
pagination: {
|
||
page: validPage,
|
||
pageSize,
|
||
total,
|
||
totalPages
|
||
},
|
||
availableTags
|
||
}
|
||
}
|
||
|
||
// 获取费用排序索引状态
|
||
router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const costRankService = require('../../services/costRankService')
|
||
const status = await costRankService.getRankStatus()
|
||
return res.json({ success: true, data: status })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get cost sort status:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to get cost sort status',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 强制刷新费用排序索引
|
||
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { timeRange } = req.body
|
||
const costRankService = require('../../services/costRankService')
|
||
|
||
// 验证时间范围
|
||
if (timeRange) {
|
||
const validTimeRanges = ['today', '7days', '30days', 'all']
|
||
if (!validTimeRanges.includes(timeRange)) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'INVALID_TIME_RANGE',
|
||
message: '无效的时间范围,可选值:today, 7days, 30days, all'
|
||
})
|
||
}
|
||
}
|
||
|
||
// 异步刷新,不等待完成
|
||
costRankService.forceRefresh(timeRange || null).catch((err) => {
|
||
logger.error('❌ Failed to refresh cost rank:', err)
|
||
})
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: timeRange ? `费用排序索引 (${timeRange}) 刷新已开始` : '所有费用排序索引刷新已开始'
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to trigger cost sort refresh:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to trigger refresh',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 获取支持的客户端列表(使用新的验证器)
|
||
router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
// 使用新的 ClientValidator 获取所有可用客户端
|
||
const ClientValidator = require('../../validators/clientValidator')
|
||
const availableClients = ClientValidator.getAvailableClients()
|
||
|
||
// 格式化返回数据
|
||
const clients = availableClients.map((client) => ({
|
||
id: client.id,
|
||
name: client.name,
|
||
description: client.description,
|
||
icon: client.icon
|
||
}))
|
||
|
||
logger.info(`📱 Returning ${clients.length} supported clients`)
|
||
return res.json({ success: true, data: clients })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get supported clients:', error)
|
||
return res
|
||
.status(500)
|
||
.json({ error: 'Failed to get supported clients', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 获取已存在的标签列表
|
||
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||
const tagSet = new Set()
|
||
|
||
// 收集所有API Keys的标签
|
||
for (const apiKey of apiKeys) {
|
||
if (apiKey.tags && Array.isArray(apiKey.tags)) {
|
||
apiKey.tags.forEach((tag) => {
|
||
if (tag && tag.trim()) {
|
||
tagSet.add(tag.trim())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 转换为数组并排序
|
||
const tags = Array.from(tagSet).sort()
|
||
|
||
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
|
||
return res.json({ success: true, data: tags })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get API key tags:', error)
|
||
return res.status(500).json({ error: 'Failed to get API key tags', message: error.message })
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取账户绑定的 API Key 数量统计
|
||
* GET /admin/accounts/binding-counts
|
||
*
|
||
* 返回每种账户类型的绑定数量统计,用于账户列表页面显示"绑定: X 个API Key"
|
||
* 这是一个轻量级接口,只返回计数而不是完整的 API Key 数据
|
||
*/
|
||
router.get('/accounts/binding-counts', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
// 使用优化的分页方法获取所有非删除的 API Keys(只需要绑定字段)
|
||
const result = await redis.getApiKeysPaginated({
|
||
page: 1,
|
||
pageSize: 10000, // 获取所有
|
||
excludeDeleted: true
|
||
})
|
||
|
||
const apiKeys = result.items
|
||
|
||
// 初始化统计对象
|
||
const bindingCounts = {
|
||
claudeAccountId: {},
|
||
claudeConsoleAccountId: {},
|
||
geminiAccountId: {},
|
||
openaiAccountId: {},
|
||
azureOpenaiAccountId: {},
|
||
bedrockAccountId: {},
|
||
droidAccountId: {},
|
||
ccrAccountId: {}
|
||
}
|
||
|
||
// 遍历一次,统计每个账户的绑定数量
|
||
for (const key of apiKeys) {
|
||
// Claude 账户
|
||
if (key.claudeAccountId) {
|
||
const id = key.claudeAccountId
|
||
bindingCounts.claudeAccountId[id] = (bindingCounts.claudeAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// Claude Console 账户
|
||
if (key.claudeConsoleAccountId) {
|
||
const id = key.claudeConsoleAccountId
|
||
bindingCounts.claudeConsoleAccountId[id] =
|
||
(bindingCounts.claudeConsoleAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// Gemini 账户(包括 api: 前缀的 Gemini-API 账户)
|
||
if (key.geminiAccountId) {
|
||
const id = key.geminiAccountId
|
||
bindingCounts.geminiAccountId[id] = (bindingCounts.geminiAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// OpenAI 账户(包括 responses: 前缀的 OpenAI-Responses 账户)
|
||
if (key.openaiAccountId) {
|
||
const id = key.openaiAccountId
|
||
bindingCounts.openaiAccountId[id] = (bindingCounts.openaiAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// Azure OpenAI 账户
|
||
if (key.azureOpenaiAccountId) {
|
||
const id = key.azureOpenaiAccountId
|
||
bindingCounts.azureOpenaiAccountId[id] = (bindingCounts.azureOpenaiAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// Bedrock 账户
|
||
if (key.bedrockAccountId) {
|
||
const id = key.bedrockAccountId
|
||
bindingCounts.bedrockAccountId[id] = (bindingCounts.bedrockAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// Droid 账户
|
||
if (key.droidAccountId) {
|
||
const id = key.droidAccountId
|
||
bindingCounts.droidAccountId[id] = (bindingCounts.droidAccountId[id] || 0) + 1
|
||
}
|
||
|
||
// CCR 账户
|
||
if (key.ccrAccountId) {
|
||
const id = key.ccrAccountId
|
||
bindingCounts.ccrAccountId[id] = (bindingCounts.ccrAccountId[id] || 0) + 1
|
||
}
|
||
}
|
||
|
||
logger.debug(`📊 Account binding counts calculated from ${apiKeys.length} API keys`)
|
||
return res.json({ success: true, data: bindingCounts })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get account binding counts:', error)
|
||
return res.status(500).json({
|
||
error: 'Failed to get account binding counts',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 批量获取指定 Keys 的统计数据和费用
|
||
* POST /admin/api-keys/batch-stats
|
||
*
|
||
* 用于 API Keys 列表页面异步加载统计数据
|
||
*/
|
||
router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const {
|
||
keyIds, // 必需:API Key ID 数组
|
||
timeRange = 'all', // 时间范围:all, today, 7days, monthly, custom
|
||
startDate, // custom 时必需
|
||
endDate // custom 时必需
|
||
} = req.body
|
||
|
||
// 参数验证
|
||
if (!Array.isArray(keyIds) || keyIds.length === 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'keyIds is required and must be a non-empty array'
|
||
})
|
||
}
|
||
|
||
// 限制单次最多处理 100 个 Key
|
||
if (keyIds.length > 100) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Max 100 keys per request'
|
||
})
|
||
}
|
||
|
||
// 验证 custom 时间范围的参数
|
||
if (timeRange === 'custom') {
|
||
if (!startDate || !endDate) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'startDate and endDate are required for custom time range'
|
||
})
|
||
}
|
||
const start = new Date(startDate)
|
||
const end = new Date(endDate)
|
||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Invalid date format'
|
||
})
|
||
}
|
||
if (start > end) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'startDate must be before or equal to endDate'
|
||
})
|
||
}
|
||
// 限制最大范围为 365 天
|
||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||
if (daysDiff > 365) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Date range cannot exceed 365 days'
|
||
})
|
||
}
|
||
}
|
||
|
||
logger.info(
|
||
`📊 Batch stats request: ${keyIds.length} keys, timeRange=${timeRange}`,
|
||
timeRange === 'custom' ? `, ${startDate} to ${endDate}` : ''
|
||
)
|
||
|
||
const stats = {}
|
||
|
||
// 并行计算每个 Key 的统计数据
|
||
await Promise.all(
|
||
keyIds.map(async (keyId) => {
|
||
try {
|
||
stats[keyId] = await calculateKeyStats(keyId, timeRange, startDate, endDate)
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to calculate stats for key ${keyId}:`, error)
|
||
stats[keyId] = {
|
||
requests: 0,
|
||
tokens: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
cost: 0,
|
||
formattedCost: '$0.00',
|
||
dailyCost: 0,
|
||
currentWindowCost: 0,
|
||
windowRemainingSeconds: null,
|
||
allTimeCost: 0,
|
||
error: error.message
|
||
}
|
||
}
|
||
})
|
||
)
|
||
|
||
return res.json({ success: true, data: stats })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to calculate batch stats:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to calculate stats',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 计算单个 Key 的统计数据
|
||
* @param {string} keyId - API Key ID
|
||
* @param {string} timeRange - 时间范围
|
||
* @param {string} startDate - 开始日期 (custom 模式)
|
||
* @param {string} endDate - 结束日期 (custom 模式)
|
||
* @returns {Object} 统计数据
|
||
*/
|
||
async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
||
const client = redis.getClientSafe()
|
||
const tzDate = redis.getDateInTimezone()
|
||
const today = redis.getDateStringInTimezone()
|
||
|
||
// 构建搜索模式
|
||
const searchPatterns = []
|
||
|
||
if (timeRange === 'custom' && startDate && endDate) {
|
||
// 自定义日期范围
|
||
const start = new Date(startDate)
|
||
const end = new Date(endDate)
|
||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||
const dateStr = redis.getDateStringInTimezone(d)
|
||
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
|
||
}
|
||
} else if (timeRange === 'today') {
|
||
searchPatterns.push(`usage:${keyId}:model:daily:*:${today}`)
|
||
} else if (timeRange === '7days') {
|
||
// 最近7天
|
||
for (let i = 0; i < 7; i++) {
|
||
const d = new Date(tzDate)
|
||
d.setDate(d.getDate() - i)
|
||
const dateStr = redis.getDateStringInTimezone(d)
|
||
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
|
||
}
|
||
} else if (timeRange === 'monthly') {
|
||
// 当月
|
||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||
searchPatterns.push(`usage:${keyId}:model:monthly:*:${currentMonth}`)
|
||
} else {
|
||
// all - 获取所有数据(日和月数据都查)
|
||
searchPatterns.push(`usage:${keyId}:model:daily:*`)
|
||
searchPatterns.push(`usage:${keyId}:model:monthly:*`)
|
||
}
|
||
|
||
// 使用 SCAN 收集所有匹配的 keys
|
||
const allKeys = []
|
||
for (const pattern of searchPatterns) {
|
||
let cursor = '0'
|
||
do {
|
||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100)
|
||
cursor = newCursor
|
||
allKeys.push(...keys)
|
||
} while (cursor !== '0')
|
||
}
|
||
|
||
// 去重(避免日数据和月数据重复计算)
|
||
const uniqueKeys = [...new Set(allKeys)]
|
||
|
||
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||
let dailyCost = 0
|
||
let currentWindowCost = 0
|
||
let windowRemainingSeconds = null
|
||
let windowStartTime = null
|
||
let windowEndTime = null
|
||
let allTimeCost = 0
|
||
|
||
try {
|
||
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||
const apiKey = await redis.getApiKey(keyId)
|
||
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||
|
||
// 只在启用了每日费用限制时查询
|
||
if (dailyCostLimit > 0) {
|
||
dailyCost = await redis.getDailyCost(keyId)
|
||
}
|
||
|
||
// 只在启用了总费用限制时查询
|
||
if (totalCostLimit > 0) {
|
||
const totalCostKey = `usage:cost:total:${keyId}`
|
||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||
}
|
||
|
||
// 只在启用了窗口限制时查询窗口数据
|
||
if (rateLimitWindow > 0) {
|
||
const costCountKey = `rate_limit:cost:${keyId}`
|
||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||
|
||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||
|
||
// 获取窗口开始时间和计算剩余时间
|
||
const windowStart = await client.get(windowStartKey)
|
||
if (windowStart) {
|
||
const now = Date.now()
|
||
windowStartTime = parseInt(windowStart)
|
||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||
windowEndTime = windowStartTime + windowDuration
|
||
|
||
// 如果窗口还有效
|
||
if (now < windowEndTime) {
|
||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||
} else {
|
||
// 窗口已过期
|
||
windowRemainingSeconds = 0
|
||
currentWindowCost = 0
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||
}
|
||
|
||
// 如果没有使用数据,返回零值但包含窗口数据
|
||
if (uniqueKeys.length === 0) {
|
||
return {
|
||
requests: 0,
|
||
tokens: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
cost: 0,
|
||
formattedCost: '$0.00',
|
||
// 实时限制数据(始终返回,不受时间范围影响)
|
||
dailyCost,
|
||
currentWindowCost,
|
||
windowRemainingSeconds,
|
||
windowStartTime,
|
||
windowEndTime,
|
||
allTimeCost
|
||
}
|
||
}
|
||
|
||
// 使用 Pipeline 批量获取数据
|
||
const pipeline = client.pipeline()
|
||
for (const key of uniqueKeys) {
|
||
pipeline.hgetall(key)
|
||
}
|
||
const results = await pipeline.exec()
|
||
|
||
// 汇总计算
|
||
const modelStatsMap = new Map()
|
||
let totalRequests = 0
|
||
|
||
// 用于去重:只统计日数据,避免与月数据重复
|
||
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||
|
||
// 检查是否有日数据
|
||
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
|
||
|
||
for (let i = 0; i < results.length; i++) {
|
||
const [err, data] = results[i]
|
||
if (err || !data || Object.keys(data).length === 0) {
|
||
continue
|
||
}
|
||
|
||
const key = uniqueKeys[i]
|
||
let model = null
|
||
let isMonthly = false
|
||
|
||
// 提取模型名称
|
||
const dailyMatch = key.match(dailyKeyPattern)
|
||
const monthlyMatch = key.match(monthlyKeyPattern)
|
||
|
||
if (dailyMatch) {
|
||
model = dailyMatch[1]
|
||
} else if (monthlyMatch) {
|
||
model = monthlyMatch[1]
|
||
isMonthly = true
|
||
}
|
||
|
||
if (!model) {
|
||
continue
|
||
}
|
||
|
||
// 如果有日数据,则跳过月数据以避免重复
|
||
if (hasDailyData && isMonthly) {
|
||
continue
|
||
}
|
||
|
||
if (!modelStatsMap.has(model)) {
|
||
modelStatsMap.set(model, {
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
requests: 0
|
||
})
|
||
}
|
||
|
||
const stats = modelStatsMap.get(model)
|
||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
|
||
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
|
||
stats.cacheCreateTokens +=
|
||
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
|
||
stats.cacheReadTokens +=
|
||
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
||
stats.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
|
||
|
||
totalRequests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
|
||
}
|
||
|
||
// 计算费用
|
||
let totalCost = 0
|
||
let inputTokens = 0
|
||
let outputTokens = 0
|
||
let cacheCreateTokens = 0
|
||
let cacheReadTokens = 0
|
||
|
||
for (const [model, stats] of modelStatsMap) {
|
||
inputTokens += stats.inputTokens
|
||
outputTokens += stats.outputTokens
|
||
cacheCreateTokens += stats.cacheCreateTokens
|
||
cacheReadTokens += stats.cacheReadTokens
|
||
|
||
const costResult = CostCalculator.calculateCost(
|
||
{
|
||
input_tokens: stats.inputTokens,
|
||
output_tokens: stats.outputTokens,
|
||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||
cache_read_input_tokens: stats.cacheReadTokens
|
||
},
|
||
model
|
||
)
|
||
totalCost += costResult.costs.total
|
||
}
|
||
|
||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||
|
||
return {
|
||
requests: totalRequests,
|
||
tokens,
|
||
inputTokens,
|
||
outputTokens,
|
||
cacheCreateTokens,
|
||
cacheReadTokens,
|
||
cost: totalCost,
|
||
formattedCost: CostCalculator.formatCost(totalCost),
|
||
// 实时限制数据
|
||
dailyCost,
|
||
currentWindowCost,
|
||
windowRemainingSeconds,
|
||
windowStartTime,
|
||
windowEndTime,
|
||
allTimeCost // 历史总费用(用于总费用限制)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量获取指定 Keys 的最后使用账号信息
|
||
* POST /admin/api-keys/batch-last-usage
|
||
*
|
||
* 用于 API Keys 列表页面异步加载最后使用账号数据
|
||
*/
|
||
router.post('/api-keys/batch-last-usage', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyIds } = req.body
|
||
|
||
// 参数验证
|
||
if (!Array.isArray(keyIds) || keyIds.length === 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'keyIds is required and must be a non-empty array'
|
||
})
|
||
}
|
||
|
||
// 限制单次最多处理 100 个 Key
|
||
if (keyIds.length > 100) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Max 100 keys per request'
|
||
})
|
||
}
|
||
|
||
logger.debug(`📊 Batch last-usage request: ${keyIds.length} keys`)
|
||
|
||
const client = redis.getClientSafe()
|
||
const lastUsageData = {}
|
||
const accountInfoCache = new Map()
|
||
|
||
// 并行获取每个 Key 的最后使用记录
|
||
await Promise.all(
|
||
keyIds.map(async (keyId) => {
|
||
try {
|
||
// 获取最新的使用记录
|
||
const usageRecords = await redis.getUsageRecords(keyId, 1)
|
||
if (!Array.isArray(usageRecords) || usageRecords.length === 0) {
|
||
lastUsageData[keyId] = null
|
||
return
|
||
}
|
||
|
||
const lastUsageRecord = usageRecords[0]
|
||
if (!lastUsageRecord || (!lastUsageRecord.accountId && !lastUsageRecord.accountType)) {
|
||
lastUsageData[keyId] = null
|
||
return
|
||
}
|
||
|
||
// 解析账号信息
|
||
const resolvedAccount = await apiKeyService._resolveAccountByUsageRecord(
|
||
lastUsageRecord,
|
||
accountInfoCache,
|
||
client
|
||
)
|
||
|
||
if (resolvedAccount) {
|
||
lastUsageData[keyId] = {
|
||
accountId: resolvedAccount.accountId,
|
||
rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId,
|
||
accountType: resolvedAccount.accountType,
|
||
accountCategory: resolvedAccount.accountCategory,
|
||
accountName: resolvedAccount.accountName,
|
||
recordedAt: lastUsageRecord.timestamp || null
|
||
}
|
||
} else {
|
||
// 账号已删除
|
||
lastUsageData[keyId] = {
|
||
accountId: null,
|
||
rawAccountId: lastUsageRecord.accountId || null,
|
||
accountType: 'deleted',
|
||
accountCategory: 'deleted',
|
||
accountName: '已删除',
|
||
recordedAt: lastUsageRecord.timestamp || null
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.debug(`获取 API Key ${keyId} 的最后使用记录失败:`, error)
|
||
lastUsageData[keyId] = null
|
||
}
|
||
})
|
||
)
|
||
|
||
return res.json({ success: true, data: lastUsageData })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get batch last-usage:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to get last-usage data',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 创建新的API Key
|
||
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const {
|
||
name,
|
||
description,
|
||
tokenLimit,
|
||
expiresAt,
|
||
claudeAccountId,
|
||
claudeConsoleAccountId,
|
||
geminiAccountId,
|
||
openaiAccountId,
|
||
bedrockAccountId,
|
||
droidAccountId,
|
||
permissions,
|
||
concurrencyLimit,
|
||
rateLimitWindow,
|
||
rateLimitRequests,
|
||
rateLimitCost,
|
||
enableModelRestriction,
|
||
restrictedModels,
|
||
enableClientRestriction,
|
||
allowedClients,
|
||
dailyCostLimit,
|
||
totalCostLimit,
|
||
weeklyOpusCostLimit,
|
||
tags,
|
||
activationDays, // 新增:激活后有效天数
|
||
activationUnit, // 新增:激活时间单位 (hours/days)
|
||
expirationMode, // 新增:过期模式
|
||
icon // 新增:图标
|
||
} = req.body
|
||
|
||
// 输入验证
|
||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||
return res.status(400).json({ error: 'Name is required and must be a non-empty string' })
|
||
}
|
||
|
||
if (name.length > 100) {
|
||
return res.status(400).json({ error: 'Name must be less than 100 characters' })
|
||
}
|
||
|
||
if (description && (typeof description !== 'string' || description.length > 500)) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Description must be a string with less than 500 characters' })
|
||
}
|
||
|
||
if (tokenLimit && (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0)) {
|
||
return res.status(400).json({ error: 'Token limit must be a non-negative integer' })
|
||
}
|
||
|
||
if (
|
||
concurrencyLimit !== undefined &&
|
||
concurrencyLimit !== null &&
|
||
concurrencyLimit !== '' &&
|
||
(!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0)
|
||
) {
|
||
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' })
|
||
}
|
||
|
||
if (
|
||
rateLimitWindow !== undefined &&
|
||
rateLimitWindow !== null &&
|
||
rateLimitWindow !== '' &&
|
||
(!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 1)
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Rate limit window must be a positive integer (minutes)' })
|
||
}
|
||
|
||
if (
|
||
rateLimitRequests !== undefined &&
|
||
rateLimitRequests !== null &&
|
||
rateLimitRequests !== '' &&
|
||
(!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 1)
|
||
) {
|
||
return res.status(400).json({ error: 'Rate limit requests must be a positive integer' })
|
||
}
|
||
|
||
// 验证模型限制字段
|
||
if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') {
|
||
return res.status(400).json({ error: 'Enable model restriction must be a boolean' })
|
||
}
|
||
|
||
if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) {
|
||
return res.status(400).json({ error: 'Restricted models must be an array' })
|
||
}
|
||
|
||
// 验证客户端限制字段
|
||
if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') {
|
||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' })
|
||
}
|
||
|
||
if (allowedClients !== undefined && !Array.isArray(allowedClients)) {
|
||
return res.status(400).json({ error: 'Allowed clients must be an array' })
|
||
}
|
||
|
||
// 验证标签字段
|
||
if (tags !== undefined && !Array.isArray(tags)) {
|
||
return res.status(400).json({ error: 'Tags must be an array' })
|
||
}
|
||
|
||
if (tags && tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) {
|
||
return res.status(400).json({ error: 'All tags must be non-empty strings' })
|
||
}
|
||
|
||
if (
|
||
totalCostLimit !== undefined &&
|
||
totalCostLimit !== null &&
|
||
totalCostLimit !== '' &&
|
||
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
|
||
) {
|
||
return res.status(400).json({ error: 'Total cost limit must be a non-negative number' })
|
||
}
|
||
|
||
// 验证激活相关字段
|
||
if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Expiration mode must be either "fixed" or "activation"' })
|
||
}
|
||
|
||
if (expirationMode === 'activation') {
|
||
// 验证激活时间单位
|
||
if (!activationUnit || !['hours', 'days'].includes(activationUnit)) {
|
||
return res.status(400).json({
|
||
error: 'Activation unit must be either "hours" or "days" when using activation mode'
|
||
})
|
||
}
|
||
|
||
// 验证激活时间数值
|
||
if (
|
||
!activationDays ||
|
||
!Number.isInteger(Number(activationDays)) ||
|
||
Number(activationDays) < 1
|
||
) {
|
||
const unitText = activationUnit === 'hours' ? 'hours' : 'days'
|
||
return res.status(400).json({
|
||
error: `Activation ${unitText} must be a positive integer when using activation mode`
|
||
})
|
||
}
|
||
// 激活模式下不应该设置固定过期时间
|
||
if (expiresAt) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Cannot set fixed expiration date when using activation mode' })
|
||
}
|
||
}
|
||
|
||
// 验证服务权限字段
|
||
if (
|
||
permissions !== undefined &&
|
||
permissions !== null &&
|
||
permissions !== '' &&
|
||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||
) {
|
||
return res.status(400).json({
|
||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||
})
|
||
}
|
||
|
||
const newKey = await apiKeyService.generateApiKey({
|
||
name,
|
||
description,
|
||
tokenLimit,
|
||
expiresAt,
|
||
claudeAccountId,
|
||
claudeConsoleAccountId,
|
||
geminiAccountId,
|
||
openaiAccountId,
|
||
bedrockAccountId,
|
||
droidAccountId,
|
||
permissions,
|
||
concurrencyLimit,
|
||
rateLimitWindow,
|
||
rateLimitRequests,
|
||
rateLimitCost,
|
||
enableModelRestriction,
|
||
restrictedModels,
|
||
enableClientRestriction,
|
||
allowedClients,
|
||
dailyCostLimit,
|
||
totalCostLimit,
|
||
weeklyOpusCostLimit,
|
||
tags,
|
||
activationDays,
|
||
activationUnit,
|
||
expirationMode,
|
||
icon
|
||
})
|
||
|
||
logger.success(`🔑 Admin created new API key: ${name}`)
|
||
return res.json({ success: true, data: newKey })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to create API key:', error)
|
||
return res.status(500).json({ error: 'Failed to create API key', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 批量创建API Keys
|
||
router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const {
|
||
baseName,
|
||
count,
|
||
description,
|
||
tokenLimit,
|
||
expiresAt,
|
||
claudeAccountId,
|
||
claudeConsoleAccountId,
|
||
geminiAccountId,
|
||
openaiAccountId,
|
||
bedrockAccountId,
|
||
droidAccountId,
|
||
permissions,
|
||
concurrencyLimit,
|
||
rateLimitWindow,
|
||
rateLimitRequests,
|
||
rateLimitCost,
|
||
enableModelRestriction,
|
||
restrictedModels,
|
||
enableClientRestriction,
|
||
allowedClients,
|
||
dailyCostLimit,
|
||
totalCostLimit,
|
||
weeklyOpusCostLimit,
|
||
tags,
|
||
activationDays,
|
||
activationUnit,
|
||
expirationMode,
|
||
icon
|
||
} = req.body
|
||
|
||
// 输入验证
|
||
if (!baseName || typeof baseName !== 'string' || baseName.trim().length === 0) {
|
||
return res.status(400).json({ error: 'Base name is required and must be a non-empty string' })
|
||
}
|
||
|
||
if (!count || !Number.isInteger(count) || count < 2 || count > 500) {
|
||
return res.status(400).json({ error: 'Count must be an integer between 2 and 500' })
|
||
}
|
||
|
||
if (baseName.length > 90) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||
}
|
||
|
||
if (
|
||
permissions !== undefined &&
|
||
permissions !== null &&
|
||
permissions !== '' &&
|
||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||
) {
|
||
return res.status(400).json({
|
||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||
})
|
||
}
|
||
|
||
// 生成批量API Keys
|
||
const createdKeys = []
|
||
const errors = []
|
||
|
||
for (let i = 1; i <= count; i++) {
|
||
try {
|
||
const name = `${baseName}_${i}`
|
||
const newKey = await apiKeyService.generateApiKey({
|
||
name,
|
||
description,
|
||
tokenLimit,
|
||
expiresAt,
|
||
claudeAccountId,
|
||
claudeConsoleAccountId,
|
||
geminiAccountId,
|
||
openaiAccountId,
|
||
bedrockAccountId,
|
||
droidAccountId,
|
||
permissions,
|
||
concurrencyLimit,
|
||
rateLimitWindow,
|
||
rateLimitRequests,
|
||
rateLimitCost,
|
||
enableModelRestriction,
|
||
restrictedModels,
|
||
enableClientRestriction,
|
||
allowedClients,
|
||
dailyCostLimit,
|
||
totalCostLimit,
|
||
weeklyOpusCostLimit,
|
||
tags,
|
||
activationDays,
|
||
activationUnit,
|
||
expirationMode,
|
||
icon
|
||
})
|
||
|
||
// 保留原始 API Key 供返回
|
||
createdKeys.push({
|
||
...newKey,
|
||
apiKey: newKey.apiKey
|
||
})
|
||
} catch (error) {
|
||
errors.push({
|
||
index: i,
|
||
name: `${baseName}_${i}`,
|
||
error: error.message
|
||
})
|
||
}
|
||
}
|
||
|
||
// 如果有部分失败,返回部分成功的结果
|
||
if (errors.length > 0 && createdKeys.length === 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Failed to create any API keys',
|
||
errors
|
||
})
|
||
}
|
||
|
||
// 返回创建的keys(包含完整的apiKey)
|
||
return res.json({
|
||
success: true,
|
||
data: createdKeys,
|
||
errors: errors.length > 0 ? errors : undefined,
|
||
summary: {
|
||
requested: count,
|
||
created: createdKeys.length,
|
||
failed: errors.length
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logger.error('Failed to batch create API keys:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to batch create API keys',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 批量编辑API Keys
|
||
router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyIds, updates } = req.body
|
||
|
||
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
||
return res.status(400).json({
|
||
error: 'Invalid input',
|
||
message: 'keyIds must be a non-empty array'
|
||
})
|
||
}
|
||
|
||
if (!updates || typeof updates !== 'object') {
|
||
return res.status(400).json({
|
||
error: 'Invalid input',
|
||
message: 'updates must be an object'
|
||
})
|
||
}
|
||
|
||
if (
|
||
updates.permissions !== undefined &&
|
||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
||
) {
|
||
return res.status(400).json({
|
||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||
})
|
||
}
|
||
|
||
logger.info(
|
||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||
)
|
||
logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`)
|
||
|
||
const results = {
|
||
successCount: 0,
|
||
failedCount: 0,
|
||
errors: []
|
||
}
|
||
|
||
// 处理每个API Key
|
||
for (const keyId of keyIds) {
|
||
try {
|
||
// 获取当前API Key信息
|
||
const currentKey = await redis.getApiKey(keyId)
|
||
if (!currentKey || Object.keys(currentKey).length === 0) {
|
||
results.failedCount++
|
||
results.errors.push(`API key ${keyId} not found`)
|
||
continue
|
||
}
|
||
|
||
// 构建最终更新数据
|
||
const finalUpdates = {}
|
||
|
||
// 处理普通字段
|
||
if (updates.name) {
|
||
finalUpdates.name = updates.name
|
||
}
|
||
if (updates.tokenLimit !== undefined) {
|
||
finalUpdates.tokenLimit = updates.tokenLimit
|
||
}
|
||
if (updates.rateLimitCost !== undefined) {
|
||
finalUpdates.rateLimitCost = updates.rateLimitCost
|
||
}
|
||
if (updates.concurrencyLimit !== undefined) {
|
||
finalUpdates.concurrencyLimit = updates.concurrencyLimit
|
||
}
|
||
if (updates.rateLimitWindow !== undefined) {
|
||
finalUpdates.rateLimitWindow = updates.rateLimitWindow
|
||
}
|
||
if (updates.rateLimitRequests !== undefined) {
|
||
finalUpdates.rateLimitRequests = updates.rateLimitRequests
|
||
}
|
||
if (updates.dailyCostLimit !== undefined) {
|
||
finalUpdates.dailyCostLimit = updates.dailyCostLimit
|
||
}
|
||
if (updates.totalCostLimit !== undefined) {
|
||
finalUpdates.totalCostLimit = updates.totalCostLimit
|
||
}
|
||
if (updates.weeklyOpusCostLimit !== undefined) {
|
||
finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit
|
||
}
|
||
if (updates.permissions !== undefined) {
|
||
finalUpdates.permissions = updates.permissions
|
||
}
|
||
if (updates.isActive !== undefined) {
|
||
finalUpdates.isActive = updates.isActive
|
||
}
|
||
if (updates.monthlyLimit !== undefined) {
|
||
finalUpdates.monthlyLimit = updates.monthlyLimit
|
||
}
|
||
if (updates.priority !== undefined) {
|
||
finalUpdates.priority = updates.priority
|
||
}
|
||
if (updates.enabled !== undefined) {
|
||
finalUpdates.enabled = updates.enabled
|
||
}
|
||
|
||
// 处理账户绑定
|
||
if (updates.claudeAccountId !== undefined) {
|
||
finalUpdates.claudeAccountId = updates.claudeAccountId
|
||
}
|
||
if (updates.claudeConsoleAccountId !== undefined) {
|
||
finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId
|
||
}
|
||
if (updates.geminiAccountId !== undefined) {
|
||
finalUpdates.geminiAccountId = updates.geminiAccountId
|
||
}
|
||
if (updates.openaiAccountId !== undefined) {
|
||
finalUpdates.openaiAccountId = updates.openaiAccountId
|
||
}
|
||
if (updates.bedrockAccountId !== undefined) {
|
||
finalUpdates.bedrockAccountId = updates.bedrockAccountId
|
||
}
|
||
if (updates.droidAccountId !== undefined) {
|
||
finalUpdates.droidAccountId = updates.droidAccountId || ''
|
||
}
|
||
|
||
// 处理标签操作
|
||
if (updates.tags !== undefined) {
|
||
if (updates.tagOperation) {
|
||
const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : []
|
||
const operationTags = updates.tags
|
||
|
||
switch (updates.tagOperation) {
|
||
case 'replace': {
|
||
finalUpdates.tags = operationTags
|
||
break
|
||
}
|
||
case 'add': {
|
||
const newTags = [...currentTags]
|
||
operationTags.forEach((tag) => {
|
||
if (!newTags.includes(tag)) {
|
||
newTags.push(tag)
|
||
}
|
||
})
|
||
finalUpdates.tags = newTags
|
||
break
|
||
}
|
||
case 'remove': {
|
||
finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag))
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
// 如果没有指定操作类型,默认为替换
|
||
finalUpdates.tags = updates.tags
|
||
}
|
||
}
|
||
|
||
// 执行更新
|
||
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
||
results.successCount++
|
||
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
|
||
} catch (error) {
|
||
results.failedCount++
|
||
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
||
logger.error(`❌ Batch edit failed for key ${keyId}:`, error)
|
||
}
|
||
}
|
||
|
||
// 记录批量编辑结果
|
||
if (results.successCount > 0) {
|
||
logger.success(
|
||
`🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed`
|
||
)
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed`
|
||
)
|
||
}
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: `批量编辑完成`,
|
||
data: results
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to batch edit API keys:', error)
|
||
return res.status(500).json({
|
||
error: 'Batch edit failed',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 更新API Key
|
||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
const {
|
||
name, // 添加名称字段
|
||
tokenLimit,
|
||
concurrencyLimit,
|
||
rateLimitWindow,
|
||
rateLimitRequests,
|
||
rateLimitCost,
|
||
isActive,
|
||
claudeAccountId,
|
||
claudeConsoleAccountId,
|
||
geminiAccountId,
|
||
openaiAccountId,
|
||
bedrockAccountId,
|
||
droidAccountId,
|
||
permissions,
|
||
enableModelRestriction,
|
||
restrictedModels,
|
||
enableClientRestriction,
|
||
allowedClients,
|
||
expiresAt,
|
||
dailyCostLimit,
|
||
totalCostLimit,
|
||
weeklyOpusCostLimit,
|
||
tags,
|
||
ownerId // 新增:所有者ID字段
|
||
} = req.body
|
||
|
||
// 只允许更新指定字段
|
||
const updates = {}
|
||
|
||
// 处理名称字段
|
||
if (name !== undefined && name !== null && name !== '') {
|
||
const trimmedName = name.toString().trim()
|
||
if (trimmedName.length === 0) {
|
||
return res.status(400).json({ error: 'API Key name cannot be empty' })
|
||
}
|
||
if (trimmedName.length > 100) {
|
||
return res.status(400).json({ error: 'API Key name must be less than 100 characters' })
|
||
}
|
||
updates.name = trimmedName
|
||
}
|
||
|
||
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
|
||
if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) {
|
||
return res.status(400).json({ error: 'Token limit must be a non-negative integer' })
|
||
}
|
||
updates.tokenLimit = Number(tokenLimit)
|
||
}
|
||
|
||
if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') {
|
||
if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) {
|
||
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' })
|
||
}
|
||
updates.concurrencyLimit = Number(concurrencyLimit)
|
||
}
|
||
|
||
if (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '') {
|
||
if (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 0) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Rate limit window must be a non-negative integer (minutes)' })
|
||
}
|
||
updates.rateLimitWindow = Number(rateLimitWindow)
|
||
}
|
||
|
||
if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '') {
|
||
if (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 0) {
|
||
return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' })
|
||
}
|
||
updates.rateLimitRequests = Number(rateLimitRequests)
|
||
}
|
||
|
||
if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') {
|
||
const cost = Number(rateLimitCost)
|
||
if (isNaN(cost) || cost < 0) {
|
||
return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' })
|
||
}
|
||
updates.rateLimitCost = cost
|
||
}
|
||
|
||
if (claudeAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.claudeAccountId = claudeAccountId || ''
|
||
}
|
||
|
||
if (claudeConsoleAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.claudeConsoleAccountId = claudeConsoleAccountId || ''
|
||
}
|
||
|
||
if (geminiAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.geminiAccountId = geminiAccountId || ''
|
||
}
|
||
|
||
if (openaiAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.openaiAccountId = openaiAccountId || ''
|
||
}
|
||
|
||
if (bedrockAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.bedrockAccountId = bedrockAccountId || ''
|
||
}
|
||
|
||
if (droidAccountId !== undefined) {
|
||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||
updates.droidAccountId = droidAccountId || ''
|
||
}
|
||
|
||
if (permissions !== undefined) {
|
||
// 验证权限值
|
||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
||
return res.status(400).json({
|
||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||
})
|
||
}
|
||
updates.permissions = permissions
|
||
}
|
||
|
||
// 处理模型限制字段
|
||
if (enableModelRestriction !== undefined) {
|
||
if (typeof enableModelRestriction !== 'boolean') {
|
||
return res.status(400).json({ error: 'Enable model restriction must be a boolean' })
|
||
}
|
||
updates.enableModelRestriction = enableModelRestriction
|
||
}
|
||
|
||
if (restrictedModels !== undefined) {
|
||
if (!Array.isArray(restrictedModels)) {
|
||
return res.status(400).json({ error: 'Restricted models must be an array' })
|
||
}
|
||
updates.restrictedModels = restrictedModels
|
||
}
|
||
|
||
// 处理客户端限制字段
|
||
if (enableClientRestriction !== undefined) {
|
||
if (typeof enableClientRestriction !== 'boolean') {
|
||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' })
|
||
}
|
||
updates.enableClientRestriction = enableClientRestriction
|
||
}
|
||
|
||
if (allowedClients !== undefined) {
|
||
if (!Array.isArray(allowedClients)) {
|
||
return res.status(400).json({ error: 'Allowed clients must be an array' })
|
||
}
|
||
updates.allowedClients = allowedClients
|
||
}
|
||
|
||
// 处理过期时间字段
|
||
if (expiresAt !== undefined) {
|
||
if (expiresAt === null) {
|
||
// null 表示永不过期
|
||
updates.expiresAt = null
|
||
updates.isActive = true
|
||
} else {
|
||
// 验证日期格式
|
||
const expireDate = new Date(expiresAt)
|
||
if (isNaN(expireDate.getTime())) {
|
||
return res.status(400).json({ error: 'Invalid expiration date format' })
|
||
}
|
||
updates.expiresAt = expiresAt
|
||
updates.isActive = expireDate > new Date() // 如果过期时间在当前时间之后,则设置为激活状态
|
||
}
|
||
}
|
||
|
||
// 处理每日费用限制
|
||
if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') {
|
||
const costLimit = Number(dailyCostLimit)
|
||
if (isNaN(costLimit) || costLimit < 0) {
|
||
return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' })
|
||
}
|
||
updates.dailyCostLimit = costLimit
|
||
}
|
||
|
||
if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') {
|
||
const costLimit = Number(totalCostLimit)
|
||
if (isNaN(costLimit) || costLimit < 0) {
|
||
return res.status(400).json({ error: 'Total cost limit must be a non-negative number' })
|
||
}
|
||
updates.totalCostLimit = costLimit
|
||
}
|
||
|
||
// 处理 Opus 周费用限制
|
||
if (
|
||
weeklyOpusCostLimit !== undefined &&
|
||
weeklyOpusCostLimit !== null &&
|
||
weeklyOpusCostLimit !== ''
|
||
) {
|
||
const costLimit = Number(weeklyOpusCostLimit)
|
||
// 明确验证非负数(0 表示禁用,负数无意义)
|
||
if (isNaN(costLimit) || costLimit < 0) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Weekly Opus cost limit must be a non-negative number' })
|
||
}
|
||
updates.weeklyOpusCostLimit = costLimit
|
||
}
|
||
|
||
// 处理标签
|
||
if (tags !== undefined) {
|
||
if (!Array.isArray(tags)) {
|
||
return res.status(400).json({ error: 'Tags must be an array' })
|
||
}
|
||
if (tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) {
|
||
return res.status(400).json({ error: 'All tags must be non-empty strings' })
|
||
}
|
||
updates.tags = tags
|
||
}
|
||
|
||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||
if (isActive !== undefined) {
|
||
if (typeof isActive !== 'boolean') {
|
||
return res.status(400).json({ error: 'isActive must be a boolean' })
|
||
}
|
||
updates.isActive = isActive
|
||
}
|
||
|
||
// 处理所有者变更
|
||
if (ownerId !== undefined) {
|
||
const userService = require('../../services/userService')
|
||
|
||
if (ownerId === 'admin') {
|
||
// 分配给Admin
|
||
updates.userId = ''
|
||
updates.userUsername = ''
|
||
updates.createdBy = 'admin'
|
||
} else if (ownerId) {
|
||
// 分配给用户
|
||
try {
|
||
const user = await userService.getUserById(ownerId, false)
|
||
if (!user) {
|
||
return res.status(400).json({ error: 'Invalid owner: User not found' })
|
||
}
|
||
if (!user.isActive) {
|
||
return res.status(400).json({ error: 'Cannot assign to inactive user' })
|
||
}
|
||
|
||
// 设置新的所有者信息
|
||
updates.userId = ownerId
|
||
updates.userUsername = user.username
|
||
updates.createdBy = user.username
|
||
|
||
// 管理员重新分配时,不检查用户的API Key数量限制
|
||
logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`)
|
||
} catch (error) {
|
||
logger.error('Error fetching user for owner reassignment:', error)
|
||
return res.status(400).json({ error: 'Invalid owner ID' })
|
||
}
|
||
} else {
|
||
// 清空所有者(分配给Admin)
|
||
updates.userId = ''
|
||
updates.userUsername = ''
|
||
updates.createdBy = 'admin'
|
||
}
|
||
}
|
||
|
||
await apiKeyService.updateApiKey(keyId, updates)
|
||
|
||
logger.success(`📝 Admin updated API key: ${keyId}`)
|
||
return res.json({ success: true, message: 'API key updated successfully' })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update API key:', error)
|
||
return res.status(500).json({ error: 'Failed to update API key', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 修改API Key过期时间(包括手动激活功能)
|
||
router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
const { expiresAt, activateNow } = req.body
|
||
|
||
// 获取当前API Key信息
|
||
const keyData = await redis.getApiKey(keyId)
|
||
if (!keyData || Object.keys(keyData).length === 0) {
|
||
return res.status(404).json({ error: 'API key not found' })
|
||
}
|
||
|
||
const updates = {}
|
||
|
||
// 如果是激活操作(用于未激活的key)
|
||
if (activateNow === true) {
|
||
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
||
const now = new Date()
|
||
const activationDays = parseInt(keyData.activationDays || 30)
|
||
const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
|
||
|
||
updates.isActivated = 'true'
|
||
updates.activatedAt = now.toISOString()
|
||
updates.expiresAt = newExpiresAt.toISOString()
|
||
|
||
logger.success(
|
||
`🔓 API key manually activated by admin: ${keyId} (${
|
||
keyData.name
|
||
}), expires at ${newExpiresAt.toISOString()}`
|
||
)
|
||
} else {
|
||
return res.status(400).json({
|
||
error: 'Cannot activate',
|
||
message: 'Key is either already activated or not in activation mode'
|
||
})
|
||
}
|
||
}
|
||
|
||
// 如果提供了新的过期时间(但不是激活操作)
|
||
if (expiresAt !== undefined && activateNow !== true) {
|
||
// 验证过期时间格式
|
||
if (expiresAt && isNaN(Date.parse(expiresAt))) {
|
||
return res.status(400).json({ error: 'Invalid expiration date format' })
|
||
}
|
||
|
||
// 如果设置了过期时间,确保key是激活状态
|
||
if (expiresAt) {
|
||
updates.expiresAt = new Date(expiresAt).toISOString()
|
||
// 如果之前是未激活状态,现在激活它
|
||
if (keyData.isActivated !== 'true') {
|
||
updates.isActivated = 'true'
|
||
updates.activatedAt = new Date().toISOString()
|
||
}
|
||
} else {
|
||
// 清除过期时间(永不过期)
|
||
updates.expiresAt = ''
|
||
}
|
||
}
|
||
|
||
if (Object.keys(updates).length === 0) {
|
||
return res.status(400).json({ error: 'No valid updates provided' })
|
||
}
|
||
|
||
// 更新API Key
|
||
await apiKeyService.updateApiKey(keyId, updates)
|
||
|
||
logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`)
|
||
return res.json({
|
||
success: true,
|
||
message: 'API key expiration updated successfully',
|
||
updates
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update API key expiration:', error)
|
||
return res.status(500).json({
|
||
error: 'Failed to update API key expiration',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 批量删除API Keys(必须在 :keyId 路由之前定义)
|
||
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyIds } = req.body
|
||
|
||
// 调试信息
|
||
logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`)
|
||
logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`)
|
||
|
||
// 参数验证
|
||
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
||
logger.warn(
|
||
`🚨 Invalid keyIds: ${JSON.stringify({
|
||
keyIds,
|
||
type: typeof keyIds,
|
||
isArray: Array.isArray(keyIds)
|
||
})}`
|
||
)
|
||
return res.status(400).json({
|
||
error: 'Invalid request',
|
||
message: 'keyIds 必须是一个非空数组'
|
||
})
|
||
}
|
||
|
||
if (keyIds.length > 100) {
|
||
return res.status(400).json({
|
||
error: 'Too many keys',
|
||
message: '每次最多只能删除100个API Keys'
|
||
})
|
||
}
|
||
|
||
// 验证keyIds格式
|
||
const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string')
|
||
if (invalidKeys.length > 0) {
|
||
return res.status(400).json({
|
||
error: 'Invalid key IDs',
|
||
message: '包含无效的API Key ID'
|
||
})
|
||
}
|
||
|
||
logger.info(
|
||
`🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}`
|
||
)
|
||
|
||
const results = {
|
||
successCount: 0,
|
||
failedCount: 0,
|
||
errors: []
|
||
}
|
||
|
||
// 逐个删除,记录成功和失败情况
|
||
for (const keyId of keyIds) {
|
||
try {
|
||
// 检查API Key是否存在
|
||
const apiKey = await redis.getApiKey(keyId)
|
||
if (!apiKey || Object.keys(apiKey).length === 0) {
|
||
results.failedCount++
|
||
results.errors.push({ keyId, error: 'API Key 不存在' })
|
||
continue
|
||
}
|
||
|
||
// 执行删除
|
||
await apiKeyService.deleteApiKey(keyId)
|
||
results.successCount++
|
||
|
||
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
|
||
} catch (error) {
|
||
results.failedCount++
|
||
results.errors.push({
|
||
keyId,
|
||
error: error.message || '删除失败'
|
||
})
|
||
|
||
logger.error(`❌ Batch delete failed for key ${keyId}:`, error)
|
||
}
|
||
}
|
||
|
||
// 记录批量删除结果
|
||
if (results.successCount > 0) {
|
||
logger.success(
|
||
`🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed`
|
||
)
|
||
} else {
|
||
logger.warn(
|
||
`⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed`
|
||
)
|
||
}
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: `批量删除完成`,
|
||
data: results
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to batch delete API keys:', error)
|
||
return res.status(500).json({
|
||
error: 'Batch delete failed',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 删除单个API Key(必须在批量删除路由之后定义)
|
||
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
|
||
await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin')
|
||
|
||
logger.success(`🗑️ Admin deleted API key: ${keyId}`)
|
||
return res.json({ success: true, message: 'API key deleted successfully' })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to delete API key:', error)
|
||
return res.status(500).json({ error: 'Failed to delete API key', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 📋 获取已删除的API Keys
|
||
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted
|
||
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true')
|
||
|
||
// Add additional metadata for deleted keys
|
||
const enrichedKeys = onlyDeleted.map((key) => ({
|
||
...key,
|
||
isDeleted: key.isDeleted === 'true',
|
||
deletedAt: key.deletedAt,
|
||
deletedBy: key.deletedBy,
|
||
deletedByType: key.deletedByType,
|
||
canRestore: true // 已删除的API Key可以恢复
|
||
}))
|
||
|
||
logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`)
|
||
return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length })
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get deleted API keys:', error)
|
||
return res
|
||
.status(500)
|
||
.json({ error: 'Failed to retrieve deleted API keys', message: error.message })
|
||
}
|
||
})
|
||
|
||
// 🔄 恢复已删除的API Key
|
||
router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
const adminUsername = req.session?.admin?.username || 'unknown'
|
||
|
||
// 调用服务层的恢复方法
|
||
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
|
||
|
||
if (result.success) {
|
||
logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`)
|
||
return res.json({
|
||
success: true,
|
||
message: 'API Key 已成功恢复',
|
||
apiKey: result.apiKey
|
||
})
|
||
} else {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Failed to restore API key'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Failed to restore API key:', error)
|
||
|
||
// 根据错误类型返回适当的响应
|
||
if (error.message === 'API key not found') {
|
||
return res.status(404).json({
|
||
success: false,
|
||
error: 'API Key 不存在'
|
||
})
|
||
} else if (error.message === 'API key is not deleted') {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: '该 API Key 未被删除,无需恢复'
|
||
})
|
||
}
|
||
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '恢复 API Key 失败',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 🗑️ 彻底删除API Key(物理删除)
|
||
router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const { keyId } = req.params
|
||
const adminUsername = req.session?.admin?.username || 'unknown'
|
||
|
||
// 调用服务层的彻底删除方法
|
||
const result = await apiKeyService.permanentDeleteApiKey(keyId)
|
||
|
||
if (result.success) {
|
||
logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`)
|
||
return res.json({
|
||
success: true,
|
||
message: 'API Key 已彻底删除'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Failed to permanently delete API key:', error)
|
||
|
||
if (error.message === 'API key not found') {
|
||
return res.status(404).json({
|
||
success: false,
|
||
error: 'API Key 不存在'
|
||
})
|
||
} else if (error.message === '只能彻底删除已经删除的API Key') {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: '只能彻底删除已经删除的API Key'
|
||
})
|
||
}
|
||
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '彻底删除 API Key 失败',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
// 🧹 清空所有已删除的API Keys
|
||
router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => {
|
||
try {
|
||
const adminUsername = req.session?.admin?.username || 'unknown'
|
||
|
||
// 调用服务层的清空方法
|
||
const result = await apiKeyService.clearAllDeletedApiKeys()
|
||
|
||
logger.success(
|
||
`🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}`
|
||
)
|
||
|
||
return res.json({
|
||
success: true,
|
||
message: `成功清空 ${result.successCount} 个已删除的 API Keys`,
|
||
details: {
|
||
total: result.total,
|
||
successCount: result.successCount,
|
||
failedCount: result.failedCount,
|
||
errors: result.errors
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to clear all deleted API keys:', error)
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '清空已删除的 API Keys 失败',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
module.exports = router
|