mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-24 09:41:17 +00:00
@@ -4,6 +4,10 @@ const path = require('path')
|
||||
const axios = require('axios')
|
||||
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -254,30 +258,43 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// ==================== OEM 设置管理 ====================
|
||||
|
||||
// 默认OEM设置
|
||||
const defaultOemSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
publicStatsEnabled: false, // 是否在首页显示公开统计概览
|
||||
// 公开统计显示选项
|
||||
publicStatsShowModelDistribution: true, // 显示模型使用分布
|
||||
publicStatsModelDistributionPeriod: 'today', // 模型使用分布时间范围: today, 24h, 7d, 30d, all
|
||||
publicStatsShowTokenTrends: false, // 显示Token使用趋势
|
||||
publicStatsShowApiKeysTrends: false, // 显示API Keys使用趋势
|
||||
publicStatsShowAccountTrends: false, // 显示账号使用趋势
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 获取OEM设置的辅助函数
|
||||
async function getOemSettings() {
|
||||
const client = redis.getClient()
|
||||
const oemSettings = await client.get('oem:settings')
|
||||
|
||||
let settings = { ...defaultOemSettings }
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultOemSettings, ...JSON.parse(oemSettings) }
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
const oemSettings = await client.get('oem:settings')
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let settings = defaultSettings
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||||
}
|
||||
}
|
||||
const settings = await getOemSettings()
|
||||
|
||||
// 添加 LDAP 启用状态到响应中
|
||||
return res.json({
|
||||
@@ -296,7 +313,18 @@ router.get('/oem-settings', async (req, res) => {
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
const {
|
||||
siteName,
|
||||
siteIcon,
|
||||
siteIconData,
|
||||
showAdminButton,
|
||||
publicStatsEnabled,
|
||||
publicStatsShowModelDistribution,
|
||||
publicStatsModelDistributionPeriod,
|
||||
publicStatsShowTokenTrends,
|
||||
publicStatsShowApiKeysTrends,
|
||||
publicStatsShowAccountTrends
|
||||
} = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -323,11 +351,24 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证时间范围值
|
||||
const validPeriods = ['today', '24h', '7d', '30d', 'all']
|
||||
const periodValue = validPeriods.includes(publicStatsModelDistributionPeriod)
|
||||
? publicStatsModelDistributionPeriod
|
||||
: 'today'
|
||||
|
||||
const settings = {
|
||||
siteName: siteName.trim(),
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
publicStatsEnabled: publicStatsEnabled === true, // 默认为false
|
||||
// 公开统计显示选项
|
||||
publicStatsShowModelDistribution: publicStatsShowModelDistribution !== false, // 默认为true
|
||||
publicStatsModelDistributionPeriod: periodValue, // 时间范围
|
||||
publicStatsShowTokenTrends: publicStatsShowTokenTrends === true, // 默认为false
|
||||
publicStatsShowApiKeysTrends: publicStatsShowApiKeysTrends === true, // 默认为false
|
||||
publicStatsShowAccountTrends: publicStatsShowAccountTrends === true, // 默认为false
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -398,4 +439,420 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 公开统计概览 ====================
|
||||
|
||||
// 获取公开统计数据(无需认证,用于首页展示)
|
||||
// 只在 publicStatsEnabled 开启时返回数据
|
||||
router.get('/public-stats', async (req, res) => {
|
||||
try {
|
||||
// 检查是否启用了公开统计
|
||||
const settings = await getOemSettings()
|
||||
if (!settings.publicStatsEnabled) {
|
||||
return res.json({
|
||||
success: true,
|
||||
enabled: false,
|
||||
data: null
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:规范化布尔值
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
const isRateLimitedFlag = (status) => {
|
||||
if (!status) {
|
||||
return false
|
||||
}
|
||||
if (typeof status === 'string') {
|
||||
return status === 'limited'
|
||||
}
|
||||
if (typeof status === 'object') {
|
||||
return status.isRateLimited === true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 并行获取统计数据
|
||||
const [
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
droidAccounts,
|
||||
todayStats,
|
||||
modelStats
|
||||
] = await Promise.all([
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
droidAccountService.getAllAccounts(),
|
||||
redis.getTodayStats(),
|
||||
getPublicModelStats(settings.publicStatsModelDistributionPeriod || 'today')
|
||||
])
|
||||
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
|
||||
// 计算各平台正常账户数
|
||||
const normalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
)
|
||||
).length
|
||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
normalizeBoolean(acc.schedulable) &&
|
||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
|
||||
// 计算总正常账户数
|
||||
const totalNormalAccounts =
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalDroidAccounts
|
||||
|
||||
// 判断服务状态
|
||||
const isHealthy = redis.isConnected && totalNormalAccounts > 0
|
||||
|
||||
// 构建公开统计数据(脱敏后的数据)
|
||||
const publicStats = {
|
||||
// 服务状态
|
||||
serviceStatus: isHealthy ? 'healthy' : 'degraded',
|
||||
uptime: process.uptime(),
|
||||
|
||||
// 平台可用性(只显示是否有可用账户,不显示具体数量)
|
||||
platforms: {
|
||||
claude: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||
gemini: normalGeminiAccounts > 0,
|
||||
bedrock: normalBedrockAccounts > 0,
|
||||
droid: normalDroidAccounts > 0
|
||||
},
|
||||
|
||||
// 今日统计
|
||||
todayStats: {
|
||||
requests: todayStats.requestsToday || 0,
|
||||
tokens: todayStats.tokensToday || 0,
|
||||
inputTokens: todayStats.inputTokensToday || 0,
|
||||
outputTokens: todayStats.outputTokensToday || 0
|
||||
},
|
||||
|
||||
// 系统时区
|
||||
systemTimezone: config.system.timezoneOffset || 8,
|
||||
|
||||
// 显示选项
|
||||
showOptions: {
|
||||
modelDistribution: settings.publicStatsShowModelDistribution !== false,
|
||||
tokenTrends: settings.publicStatsShowTokenTrends === true,
|
||||
apiKeysTrends: settings.publicStatsShowApiKeysTrends === true,
|
||||
accountTrends: settings.publicStatsShowAccountTrends === true
|
||||
}
|
||||
}
|
||||
|
||||
// 根据设置添加可选数据
|
||||
if (settings.publicStatsShowModelDistribution !== false) {
|
||||
// modelStats 现在返回 { stats: [], period }
|
||||
publicStats.modelDistribution = modelStats.stats
|
||||
publicStats.modelDistributionPeriod = modelStats.period
|
||||
}
|
||||
|
||||
// 获取趋势数据(最近7天)
|
||||
if (
|
||||
settings.publicStatsShowTokenTrends ||
|
||||
settings.publicStatsShowApiKeysTrends ||
|
||||
settings.publicStatsShowAccountTrends
|
||||
) {
|
||||
const trendData = await getPublicTrendData(settings)
|
||||
if (settings.publicStatsShowTokenTrends && trendData.tokenTrends) {
|
||||
publicStats.tokenTrends = trendData.tokenTrends
|
||||
}
|
||||
if (settings.publicStatsShowApiKeysTrends && trendData.apiKeysTrends) {
|
||||
publicStats.apiKeysTrends = trendData.apiKeysTrends
|
||||
}
|
||||
if (settings.publicStatsShowAccountTrends && trendData.accountTrends) {
|
||||
publicStats.accountTrends = trendData.accountTrends
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
enabled: true,
|
||||
data: publicStats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get public stats:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get public stats',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取公开模型统计的辅助函数
|
||||
// period: 'today' | '24h' | '7d' | '30d' | 'all'
|
||||
async function getPublicModelStats(period = 'today') {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
|
||||
// 根据period生成日期范围
|
||||
const getDatePatterns = () => {
|
||||
const patterns = []
|
||||
|
||||
if (period === 'today') {
|
||||
patterns.push(`usage:model:daily:*:${today}`)
|
||||
} else if (period === '24h') {
|
||||
// 过去24小时 = 今天 + 昨天
|
||||
patterns.push(`usage:model:daily:*:${today}`)
|
||||
const yesterday = new Date(tzDate)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(yesterday)}`)
|
||||
} else if (period === '7d') {
|
||||
// 过去7天
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(tzDate)
|
||||
date.setDate(date.getDate() - i)
|
||||
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
|
||||
}
|
||||
} else if (period === '30d') {
|
||||
// 过去30天
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(tzDate)
|
||||
date.setDate(date.getDate() - i)
|
||||
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
|
||||
}
|
||||
} else if (period === 'all') {
|
||||
// 所有数据
|
||||
patterns.push('usage:model:daily:*')
|
||||
} else {
|
||||
// 默认今天
|
||||
patterns.push(`usage:model:daily:*:${today}`)
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
const patterns = getDatePatterns()
|
||||
let allKeys = []
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
allKeys.push(...keys)
|
||||
}
|
||||
|
||||
// 去重
|
||||
allKeys = [...new Set(allKeys)]
|
||||
|
||||
if (allKeys.length === 0) {
|
||||
return { stats: [], period }
|
||||
}
|
||||
|
||||
// 模型名标准化
|
||||
const normalizeModelName = (model) => {
|
||||
if (!model || model === 'unknown') {
|
||||
return model
|
||||
}
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '')
|
||||
normalized = normalized.replace('anthropic.', '')
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '')
|
||||
return normalized
|
||||
}
|
||||
return model.replace(/-v\d+:\d+|:latest$/, '')
|
||||
}
|
||||
|
||||
// 聚合模型数据
|
||||
const modelStatsMap = new Map()
|
||||
let totalRequests = 0
|
||||
|
||||
for (const key of allKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawModel = match[1]
|
||||
const normalizedModel = normalizeModelName(rawModel)
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const requests = parseInt(data.requests) || 0
|
||||
totalRequests += requests
|
||||
|
||||
const stats = modelStatsMap.get(normalizedModel) || { requests: 0 }
|
||||
stats.requests += requests
|
||||
modelStatsMap.set(normalizedModel, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为数组并计算占比
|
||||
const modelStats = []
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
modelStats.push({
|
||||
model,
|
||||
percentage: totalRequests > 0 ? Math.round((stats.requests / totalRequests) * 100) : 0
|
||||
})
|
||||
}
|
||||
|
||||
// 按占比排序,取前5个
|
||||
modelStats.sort((a, b) => b.percentage - a.percentage)
|
||||
return { stats: modelStats.slice(0, 5), period }
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to get public model stats:', error.message)
|
||||
return { stats: [], period }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取公开趋势数据的辅助函数(最近7天)
|
||||
async function getPublicTrendData(settings) {
|
||||
const result = {
|
||||
tokenTrends: null,
|
||||
apiKeysTrends: null,
|
||||
accountTrends: null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const days = 7
|
||||
|
||||
// 生成最近7天的日期列表
|
||||
const dates = []
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - i)
|
||||
dates.push(redis.getDateStringInTimezone(date))
|
||||
}
|
||||
|
||||
// Token使用趋势
|
||||
if (settings.publicStatsShowTokenTrends) {
|
||||
const tokenTrends = []
|
||||
for (const dateStr of dates) {
|
||||
const pattern = `usage:model:daily:*:${dateStr}`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
let dayTokens = 0
|
||||
let dayRequests = 0
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key)
|
||||
if (data) {
|
||||
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
|
||||
dayRequests += parseInt(data.requests) || 0
|
||||
}
|
||||
}
|
||||
|
||||
tokenTrends.push({
|
||||
date: dateStr,
|
||||
tokens: dayTokens,
|
||||
requests: dayRequests
|
||||
})
|
||||
}
|
||||
result.tokenTrends = tokenTrends
|
||||
}
|
||||
|
||||
// API Keys使用趋势(脱敏:只显示总数,不显示具体Key)
|
||||
if (settings.publicStatsShowApiKeysTrends) {
|
||||
const apiKeysTrends = []
|
||||
for (const dateStr of dates) {
|
||||
const pattern = `usage:apikey:daily:*:${dateStr}`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
let dayRequests = 0
|
||||
let dayTokens = 0
|
||||
let activeKeys = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key)
|
||||
if (data) {
|
||||
const requests = parseInt(data.requests) || 0
|
||||
if (requests > 0) {
|
||||
activeKeys++
|
||||
dayRequests += requests
|
||||
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apiKeysTrends.push({
|
||||
date: dateStr,
|
||||
activeKeys,
|
||||
requests: dayRequests,
|
||||
tokens: dayTokens
|
||||
})
|
||||
}
|
||||
result.apiKeysTrends = apiKeysTrends
|
||||
}
|
||||
|
||||
// 账号使用趋势(脱敏:只显示总数,不显示具体账号)
|
||||
if (settings.publicStatsShowAccountTrends) {
|
||||
const accountTrends = []
|
||||
for (const dateStr of dates) {
|
||||
const pattern = `usage:account:daily:*:${dateStr}`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
let dayRequests = 0
|
||||
let dayTokens = 0
|
||||
let activeAccounts = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key)
|
||||
if (data) {
|
||||
const requests = parseInt(data.requests) || 0
|
||||
if (requests > 0) {
|
||||
activeAccounts++
|
||||
dayRequests += requests
|
||||
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountTrends.push({
|
||||
date: dateStr,
|
||||
activeAccounts,
|
||||
requests: dayRequests,
|
||||
tokens: dayTokens
|
||||
})
|
||||
}
|
||||
result.accountTrends = accountTrends
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to get public trend data:', error.message)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
|
||||
Reference in New Issue
Block a user