feat: 支持账号维度的数据统计

This commit is contained in:
shaw
2025-09-27 22:55:06 +08:00
parent 5e730db7f9
commit ea28222c71
7 changed files with 1661 additions and 9 deletions

View File

@@ -4052,6 +4052,198 @@ router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, re
}
})
// 获取账号近30天使用历史
router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform = 'claude', days = 30 } = req.query
const allowedPlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
if (!allowedPlatforms.includes(platform)) {
return res.status(400).json({
success: false,
error: 'Unsupported account platform'
})
}
const accountTypeMap = {
openai: 'openai',
'openai-responses': 'openai-responses'
}
const fallbackModelMap = {
claude: 'claude-3-5-sonnet-20241022',
'claude-console': 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o-mini-2024-07-18',
'openai-responses': 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash'
}
const client = redis.getClientSafe()
const fallbackModel = fallbackModelMap[platform] || 'unknown'
const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60)
// 获取概览统计数据
const accountUsageStats = await redis.getAccountUsageStats(
accountId,
accountTypeMap[platform] || null
)
const history = []
let totalCost = 0
let totalRequests = 0
let totalTokens = 0
let highestCostDay = null
let highestRequestDay = null
const sumModelCostsForDay = async (dateKey) => {
const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}`
const modelKeys = await client.keys(modelPattern)
let summedCost = 0
if (modelKeys.length === 0) {
return summedCost
}
for (const modelKey of modelKeys) {
const modelParts = modelKey.split(':')
const modelName = modelParts[4] || 'unknown'
const modelData = await client.hgetall(modelKey)
if (!modelData || Object.keys(modelData).length === 0) {
continue
}
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, modelName)
summedCost += costResult.costs.total
}
return summedCost
}
const today = new Date()
for (let offset = daysCount - 1; offset >= 0; offset--) {
const date = new Date(today)
date.setDate(date.getDate() - offset)
const tzDate = redis.getDateInTimezone(date)
const dateKey = redis.getDateStringInTimezone(date)
const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0')
const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0')
const label = `${monthLabel}/${dayLabel}`
const dailyKey = `account_usage:daily:${accountId}:${dateKey}`
const dailyData = await client.hgetall(dailyKey)
const inputTokens = parseInt(dailyData?.inputTokens) || 0
const outputTokens = parseInt(dailyData?.outputTokens) || 0
const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0
const allTokens =
parseInt(dailyData?.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(dailyData?.requests) || 0
let cost = await sumModelCostsForDay(dateKey)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000
totalCost += normalizedCost
totalRequests += requests
totalTokens += allTokens
if (!highestCostDay || normalizedCost > highestCostDay.cost) {
highestCostDay = {
date: dateKey,
label,
cost: normalizedCost,
formattedCost: CostCalculator.formatCost(normalizedCost)
}
}
if (!highestRequestDay || requests > highestRequestDay.requests) {
highestRequestDay = {
date: dateKey,
label,
requests
}
}
history.push({
date: dateKey,
label,
cost: normalizedCost,
formattedCost: CostCalculator.formatCost(normalizedCost),
requests,
tokens: allTokens
})
}
const avgDailyCost = daysCount > 0 ? totalCost / daysCount : 0
const avgDailyRequests = daysCount > 0 ? totalRequests / daysCount : 0
const avgDailyTokens = daysCount > 0 ? totalTokens / daysCount : 0
const todayData = history.length > 0 ? history[history.length - 1] : null
return res.json({
success: true,
data: {
history,
summary: {
days: daysCount,
totalCost,
totalCostFormatted: CostCalculator.formatCost(totalCost),
totalRequests,
totalTokens,
avgDailyCost,
avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost),
avgDailyRequests,
avgDailyTokens,
today: todayData
? {
date: todayData.date,
cost: todayData.cost,
costFormatted: todayData.formattedCost,
requests: todayData.requests,
tokens: todayData.tokens
}
: null,
highestCostDay,
highestRequestDay
},
overview: accountUsageStats,
generatedAt: new Date().toISOString()
}
})
} catch (error) {
logger.error('❌ Failed to get account usage history:', error)
return res.status(500).json({
success: false,
error: 'Failed to get account usage history',
message: error.message
})
}
})
// 📊 系统统计
// 获取系统概览
@@ -5158,6 +5350,345 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
}
})
// 获取按账号分组的使用趋势
router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
const allowedGroups = ['claude', 'openai', 'gemini']
if (!allowedGroups.includes(group)) {
return res.status(400).json({
success: false,
error: 'Invalid account group'
})
}
const groupLabels = {
claude: 'Claude账户',
openai: 'OpenAI账户',
gemini: 'Gemini账户'
}
// 拉取各平台账号列表
let accounts = []
if (group === 'claude') {
const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts()
])
accounts = [
...claudeAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `Claude账号 ${shortId}`,
platform: 'claude'
}
}),
...claudeConsoleAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Console账号 ${shortId}`,
platform: 'claude-console'
}
})
]
} else if (group === 'openai') {
const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([
openaiAccountService.getAllAccounts(),
openaiResponsesAccountService.getAllAccounts(true)
])
accounts = [
...openaiAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `OpenAI账号 ${shortId}`,
platform: 'openai'
}
}),
...openaiResponsesAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Responses账号 ${shortId}`,
platform: 'openai-responses'
}
})
]
} else if (group === 'gemini') {
const geminiAccounts = await geminiAccountService.getAllAccounts()
accounts = geminiAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `Gemini账号 ${shortId}`,
platform: 'gemini'
}
})
}
if (!accounts || accounts.length === 0) {
return res.json({
success: true,
data: [],
granularity,
group,
groupLabel: groupLabels[group],
topAccounts: [],
totalAccounts: 0
})
}
const accountMap = new Map()
const accountIdSet = new Set()
for (const account of accounts) {
accountMap.set(account.id, {
name: account.name,
platform: account.platform
})
accountIdSet.add(account.id)
}
const fallbackModelByGroup = {
claude: 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash'
}
const fallbackModel = fallbackModelByGroup[group] || 'unknown'
const client = redis.getClientSafe()
const trendData = []
const accountCostTotals = new Map()
const sumModelCosts = async (accountId, period, timeKey) => {
const modelPattern = `account_usage:model:${period}:${accountId}:*:${timeKey}`
const modelKeys = await client.keys(modelPattern)
let totalCost = 0
for (const modelKey of modelKeys) {
const modelData = await client.hgetall(modelKey)
if (!modelData) {
continue
}
const parts = modelKey.split(':')
if (parts.length < 5) {
continue
}
const modelName = parts[4]
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, modelName)
totalCost += costResult.costs.total
}
return totalCost
}
if (granularity === 'hour') {
let startTime
let endTime
if (startDate && endDate) {
startTime = new Date(startDate)
endTime = new Date(endDate)
} else {
endTime = new Date()
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
}
const currentHour = new Date(startTime)
currentHour.setMinutes(0, 0, 0)
while (currentHour <= endTime) {
const tzCurrentHour = redis.getDateInTimezone(currentHour)
const dateStr = redis.getDateStringInTimezone(currentHour)
const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0')
const hourKey = `${dateStr}:${hour}`
const tzDateForLabel = redis.getDateInTimezone(currentHour)
const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0')
const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0')
const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0')
const hourData = {
hour: currentHour.toISOString(),
label: `${monthLabel}/${dayLabel} ${hourLabel}:00`,
accounts: {}
}
const pattern = `account_usage:hourly:*:${hourKey}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) {
continue
}
const accountId = match[1]
if (!accountIdSet.has(accountId)) {
continue
}
const data = await client.hgetall(key)
if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const allTokens =
parseInt(data.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(data.requests) || 0
let cost = await sumModelCosts(accountId, 'hourly', hourKey)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const formattedCost = CostCalculator.formatCost(cost)
const accountInfo = accountMap.get(accountId)
hourData.accounts[accountId] = {
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
cost,
formattedCost,
requests
}
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
}
trendData.push(hourData)
currentHour.setHours(currentHour.getHours() + 1)
}
} else {
const daysCount = parseInt(days) || 7
const today = new Date()
for (let i = 0; i < daysCount; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = redis.getDateStringInTimezone(date)
const dayData = {
date: dateStr,
accounts: {}
}
const pattern = `account_usage:daily:*:${dateStr}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) {
continue
}
const accountId = match[1]
if (!accountIdSet.has(accountId)) {
continue
}
const data = await client.hgetall(key)
if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const allTokens =
parseInt(data.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(data.requests) || 0
let cost = await sumModelCosts(accountId, 'daily', dateStr)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const formattedCost = CostCalculator.formatCost(cost)
const accountInfo = accountMap.get(accountId)
dayData.accounts[accountId] = {
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
cost,
formattedCost,
requests
}
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
}
trendData.push(dayData)
}
}
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour))
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date))
}
const topAccounts = Array.from(accountCostTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([accountId]) => accountId)
return res.json({
success: true,
data: trendData,
granularity,
group,
groupLabel: groupLabels[group],
topAccounts,
totalAccounts: accountCostTotals.size
})
} catch (error) {
logger.error('❌ Failed to get account usage trend:', error)
return res
.status(500)
.json({ error: 'Failed to get account usage trend', message: error.message })
}
})
// 获取按API Key分组的使用趋势
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
try {