mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2180c42b84 | ||
|
|
0883bb6b39 | ||
|
|
ea6d1f1b36 | ||
|
|
4367fa47da | ||
|
|
55c876fad5 | ||
|
|
f9df276d0c | ||
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
83cbaf7c3e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
24f825f60d | ||
|
|
ac7d28f9ce | ||
|
|
1027a2e3e2 | ||
|
|
cb935ea0f0 | ||
|
|
73a241df1a | ||
|
|
029bdf3719 | ||
|
|
76ecbe18a5 | ||
|
|
12fd5e1cb4 | ||
|
|
f5e982632d | ||
|
|
90023d1551 | ||
|
|
74e71d0afc | ||
|
|
d8a33f9aa7 | ||
|
|
666b0120b7 | ||
|
|
fba18000e5 | ||
|
|
b4233033a6 | ||
|
|
584fa8c9c1 |
10
.env.example
10
.env.example
@@ -114,6 +114,16 @@ PROXY_USE_IPV4=true
|
||||
# ⏱️ 请求超时配置
|
||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||
|
||||
# 🔗 HTTP 连接池配置(keep-alive)
|
||||
# 流式请求最大连接数(默认65535)
|
||||
# HTTPS_MAX_SOCKETS_STREAM=65535
|
||||
# 非流式请求最大连接数(默认16384)
|
||||
# HTTPS_MAX_SOCKETS_NON_STREAM=16384
|
||||
# 空闲连接数(默认2048)
|
||||
# HTTPS_MAX_FREE_SOCKETS=2048
|
||||
# 空闲连接超时(毫秒,默认30000)
|
||||
# HTTPS_FREE_SOCKET_TIMEOUT=30000
|
||||
|
||||
# 🔧 请求体大小配置
|
||||
REQUEST_MAX_SIZE_MB=60
|
||||
|
||||
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
16
cli/index.js
16
cli/index.js
@@ -103,7 +103,7 @@ program
|
||||
try {
|
||||
const [, apiKeys, accounts] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
apiKeyService.getAllApiKeysFast(),
|
||||
claudeAccountService.getAllAccounts()
|
||||
])
|
||||
|
||||
@@ -284,7 +284,7 @@ async function listApiKeys() {
|
||||
const spinner = ora('正在获取 API Keys...').start()
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
@@ -314,7 +314,7 @@ async function listApiKeys() {
|
||||
|
||||
tableData.push([
|
||||
key.name,
|
||||
key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
|
||||
key.maskedKey || '-',
|
||||
key.isActive ? '🟢 活跃' : '🔴 停用',
|
||||
expiryStatus,
|
||||
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
|
||||
@@ -333,7 +333,7 @@ async function listApiKeys() {
|
||||
async function updateApiKeyExpiry() {
|
||||
try {
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'))
|
||||
@@ -347,7 +347,7 @@ async function updateApiKeyExpiry() {
|
||||
name: 'selectedKey',
|
||||
message: '选择要修改的 API Key:',
|
||||
choices: apiKeys.map((key) => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||
value: key
|
||||
}))
|
||||
}
|
||||
@@ -463,7 +463,7 @@ async function renewApiKeys() {
|
||||
const spinner = ora('正在查找即将过期的 API Keys...').start()
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
const now = new Date()
|
||||
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
@@ -562,7 +562,7 @@ async function renewApiKeys() {
|
||||
|
||||
async function deleteApiKey() {
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'))
|
||||
@@ -575,7 +575,7 @@ async function deleteApiKey() {
|
||||
name: 'selectedKeys',
|
||||
message: '选择要删除的 API Keys (空格选择,回车确认):',
|
||||
choices: apiKeys.map((key) => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
|
||||
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)})`,
|
||||
value: key.id
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -123,7 +123,8 @@ const config = {
|
||||
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
||||
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8
|
||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8
|
||||
metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟)
|
||||
},
|
||||
|
||||
// 🎨 Web界面配置
|
||||
@@ -220,6 +221,13 @@ const config = {
|
||||
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
|
||||
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
|
||||
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送
|
||||
},
|
||||
|
||||
// 🎫 额度卡兑换上限配置(防盗刷)
|
||||
quotaCardLimits: {
|
||||
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
|
||||
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
|
||||
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
config/models.js
Normal file
64
config/models.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 模型列表配置
|
||||
* 用于前端展示和测试功能
|
||||
*/
|
||||
|
||||
const CLAUDE_MODELS = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }
|
||||
]
|
||||
|
||||
const GEMINI_MODELS = [
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }
|
||||
]
|
||||
|
||||
const OPENAI_MODELS = [
|
||||
{ value: 'gpt-5', label: 'GPT-5' },
|
||||
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||
{ value: 'gpt-5-nano', label: 'GPT-5 Nano' },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'codex-mini', label: 'Codex Mini' }
|
||||
]
|
||||
|
||||
// 其他模型(用于账户编辑的模型映射)
|
||||
const OTHER_MODELS = [
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
{ value: 'Qwen', label: 'Qwen' },
|
||||
{ value: 'Kimi', label: 'Kimi' },
|
||||
{ value: 'GLM', label: 'GLM' }
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
OPENAI_MODELS,
|
||||
OTHER_MODELS,
|
||||
// 按服务分组
|
||||
getModelsByService: (service) => {
|
||||
switch (service) {
|
||||
case 'claude':
|
||||
return CLAUDE_MODELS
|
||||
case 'gemini':
|
||||
return GEMINI_MODELS
|
||||
case 'openai':
|
||||
return OPENAI_MODELS
|
||||
default:
|
||||
return []
|
||||
}
|
||||
},
|
||||
// 获取所有模型(用于账户编辑)
|
||||
getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS]
|
||||
}
|
||||
@@ -2,7 +2,8 @@ const repository =
|
||||
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
|
||||
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
|
||||
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
|
||||
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||
const hashFileName =
|
||||
process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||
|
||||
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
|
||||
@@ -11,7 +12,6 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||
module.exports = {
|
||||
pricingFileName,
|
||||
hashFileName,
|
||||
pricingUrl:
|
||||
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||
pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
|
||||
}
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -20,7 +20,6 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"heapdump": "^0.3.15",
|
||||
"helmet": "^7.1.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"inquirer": "^8.2.6",
|
||||
@@ -5399,19 +5398,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heapdump": {
|
||||
"version": "0.3.15",
|
||||
"resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz",
|
||||
"integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"nan": "^2.13.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
|
||||
@@ -7027,12 +7013,6 @@
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
|
||||
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
||||
@@ -152,62 +152,110 @@ async function exportUsageStats(keyId) {
|
||||
daily: {},
|
||||
monthly: {},
|
||||
hourly: {},
|
||||
models: {}
|
||||
models: {},
|
||||
// 费用统计(String 类型)
|
||||
costTotal: null,
|
||||
costDaily: {},
|
||||
costMonthly: {},
|
||||
costHourly: {},
|
||||
opusTotal: null,
|
||||
opusWeekly: {}
|
||||
}
|
||||
|
||||
// 导出总统计
|
||||
const totalKey = `usage:${keyId}`
|
||||
const totalData = await redis.client.hgetall(totalKey)
|
||||
// 导出总统计(Hash)
|
||||
const totalData = await redis.client.hgetall(`usage:${keyId}`)
|
||||
if (totalData && Object.keys(totalData).length > 0) {
|
||||
stats.total = totalData
|
||||
}
|
||||
|
||||
// 导出每日统计(最近30天)
|
||||
const today = new Date()
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const dailyKey = `usage:daily:${keyId}:${dateStr}`
|
||||
// 导出费用总统计(String)
|
||||
const costTotal = await redis.client.get(`usage:cost:total:${keyId}`)
|
||||
if (costTotal) {
|
||||
stats.costTotal = costTotal
|
||||
}
|
||||
|
||||
const dailyData = await redis.client.hgetall(dailyKey)
|
||||
if (dailyData && Object.keys(dailyData).length > 0) {
|
||||
stats.daily[dateStr] = dailyData
|
||||
// 导出 Opus 费用总统计(String)
|
||||
const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`)
|
||||
if (opusTotal) {
|
||||
stats.opusTotal = opusTotal
|
||||
}
|
||||
|
||||
// 导出每日统计(扫描现有 key,避免时区问题)
|
||||
const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`)
|
||||
for (const key of dailyKeys) {
|
||||
const date = key.split(':').pop()
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.daily[date] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出每月统计(最近12个月)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today)
|
||||
date.setMonth(date.getMonth() - i)
|
||||
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
|
||||
|
||||
const monthlyData = await redis.client.hgetall(monthlyKey)
|
||||
if (monthlyData && Object.keys(monthlyData).length > 0) {
|
||||
stats.monthly[monthStr] = monthlyData
|
||||
// 导出每日费用(扫描现有 key)
|
||||
const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`)
|
||||
for (const key of costDailyKeys) {
|
||||
const date = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costDaily[date] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时统计(最近24小时)
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const date = new Date(today)
|
||||
date.setHours(date.getHours() - i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const hourKey = `${dateStr}:${hour}`
|
||||
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
|
||||
|
||||
const hourlyData = await redis.client.hgetall(hourlyKey)
|
||||
if (hourlyData && Object.keys(hourlyData).length > 0) {
|
||||
stats.hourly[hourKey] = hourlyData
|
||||
// 导出每月统计(扫描现有 key)
|
||||
const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`)
|
||||
for (const key of monthlyKeys) {
|
||||
const month = key.split(':').pop()
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.monthly[month] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出模型统计
|
||||
// 每日模型统计
|
||||
const modelDailyPattern = `usage:${keyId}:model:daily:*`
|
||||
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
||||
// 导出每月费用(扫描现有 key)
|
||||
const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`)
|
||||
for (const key of costMonthlyKeys) {
|
||||
const month = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costMonthly[month] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 Opus 周费用(扫描现有 key)
|
||||
const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`)
|
||||
for (const key of opusWeeklyKeys) {
|
||||
const week = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.opusWeekly[week] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时统计(扫描现有 key)
|
||||
// key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||
const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`)
|
||||
for (const key of hourlyKeys) {
|
||||
const parts = key.split(':')
|
||||
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.hourly[hourKey] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时费用(扫描现有 key)
|
||||
// key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||
const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`)
|
||||
for (const key of costHourlyKeys) {
|
||||
const parts = key.split(':')
|
||||
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costHourly[hourKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出模型统计(每日)
|
||||
const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`)
|
||||
for (const key of modelDailyKeys) {
|
||||
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
@@ -223,9 +271,8 @@ async function exportUsageStats(keyId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 每月模型统计
|
||||
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`
|
||||
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
||||
// 导出模型统计(每月)
|
||||
const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`)
|
||||
for (const key of modelMonthlyKeys) {
|
||||
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (match) {
|
||||
@@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) {
|
||||
const pipeline = redis.client.pipeline()
|
||||
let importCount = 0
|
||||
|
||||
// 导入总统计
|
||||
// 导入总统计(Hash)
|
||||
if (stats.total && Object.keys(stats.total).length > 0) {
|
||||
for (const [field, value] of Object.entries(stats.total)) {
|
||||
pipeline.hset(`usage:${keyId}`, field, value)
|
||||
@@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) {
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入每日统计
|
||||
// 导入费用总统计(String)
|
||||
if (stats.costTotal) {
|
||||
pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal)
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入 Opus 费用总统计(String)
|
||||
if (stats.opusTotal) {
|
||||
pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal)
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入每日统计(Hash)
|
||||
if (stats.daily) {
|
||||
for (const [date, data] of Object.entries(stats.daily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -276,7 +335,15 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入每月统计
|
||||
// 导入每日费用(String)
|
||||
if (stats.costDaily) {
|
||||
for (const [date, value] of Object.entries(stats.costDaily)) {
|
||||
pipeline.set(`usage:cost:daily:${keyId}:${date}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入每月统计(Hash)
|
||||
if (stats.monthly) {
|
||||
for (const [month, data] of Object.entries(stats.monthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -286,7 +353,23 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入小时统计
|
||||
// 导入每月费用(String)
|
||||
if (stats.costMonthly) {
|
||||
for (const [month, value] of Object.entries(stats.costMonthly)) {
|
||||
pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入 Opus 周费用(String,不加 TTL 保留历史全量)
|
||||
if (stats.opusWeekly) {
|
||||
for (const [week, value] of Object.entries(stats.opusWeekly)) {
|
||||
pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入小时统计(Hash)
|
||||
if (stats.hourly) {
|
||||
for (const [hour, data] of Object.entries(stats.hourly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -296,10 +379,17 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入模型统计
|
||||
// 导入小时费用(String)
|
||||
if (stats.costHourly) {
|
||||
for (const [hour, value] of Object.entries(stats.costHourly)) {
|
||||
pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入模型统计(Hash)
|
||||
if (stats.models) {
|
||||
for (const [model, modelStats] of Object.entries(stats.models)) {
|
||||
// 每日模型统计
|
||||
if (modelStats.daily) {
|
||||
for (const [date, data] of Object.entries(modelStats.daily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 每月模型统计
|
||||
if (modelStats.monthly) {
|
||||
for (const [month, data] of Object.entries(modelStats.monthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -547,13 +636,54 @@ async function exportData() {
|
||||
const globalStats = {
|
||||
daily: {},
|
||||
monthly: {},
|
||||
hourly: {}
|
||||
hourly: {},
|
||||
// 新增:索引和全局统计
|
||||
monthlyMonths: [], // usage:model:monthly:months Set
|
||||
globalTotal: null, // usage:global:total Hash
|
||||
globalDaily: {}, // usage:global:daily:* Hash
|
||||
globalMonthly: {} // usage:global:monthly:* Hash
|
||||
}
|
||||
|
||||
// 导出全局每日模型统计
|
||||
const globalDailyPattern = 'usage:model:daily:*'
|
||||
const globalDailyKeys = await redis.client.keys(globalDailyPattern)
|
||||
// 导出月份索引
|
||||
const monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
|
||||
if (monthlyMonths && monthlyMonths.length > 0) {
|
||||
globalStats.monthlyMonths = monthlyMonths
|
||||
logger.info(`📤 Found ${monthlyMonths.length} months in index`)
|
||||
}
|
||||
|
||||
// 导出全局统计
|
||||
const globalTotal = await redis.client.hgetall('usage:global:total')
|
||||
if (globalTotal && Object.keys(globalTotal).length > 0) {
|
||||
globalStats.globalTotal = globalTotal
|
||||
logger.info('📤 Found global total stats')
|
||||
}
|
||||
|
||||
// 导出全局每日统计
|
||||
const globalDailyKeys = await redis.client.keys('usage:global:daily:*')
|
||||
for (const key of globalDailyKeys) {
|
||||
const date = key.replace('usage:global:daily:', '')
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
globalStats.globalDaily[date] = data
|
||||
}
|
||||
}
|
||||
logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`)
|
||||
|
||||
// 导出全局每月统计
|
||||
const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*')
|
||||
for (const key of globalMonthlyKeys) {
|
||||
const month = key.replace('usage:global:monthly:', '')
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
globalStats.globalMonthly[month] = data
|
||||
}
|
||||
}
|
||||
logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`)
|
||||
|
||||
// 导出全局每日模型统计
|
||||
const modelDailyPattern = 'usage:model:daily:*'
|
||||
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
||||
for (const key of modelDailyKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const model = match[1]
|
||||
@@ -569,9 +699,9 @@ async function exportData() {
|
||||
}
|
||||
|
||||
// 导出全局每月模型统计
|
||||
const globalMonthlyPattern = 'usage:model:monthly:*'
|
||||
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
|
||||
for (const key of globalMonthlyKeys) {
|
||||
const modelMonthlyPattern = 'usage:model:monthly:*'
|
||||
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
||||
for (const key of modelMonthlyKeys) {
|
||||
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (match) {
|
||||
const model = match[1]
|
||||
@@ -1040,6 +1170,46 @@ async function importData() {
|
||||
const pipeline = redis.client.pipeline()
|
||||
let globalStatCount = 0
|
||||
|
||||
// 导入月份索引
|
||||
if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) {
|
||||
for (const month of globalStats.monthlyMonths) {
|
||||
pipeline.sadd('usage:model:monthly:months', month)
|
||||
}
|
||||
logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`)
|
||||
}
|
||||
|
||||
// 导入全局统计
|
||||
if (globalStats.globalTotal) {
|
||||
for (const [field, value] of Object.entries(globalStats.globalTotal)) {
|
||||
pipeline.hset('usage:global:total', field, value)
|
||||
}
|
||||
logger.info('📥 Importing global total stats')
|
||||
}
|
||||
|
||||
// 导入全局每日统计
|
||||
if (globalStats.globalDaily) {
|
||||
for (const [date, data] of Object.entries(globalStats.globalDaily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
pipeline.hset(`usage:global:daily:${date}`, field, value)
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入全局每月统计
|
||||
if (globalStats.globalMonthly) {
|
||||
for (const [month, data] of Object.entries(globalStats.globalMonthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
pipeline.hset(`usage:global:monthly:${month}`, field, value)
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入每日统计
|
||||
if (globalStats.daily) {
|
||||
for (const [date, models] of Object.entries(globalStats.daily)) {
|
||||
@@ -1061,6 +1231,8 @@ async function importData() {
|
||||
}
|
||||
globalStatCount++
|
||||
}
|
||||
// 同时更新月份索引(兼容旧格式导出文件)
|
||||
pipeline.sadd('usage:model:monthly:months', month)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async function cleanTestData() {
|
||||
logger.info('🧹 Cleaning test data...')
|
||||
|
||||
// 获取所有API Keys
|
||||
const allKeys = await apiKeyService.getAllApiKeys()
|
||||
const allKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找出所有测试 API Keys
|
||||
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const logger = require('../src/utils/logger')
|
||||
const readline = require('readline')
|
||||
|
||||
@@ -51,7 +52,7 @@ async function migrateApiKeys() {
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await redis.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
|
||||
|
||||
// 统计信息
|
||||
|
||||
138
scripts/migrate-usage-index.js
Normal file
138
scripts/migrate-usage-index.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 历史数据索引迁移脚本
|
||||
* 为现有的 usage 数据建立索引,加速查询
|
||||
*/
|
||||
const Redis = require('ioredis')
|
||||
const config = require('../config/config')
|
||||
|
||||
const redis = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
db: config.redis.db || 0
|
||||
})
|
||||
|
||||
async function migrate() {
|
||||
console.log('开始迁移历史数据索引...')
|
||||
console.log('Redis DB:', config.redis.db || 0)
|
||||
|
||||
const stats = {
|
||||
dailyIndex: 0,
|
||||
hourlyIndex: 0,
|
||||
modelDailyIndex: 0,
|
||||
modelHourlyIndex: 0
|
||||
}
|
||||
|
||||
// 1. 迁移 usage:daily:{keyId}:{date} 索引
|
||||
console.log('\n1. 迁移 usage:daily 索引...')
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:daily:{keyId}:{date}
|
||||
const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, date] = match
|
||||
pipeline.sadd(`usage:daily:index:${date}`, keyId)
|
||||
pipeline.expire(`usage:daily:index:${date}`, 86400 * 32)
|
||||
stats.dailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.dailyIndex} 条`)
|
||||
|
||||
// 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引
|
||||
console.log('\n2. 迁移 usage:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:hourly:{keyId}:{date}:{hour}
|
||||
const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, hourKey] = match
|
||||
pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId)
|
||||
pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.hourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.hourlyIndex} 条`)
|
||||
|
||||
// 3. 迁移 usage:model:daily:{model}:{date} 索引
|
||||
console.log('\n3. 迁移 usage:model:daily 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:daily:{model}:{date}
|
||||
const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, date] = match
|
||||
pipeline.sadd(`usage:model:daily:index:${date}`, model)
|
||||
pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32)
|
||||
stats.modelDailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelDailyIndex} 条`)
|
||||
|
||||
// 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引
|
||||
console.log('\n4. 迁移 usage:model:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:model:hourly:*',
|
||||
'COUNT',
|
||||
500
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:hourly:{model}:{date}:{hour}
|
||||
const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, hourKey] = match
|
||||
pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model)
|
||||
pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.modelHourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelHourlyIndex} 条`)
|
||||
|
||||
console.log('\n迁移完成!')
|
||||
console.log('统计:', stats)
|
||||
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('迁移失败:', err)
|
||||
redis.disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
91
src/app.js
91
src/app.js
@@ -11,6 +11,7 @@ const logger = require('./utils/logger')
|
||||
const redis = require('./models/redis')
|
||||
const pricingService = require('./services/pricingService')
|
||||
const cacheMonitor = require('./utils/cacheMonitor')
|
||||
const { getSafeMessage } = require('./utils/errorSanitizer')
|
||||
|
||||
// Import routes
|
||||
const apiRoutes = require('./routes/api')
|
||||
@@ -50,7 +51,38 @@ class Application {
|
||||
// 🔗 连接Redis
|
||||
logger.info('🔄 Connecting to Redis...')
|
||||
await redis.connect()
|
||||
logger.success('✅ Redis connected successfully')
|
||||
logger.success('Redis connected successfully')
|
||||
|
||||
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
|
||||
const { getAppVersion, versionGt } = require('./utils/commonHelper')
|
||||
const currentVersion = getAppVersion()
|
||||
const migratedVersion = await redis.getMigratedVersion()
|
||||
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
|
||||
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
|
||||
try {
|
||||
if (await redis.needsGlobalStatsMigration()) {
|
||||
await redis.migrateGlobalStats()
|
||||
}
|
||||
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
|
||||
} catch (err) {
|
||||
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
|
||||
}
|
||||
await redis.setMigratedVersion(currentVersion)
|
||||
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
|
||||
}
|
||||
|
||||
// 📅 后台检查月份索引完整性(不阻塞启动)
|
||||
redis.ensureMonthlyMonthsIndex().catch((err) => {
|
||||
logger.error('📅 月份索引检查失败:', err.message)
|
||||
})
|
||||
|
||||
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
||||
redis.migrateUsageIndex().catch((err) => {
|
||||
logger.error('📊 Background usage index migration failed:', err)
|
||||
})
|
||||
|
||||
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
|
||||
await redis.migrateAlltimeModelStats()
|
||||
|
||||
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||
try {
|
||||
@@ -94,6 +126,15 @@ class Application {
|
||||
)
|
||||
}
|
||||
|
||||
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||
try {
|
||||
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||
}
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
@@ -104,6 +145,18 @@ class Application {
|
||||
const costRankService = require('./services/costRankService')
|
||||
await costRankService.initialize()
|
||||
|
||||
// 🔍 初始化 API Key 索引服务(用于分页查询优化)
|
||||
logger.info('🔍 Initializing API Key index service...')
|
||||
const apiKeyIndexService = require('./services/apiKeyIndexService')
|
||||
apiKeyIndexService.init(redis)
|
||||
await apiKeyIndexService.checkAndRebuild()
|
||||
|
||||
// 📁 确保账户分组反向索引存在(后台执行,不阻塞启动)
|
||||
const accountGroupService = require('./services/accountGroupService')
|
||||
accountGroupService.ensureReverseIndexes().catch((err) => {
|
||||
logger.error('📁 Account group reverse index migration failed:', err)
|
||||
})
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
@@ -377,7 +430,7 @@ class Application {
|
||||
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
error: getSafeMessage(error),
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
@@ -413,7 +466,7 @@ class Application {
|
||||
// 🚨 错误处理
|
||||
this.app.use(errorHandler)
|
||||
|
||||
logger.success('✅ Application initialized successfully')
|
||||
logger.success('Application initialized successfully')
|
||||
} catch (error) {
|
||||
logger.error('💥 Application initialization failed:', error)
|
||||
throw error
|
||||
@@ -448,7 +501,7 @@ class Application {
|
||||
|
||||
await redis.setSession('admin_credentials', adminCredentials)
|
||||
|
||||
logger.success('✅ Admin credentials loaded from init.json (single source of truth)')
|
||||
logger.success('Admin credentials loaded from init.json (single source of truth)')
|
||||
logger.info(`📋 Admin username: ${adminCredentials.username}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize admin credentials:', {
|
||||
@@ -465,22 +518,24 @@ class Application {
|
||||
const client = redis.getClient()
|
||||
|
||||
// 获取所有 session:* 键
|
||||
const sessionKeys = await client.keys('session:*')
|
||||
const sessionKeys = await redis.scanKeys('session:*')
|
||||
const dataList = await redis.batchHgetallChunked(sessionKeys)
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const key of sessionKeys) {
|
||||
for (let i = 0; i < sessionKeys.length; i++) {
|
||||
const key = sessionKeys[i]
|
||||
// 跳过 admin_credentials(系统凭据)
|
||||
if (key === 'session:admin_credentials') {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionData = await client.hgetall(key)
|
||||
const sessionData = dataList[i]
|
||||
|
||||
// 检查会话完整性:必须有 username 和 loginTime
|
||||
const hasUsername = !!sessionData.username
|
||||
const hasLoginTime = !!sessionData.loginTime
|
||||
const hasUsername = !!sessionData?.username
|
||||
const hasLoginTime = !!sessionData?.loginTime
|
||||
|
||||
if (!hasUsername || !hasLoginTime) {
|
||||
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||
@@ -495,11 +550,11 @@ class Application {
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
`Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
)
|
||||
} catch (error) {
|
||||
// 清理失败不应阻止服务启动
|
||||
@@ -549,9 +604,7 @@ class Application {
|
||||
await this.initialize()
|
||||
|
||||
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
||||
logger.start(
|
||||
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
|
||||
)
|
||||
logger.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`)
|
||||
logger.info(
|
||||
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
|
||||
)
|
||||
@@ -606,7 +659,7 @@ class Application {
|
||||
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
||||
}, 5000)
|
||||
|
||||
logger.success('✅ Cache monitoring initialized')
|
||||
logger.success('Cache monitoring initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize cache monitoring:', error)
|
||||
// 不阻止应用启动
|
||||
@@ -655,7 +708,7 @@ class Application {
|
||||
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
const keys = await redis.scanKeys('concurrency:*')
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -837,9 +890,9 @@ class Application {
|
||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||
try {
|
||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
const keys = await redis.scanKeys('concurrency:*')
|
||||
if (keys.length > 0) {
|
||||
await redis.client.del(...keys)
|
||||
await redis.batchDelChunked(keys)
|
||||
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
||||
} else {
|
||||
logger.info('✅ No concurrency keys to clean')
|
||||
@@ -856,7 +909,7 @@ class Application {
|
||||
logger.error('❌ Error disconnecting Redis:', error)
|
||||
}
|
||||
|
||||
logger.success('✅ Graceful shutdown completed')
|
||||
logger.success('Graceful shutdown completed')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const { parseSSELine } = require('../utils/sseParser')
|
||||
const axios = require('axios')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
|
||||
// ============================================================================
|
||||
@@ -136,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
req.rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
req.apiKey?.id,
|
||||
'gemini'
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -353,7 +357,7 @@ async function handleMessages(req, res) {
|
||||
logger.error('Failed to select Gemini account:', error)
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: error.message || 'No available Gemini accounts',
|
||||
message: getSafeMessage(error) || 'No available Gemini accounts',
|
||||
type: 'service_unavailable'
|
||||
}
|
||||
})
|
||||
@@ -492,7 +496,8 @@ async function handleMessages(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -596,7 +601,8 @@ async function handleMessages(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.then(() => {
|
||||
logger.info(
|
||||
@@ -614,7 +620,7 @@ async function handleMessages(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -662,7 +668,7 @@ async function handleMessages(req, res) {
|
||||
const status = errorStatus || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
}
|
||||
@@ -830,16 +836,18 @@ function handleModelDetails(req, res) {
|
||||
*/
|
||||
async function handleUsage(req, res) {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -858,17 +866,18 @@ async function handleUsage(req, res) {
|
||||
async function handleKeyInfo(req, res) {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
@@ -1408,7 +1417,7 @@ async function handleCountTokens(req, res) {
|
||||
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1594,7 +1603,8 @@ async function handleGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
@@ -1630,7 +1640,7 @@ async function handleGenerateContent(req, res) {
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1914,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
),
|
||||
applyRateLimitTracking(
|
||||
req,
|
||||
@@ -1951,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1961,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'stream_error',
|
||||
code: error.code
|
||||
}
|
||||
@@ -1990,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2251,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
@@ -2273,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) {
|
||||
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2680,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.then(() => {
|
||||
logger.info(
|
||||
@@ -2708,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2718,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'stream_error',
|
||||
code: error.code
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
// 工具函数
|
||||
function sleep(ms) {
|
||||
@@ -451,7 +452,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing API key attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message:
|
||||
@@ -461,7 +462,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
|
||||
// 基本API Key格式验证
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid API key format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
@@ -473,7 +474,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`)
|
||||
logger.security(`Invalid API key attempt: ${validation.error} from ${clientIP}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
@@ -1195,12 +1196,16 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Daily cost limit exceeded',
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
code: 'daily_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: dailyCost,
|
||||
costLimit: dailyCostLimit,
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1224,9 +1229,13 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Total cost limit exceeded',
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到总费用限制 ($${totalCostLimit})`,
|
||||
code: 'total_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: totalCost,
|
||||
costLimit: totalCostLimit
|
||||
})
|
||||
@@ -1239,20 +1248,20 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
||||
// 检查 Claude 周费用限制
|
||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
// 从请求中获取模型信息
|
||||
const requestBody = req.body || {}
|
||||
const model = requestBody.model || ''
|
||||
|
||||
// 判断是否为 Opus 模型
|
||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
||||
// 判断是否为 Claude 模型
|
||||
if (isClaudeFamilyModel(model)) {
|
||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||
|
||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||
logger.security(
|
||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1265,18 +1274,22 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
resetDate.setDate(now.getDate() + daysUntilMonday)
|
||||
resetDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Weekly Opus cost limit exceeded',
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
code: 'weekly_opus_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: weeklyOpusCost,
|
||||
costLimit: weeklyOpusCostLimit,
|
||||
resetAt: resetDate.toISOString() // 下周一重置
|
||||
resetAt: resetDate.toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 记录当前 Opus 费用使用情况
|
||||
// 记录当前 Claude 费用使用情况
|
||||
logger.api(
|
||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
||||
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1306,10 +1319,8 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
totalCostLimit: validation.keyData.totalCostLimit,
|
||||
totalCost: validation.keyData.totalCost,
|
||||
usage: validation.keyData.usage
|
||||
totalCost: validation.keyData.totalCost
|
||||
}
|
||||
req.usage = validation.keyData.usage
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
const userAgent = req.headers['user-agent'] || 'No User-Agent'
|
||||
@@ -1357,7 +1368,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
req.headers['x-admin-token']
|
||||
|
||||
if (!token) {
|
||||
logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing admin token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing admin token',
|
||||
message: 'Please provide an admin token'
|
||||
@@ -1366,7 +1377,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
|
||||
// 基本token格式验证
|
||||
if (typeof token !== 'string' || token.length < 32 || token.length > 512) {
|
||||
logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid admin token format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid admin token format',
|
||||
message: 'Admin token format is invalid'
|
||||
@@ -1382,7 +1393,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
])
|
||||
|
||||
if (!adminSession || Object.keys(adminSession).length === 0) {
|
||||
logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid admin token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid admin token',
|
||||
message: 'Invalid or expired admin session'
|
||||
@@ -1440,7 +1451,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
|
||||
return next()
|
||||
} catch (error) {
|
||||
@@ -1471,7 +1482,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
req.headers['x-user-token']
|
||||
|
||||
if (!sessionToken) {
|
||||
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing user session token',
|
||||
message: 'Please login to access this resource'
|
||||
@@ -1480,7 +1491,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
|
||||
// 基本token格式验证
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) {
|
||||
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid user session token format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token format',
|
||||
message: 'Session token format is invalid'
|
||||
@@ -1491,7 +1502,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
const sessionValidation = await userService.validateUserSession(sessionToken)
|
||||
|
||||
if (!sessionValidation) {
|
||||
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token',
|
||||
message: 'Invalid or expired user session'
|
||||
@@ -1582,7 +1593,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
req.userType = 'admin'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -1623,7 +1634,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// 如果都失败了,返回未授权
|
||||
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Authentication failed from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Please login as user or admin to access this resource'
|
||||
|
||||
1751
src/models/redis.js
1751
src/models/redis.js
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,27 @@ function validatePermissions(permissions) {
|
||||
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 serviceRates 格式
|
||||
* @param {any} serviceRates - 服务倍率对象
|
||||
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||
*/
|
||||
function validateServiceRates(serviceRates) {
|
||||
if (serviceRates === undefined || serviceRates === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) {
|
||||
return 'Service rates must be an object'
|
||||
}
|
||||
for (const [service, rate] of Object.entries(serviceRates)) {
|
||||
const numRate = Number(rate)
|
||||
if (!Number.isFinite(numRate) || numRate < 0) {
|
||||
return `Invalid rate for service "${service}": must be a non-negative number`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 👥 用户管理 (用于API Key分配)
|
||||
|
||||
// 获取所有用户列表(用于API Key分配)
|
||||
@@ -116,14 +137,14 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
||||
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 costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`)
|
||||
const costValues = await redis.batchGetChunked(costKeys)
|
||||
const keyValues = {}
|
||||
|
||||
for (const key of costKeys) {
|
||||
keyValues[key] = await client.get(key)
|
||||
for (let i = 0; i < costKeys.length; i++) {
|
||||
keyValues[costKeys[i]] = costValues[i]
|
||||
}
|
||||
|
||||
return res.json({
|
||||
@@ -324,20 +345,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 为每个API Key添加owner的displayName
|
||||
// 为每个API Key添加owner的displayName(批量获取优化)
|
||||
const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))]
|
||||
const userMap = new Map()
|
||||
|
||||
if (userIdsToFetch.length > 0) {
|
||||
// 批量获取用户信息
|
||||
const users = await Promise.all(
|
||||
userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null))
|
||||
)
|
||||
userIdsToFetch.forEach((id, i) => {
|
||||
if (users[i]) {
|
||||
userMap.set(id, users[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const apiKey of result.items) {
|
||||
if (apiKey.userId) {
|
||||
try {
|
||||
const user = await userService.getUserById(apiKey.userId, false)
|
||||
if (user) {
|
||||
if (apiKey.userId && userMap.has(apiKey.userId)) {
|
||||
const user = userMap.get(apiKey.userId)
|
||||
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||||
} else {
|
||||
} else if (apiKey.userId) {
|
||||
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'
|
||||
@@ -608,6 +637,56 @@ router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// 获取 API Key 索引状态
|
||||
router.get('/api-keys/index-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||
const status = await apiKeyIndexService.getStatus()
|
||||
return res.json({ success: true, data: status })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API Key index status:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get index status',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重建 API Key 索引
|
||||
router.post('/api-keys/index-rebuild', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||
const status = await apiKeyIndexService.getStatus()
|
||||
|
||||
if (status.building) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'INDEX_BUILDING',
|
||||
message: '索引正在重建中,请稍后再试',
|
||||
progress: status.progress
|
||||
})
|
||||
}
|
||||
|
||||
// 异步重建,不等待完成
|
||||
apiKeyIndexService.rebuildIndexes().catch((err) => {
|
||||
logger.error('❌ Failed to rebuild API Key index:', err)
|
||||
})
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'API Key 索引重建已开始'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to trigger API Key index rebuild:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to trigger rebuild',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 强制刷新费用排序索引
|
||||
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -673,22 +752,7 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
||||
// 获取已存在的标签列表
|
||||
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()
|
||||
const tags = await apiKeyService.getAllTags()
|
||||
|
||||
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
|
||||
return res.json({ success: true, data: tags })
|
||||
@@ -698,6 +762,93 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 获取标签详情(含使用数量)
|
||||
router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const tagDetails = await apiKeyService.getTagsWithCount()
|
||||
logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`)
|
||||
return res.json({ success: true, data: tagDetails })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get tag details:', error)
|
||||
return res.status(500).json({ error: 'Failed to get tag details', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新标签
|
||||
router.post('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: '标签名称不能为空' })
|
||||
}
|
||||
|
||||
const result = await apiKeyService.createTag(name.trim())
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error })
|
||||
}
|
||||
|
||||
logger.info(`🏷️ Created new tag: ${name}`)
|
||||
return res.json({ success: true, message: '标签创建成功' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to create tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除标签(从所有 API Key 中移除)
|
||||
router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { tagName } = req.params
|
||||
if (!tagName) {
|
||||
return res.status(400).json({ error: 'Tag name is required' })
|
||||
}
|
||||
|
||||
const decodedTagName = decodeURIComponent(tagName)
|
||||
const result = await apiKeyService.removeTagFromAllKeys(decodedTagName)
|
||||
|
||||
logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`,
|
||||
affectedCount: result.affectedCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to delete tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 重命名标签
|
||||
router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { tagName } = req.params
|
||||
const { newName } = req.body
|
||||
if (!tagName || !newName || !newName.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name and new name are required' })
|
||||
}
|
||||
|
||||
const decodedTagName = decodeURIComponent(tagName)
|
||||
const trimmedNewName = newName.trim()
|
||||
const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName)
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).json({ error: result.error })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`
|
||||
)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Tag renamed in ${result.affectedCount} API keys`,
|
||||
affectedCount: result.affectedCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to rename tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to rename tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取账户绑定的 API Key 数量统计
|
||||
* GET /admin/accounts/binding-counts
|
||||
@@ -1298,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
activationDays, // 新增:激活后有效天数
|
||||
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||
expirationMode, // 新增:过期模式
|
||||
icon // 新增:图标
|
||||
icon, // 新增:图标
|
||||
serviceRates // API Key 级别服务倍率
|
||||
} = req.body
|
||||
|
||||
// 输入验证
|
||||
@@ -1425,6 +1577,12 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: permissionsError })
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
const serviceRatesError = validateServiceRates(serviceRates)
|
||||
if (serviceRatesError) {
|
||||
return res.status(400).json({ error: serviceRatesError })
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name,
|
||||
description,
|
||||
@@ -1452,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
})
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`)
|
||||
@@ -1494,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
} = req.body
|
||||
|
||||
// 输入验证
|
||||
@@ -1518,6 +1678,12 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: batchPermissionsError })
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
const batchServiceRatesError = validateServiceRates(serviceRates)
|
||||
if (batchServiceRatesError) {
|
||||
return res.status(400).json({ error: batchServiceRatesError })
|
||||
}
|
||||
|
||||
// 生成批量API Keys
|
||||
const createdKeys = []
|
||||
const errors = []
|
||||
@@ -1552,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
})
|
||||
|
||||
// 保留原始 API Key 供返回
|
||||
@@ -1626,6 +1793,14 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
if (updates.serviceRates !== undefined) {
|
||||
const updateServiceRatesError = validateServiceRates(updates.serviceRates)
|
||||
if (updateServiceRatesError) {
|
||||
return res.status(400).json({ error: updateServiceRatesError })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||||
)
|
||||
@@ -1694,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
if (updates.enabled !== undefined) {
|
||||
finalUpdates.enabled = updates.enabled
|
||||
}
|
||||
if (updates.serviceRates !== undefined) {
|
||||
finalUpdates.serviceRates = updates.serviceRates
|
||||
}
|
||||
|
||||
// 处理账户绑定
|
||||
if (updates.claudeAccountId !== undefined) {
|
||||
@@ -1750,7 +1928,7 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
// 执行更新
|
||||
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
||||
results.successCount++
|
||||
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
|
||||
logger.success(`Batch edit: API key ${keyId} updated successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
||||
@@ -1811,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
totalCostLimit,
|
||||
weeklyOpusCostLimit,
|
||||
tags,
|
||||
ownerId // 新增:所有者ID字段
|
||||
ownerId, // 新增:所有者ID字段
|
||||
serviceRates // API Key 级别服务倍率
|
||||
} = req.body
|
||||
|
||||
// 只允许更新指定字段
|
||||
@@ -1997,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.tags = tags
|
||||
}
|
||||
|
||||
// 处理服务倍率
|
||||
if (serviceRates !== undefined) {
|
||||
const singleServiceRatesError = validateServiceRates(serviceRates)
|
||||
if (singleServiceRatesError) {
|
||||
return res.status(400).json({ error: singleServiceRatesError })
|
||||
}
|
||||
updates.serviceRates = serviceRates
|
||||
}
|
||||
|
||||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||||
if (isActive !== undefined) {
|
||||
if (typeof isActive !== 'boolean') {
|
||||
@@ -2200,7 +2388,7 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
await apiKeyService.deleteApiKey(keyId)
|
||||
results.successCount++
|
||||
|
||||
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
|
||||
logger.success(`Batch delete: API key ${keyId} deleted successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push({
|
||||
@@ -2255,13 +2443,13 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
// 📋 获取已删除的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')
|
||||
const deletedApiKeys = await apiKeyService.getAllApiKeysFast(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',
|
||||
isDeleted: key.isDeleted === true,
|
||||
deletedAt: key.deletedAt,
|
||||
deletedBy: key.deletedBy,
|
||||
deletedByType: key.deletedByType,
|
||||
@@ -2288,7 +2476,7 @@ router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
|
||||
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`)
|
||||
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'API Key 已成功恢复',
|
||||
|
||||
@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Azure OpenAI 账户连通性
|
||||
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
const payload = createOpenAITestPayload(deploymentName)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': apiKey
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model: deploymentName,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -377,7 +377,7 @@ router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
await ccrAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||
@@ -390,7 +390,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
||||
logger.success(`Admin reset status for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account status:', error)
|
||||
@@ -403,7 +403,7 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await ccrAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
||||
logger.success('Admin manually reset daily usage for all CCR accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 CCR 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的凭据
|
||||
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
|
||||
if (!credentials) {
|
||||
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
|
||||
const apiUrl = `${baseUrl}/v1/messages`
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': credentials.apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -36,7 +36,7 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||
})
|
||||
|
||||
logger.success('🔗 Generated OAuth authorization URL with proxy support')
|
||||
logger.success('Generated OAuth authorization URL with proxy support')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -152,7 +152,7 @@ router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, asyn
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||
})
|
||||
|
||||
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
|
||||
logger.success('Generated Setup Token authorization URL with proxy support')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -786,7 +786,7 @@ router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, asy
|
||||
|
||||
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
||||
|
||||
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
|
||||
logger.success(`Updated profile for Claude account: ${accountId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Account profile updated successfully',
|
||||
@@ -805,7 +805,7 @@ router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (re
|
||||
try {
|
||||
const result = await claudeAccountService.updateAllAccountProfiles()
|
||||
|
||||
logger.success('✅ Batch profile update completed')
|
||||
logger.success('Batch profile update completed')
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Batch profile update completed',
|
||||
@@ -841,7 +841,7 @@ router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async
|
||||
|
||||
const result = await claudeAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for Claude account: ${accountId}`)
|
||||
logger.success(`Admin reset status for Claude account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude account status:', error)
|
||||
|
||||
@@ -441,7 +441,7 @@ router.post(
|
||||
const { accountId } = req.params
|
||||
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||
logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
||||
@@ -458,7 +458,7 @@ router.post(
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||
logger.success(`Admin reset status for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||
@@ -472,7 +472,7 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
|
||||
try {
|
||||
await claudeConsoleAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
|
||||
logger.success('Admin manually reset daily usage for all Claude Console accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
||||
|
||||
@@ -20,9 +20,14 @@ const router = express.Router()
|
||||
// 获取系统概览
|
||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
// 先检查是否有全局预聚合数据
|
||||
const globalStats = await redis.getGlobalStats()
|
||||
|
||||
// 根据是否有全局统计决定查询策略
|
||||
let apiKeys = null
|
||||
let apiKeyCount = null
|
||||
|
||||
const [
|
||||
,
|
||||
apiKeys,
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
@@ -35,8 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
@@ -50,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
redis.getRealtimeSystemMetrics()
|
||||
])
|
||||
|
||||
// 有全局统计时只获取计数,否则拉全量
|
||||
if (globalStats) {
|
||||
apiKeyCount = await redis.getApiKeyCount()
|
||||
} else {
|
||||
apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
}
|
||||
|
||||
// 处理Bedrock账户数据
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
@@ -66,250 +76,118 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
normalizeBoolean(acc.schedulable) &&
|
||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
const abnormalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.schedulable) &&
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
||||
isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
// 通用账户统计函数 - 单次遍历完成所有统计
|
||||
const countAccountStats = (accounts, opts = {}) => {
|
||||
const { isStringType = false, checkGeminiRateLimit = false } = opts
|
||||
let normal = 0,
|
||||
abnormal = 0,
|
||||
paused = 0,
|
||||
rateLimited = 0
|
||||
|
||||
// 计算使用统计(统一使用allTokens)
|
||||
const totalTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalRequestsUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.requests || 0),
|
||||
0
|
||||
)
|
||||
const totalInputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalOutputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheCreateTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheReadTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalAllTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
|
||||
|
||||
// Claude账户统计 - 根据账户管理页面的判断逻辑
|
||||
const normalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// Claude Console账户统计
|
||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// Gemini账户统计
|
||||
const normalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
for (const acc of accounts) {
|
||||
const isActive = isStringType
|
||||
? acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
|
||||
: acc.isActive
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
const isSchedulable = isStringType
|
||||
? acc.schedulable !== 'false' && acc.schedulable !== false
|
||||
: acc.schedulable !== false
|
||||
const isRateLimited = checkGeminiRateLimit
|
||||
? acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
)
|
||||
).length
|
||||
const abnormalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
|
||||
// Bedrock账户统计
|
||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
if (!isActive || isBlocked) {
|
||||
abnormal++
|
||||
} else if (!isSchedulable) {
|
||||
paused++
|
||||
} else if (isRateLimited) {
|
||||
rateLimited++
|
||||
} else {
|
||||
normal++
|
||||
}
|
||||
}
|
||||
return { normal, abnormal, paused, rateLimited }
|
||||
}
|
||||
|
||||
// OpenAI账户统计
|
||||
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
||||
const normalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false && // 包括'true'、true和undefined
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
// Droid 账户统计(特殊逻辑)
|
||||
let normalDroidAccounts = 0,
|
||||
abnormalDroidAccounts = 0,
|
||||
pausedDroidAccounts = 0,
|
||||
rateLimitedDroidAccounts = 0
|
||||
for (const acc of droidAccounts) {
|
||||
const isActive = normalizeBoolean(acc.isActive)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
const isSchedulable = normalizeBoolean(acc.schedulable)
|
||||
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
|
||||
|
||||
// CCR账户统计
|
||||
const normalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
if (!isActive || isBlocked) {
|
||||
abnormalDroidAccounts++
|
||||
} else if (!isSchedulable) {
|
||||
pausedDroidAccounts++
|
||||
} else if (isRateLimited) {
|
||||
rateLimitedDroidAccounts++
|
||||
} else {
|
||||
normalDroidAccounts++
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI-Responses账户统计
|
||||
// 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型
|
||||
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
// 计算使用统计
|
||||
let totalTokensUsed = 0,
|
||||
totalRequestsUsed = 0,
|
||||
totalInputTokensUsed = 0,
|
||||
totalOutputTokensUsed = 0,
|
||||
totalCacheCreateTokensUsed = 0,
|
||||
totalCacheReadTokensUsed = 0,
|
||||
totalAllTokensUsed = 0,
|
||||
activeApiKeys = 0,
|
||||
totalApiKeys = 0
|
||||
|
||||
if (globalStats) {
|
||||
// 使用预聚合数据(快速路径)
|
||||
totalRequestsUsed = globalStats.requests
|
||||
totalInputTokensUsed = globalStats.inputTokens
|
||||
totalOutputTokensUsed = globalStats.outputTokens
|
||||
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
|
||||
totalCacheReadTokensUsed = globalStats.cacheReadTokens
|
||||
totalAllTokensUsed = globalStats.allTokens
|
||||
totalTokensUsed = totalAllTokensUsed
|
||||
totalApiKeys = apiKeyCount.total
|
||||
activeApiKeys = apiKeyCount.active
|
||||
} else {
|
||||
// 回退到遍历(兼容旧数据)
|
||||
totalApiKeys = apiKeys.length
|
||||
for (const key of apiKeys) {
|
||||
const usage = key.usage?.total
|
||||
if (usage) {
|
||||
totalTokensUsed += usage.allTokens || 0
|
||||
totalRequestsUsed += usage.requests || 0
|
||||
totalInputTokensUsed += usage.inputTokens || 0
|
||||
totalOutputTokensUsed += usage.outputTokens || 0
|
||||
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||
totalAllTokensUsed += usage.allTokens || 0
|
||||
}
|
||||
if (key.isActive) {
|
||||
activeApiKeys++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 各平台账户统计(单次遍历)
|
||||
const claudeStats = countAccountStats(claudeAccounts)
|
||||
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts)
|
||||
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true })
|
||||
const bedrockStats = countAccountStats(bedrockAccounts)
|
||||
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true })
|
||||
const ccrStats = countAccountStats(ccrAccounts)
|
||||
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true })
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
totalApiKeys,
|
||||
activeApiKeys,
|
||||
// 总账户统计(所有平台)
|
||||
totalAccounts:
|
||||
@@ -321,90 +199,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
openaiResponsesAccounts.length +
|
||||
ccrAccounts.length,
|
||||
normalAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts,
|
||||
claudeStats.normal +
|
||||
claudeConsoleStats.normal +
|
||||
geminiStats.normal +
|
||||
bedrockStats.normal +
|
||||
openaiStats.normal +
|
||||
openaiResponsesStats.normal +
|
||||
ccrStats.normal,
|
||||
abnormalAccounts:
|
||||
abnormalClaudeAccounts +
|
||||
abnormalClaudeConsoleAccounts +
|
||||
abnormalGeminiAccounts +
|
||||
abnormalBedrockAccounts +
|
||||
abnormalOpenAIAccounts +
|
||||
abnormalOpenAIResponsesAccounts +
|
||||
abnormalCcrAccounts +
|
||||
claudeStats.abnormal +
|
||||
claudeConsoleStats.abnormal +
|
||||
geminiStats.abnormal +
|
||||
bedrockStats.abnormal +
|
||||
openaiStats.abnormal +
|
||||
openaiResponsesStats.abnormal +
|
||||
ccrStats.abnormal +
|
||||
abnormalDroidAccounts,
|
||||
pausedAccounts:
|
||||
pausedClaudeAccounts +
|
||||
pausedClaudeConsoleAccounts +
|
||||
pausedGeminiAccounts +
|
||||
pausedBedrockAccounts +
|
||||
pausedOpenAIAccounts +
|
||||
pausedOpenAIResponsesAccounts +
|
||||
pausedCcrAccounts +
|
||||
claudeStats.paused +
|
||||
claudeConsoleStats.paused +
|
||||
geminiStats.paused +
|
||||
bedrockStats.paused +
|
||||
openaiStats.paused +
|
||||
openaiResponsesStats.paused +
|
||||
ccrStats.paused +
|
||||
pausedDroidAccounts,
|
||||
rateLimitedAccounts:
|
||||
rateLimitedClaudeAccounts +
|
||||
rateLimitedClaudeConsoleAccounts +
|
||||
rateLimitedGeminiAccounts +
|
||||
rateLimitedBedrockAccounts +
|
||||
rateLimitedOpenAIAccounts +
|
||||
rateLimitedOpenAIResponsesAccounts +
|
||||
rateLimitedCcrAccounts +
|
||||
claudeStats.rateLimited +
|
||||
claudeConsoleStats.rateLimited +
|
||||
geminiStats.rateLimited +
|
||||
bedrockStats.rateLimited +
|
||||
openaiStats.rateLimited +
|
||||
openaiResponsesStats.rateLimited +
|
||||
ccrStats.rateLimited +
|
||||
rateLimitedDroidAccounts,
|
||||
// 各平台详细统计
|
||||
accountsByPlatform: {
|
||||
claude: {
|
||||
total: claudeAccounts.length,
|
||||
normal: normalClaudeAccounts,
|
||||
abnormal: abnormalClaudeAccounts,
|
||||
paused: pausedClaudeAccounts,
|
||||
rateLimited: rateLimitedClaudeAccounts
|
||||
normal: claudeStats.normal,
|
||||
abnormal: claudeStats.abnormal,
|
||||
paused: claudeStats.paused,
|
||||
rateLimited: claudeStats.rateLimited
|
||||
},
|
||||
'claude-console': {
|
||||
total: claudeConsoleAccounts.length,
|
||||
normal: normalClaudeConsoleAccounts,
|
||||
abnormal: abnormalClaudeConsoleAccounts,
|
||||
paused: pausedClaudeConsoleAccounts,
|
||||
rateLimited: rateLimitedClaudeConsoleAccounts
|
||||
normal: claudeConsoleStats.normal,
|
||||
abnormal: claudeConsoleStats.abnormal,
|
||||
paused: claudeConsoleStats.paused,
|
||||
rateLimited: claudeConsoleStats.rateLimited
|
||||
},
|
||||
gemini: {
|
||||
total: geminiAccounts.length,
|
||||
normal: normalGeminiAccounts,
|
||||
abnormal: abnormalGeminiAccounts,
|
||||
paused: pausedGeminiAccounts,
|
||||
rateLimited: rateLimitedGeminiAccounts
|
||||
normal: geminiStats.normal,
|
||||
abnormal: geminiStats.abnormal,
|
||||
paused: geminiStats.paused,
|
||||
rateLimited: geminiStats.rateLimited
|
||||
},
|
||||
bedrock: {
|
||||
total: bedrockAccounts.length,
|
||||
normal: normalBedrockAccounts,
|
||||
abnormal: abnormalBedrockAccounts,
|
||||
paused: pausedBedrockAccounts,
|
||||
rateLimited: rateLimitedBedrockAccounts
|
||||
normal: bedrockStats.normal,
|
||||
abnormal: bedrockStats.abnormal,
|
||||
paused: bedrockStats.paused,
|
||||
rateLimited: bedrockStats.rateLimited
|
||||
},
|
||||
openai: {
|
||||
total: openaiAccounts.length,
|
||||
normal: normalOpenAIAccounts,
|
||||
abnormal: abnormalOpenAIAccounts,
|
||||
paused: pausedOpenAIAccounts,
|
||||
rateLimited: rateLimitedOpenAIAccounts
|
||||
normal: openaiStats.normal,
|
||||
abnormal: openaiStats.abnormal,
|
||||
paused: openaiStats.paused,
|
||||
rateLimited: openaiStats.rateLimited
|
||||
},
|
||||
ccr: {
|
||||
total: ccrAccounts.length,
|
||||
normal: normalCcrAccounts,
|
||||
abnormal: abnormalCcrAccounts,
|
||||
paused: pausedCcrAccounts,
|
||||
rateLimited: rateLimitedCcrAccounts
|
||||
normal: ccrStats.normal,
|
||||
abnormal: ccrStats.abnormal,
|
||||
paused: ccrStats.paused,
|
||||
rateLimited: ccrStats.rateLimited
|
||||
},
|
||||
'openai-responses': {
|
||||
total: openaiResponsesAccounts.length,
|
||||
normal: normalOpenAIResponsesAccounts,
|
||||
abnormal: abnormalOpenAIResponsesAccounts,
|
||||
paused: pausedOpenAIResponsesAccounts,
|
||||
rateLimited: rateLimitedOpenAIResponsesAccounts
|
||||
normal: openaiResponsesStats.normal,
|
||||
abnormal: openaiResponsesStats.abnormal,
|
||||
paused: openaiResponsesStats.paused,
|
||||
rateLimited: openaiResponsesStats.rateLimited
|
||||
},
|
||||
droid: {
|
||||
total: droidAccounts.length,
|
||||
@@ -416,20 +294,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
// 保留旧字段以兼容
|
||||
activeAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts +
|
||||
claudeStats.normal +
|
||||
claudeConsoleStats.normal +
|
||||
geminiStats.normal +
|
||||
bedrockStats.normal +
|
||||
openaiStats.normal +
|
||||
openaiResponsesStats.normal +
|
||||
ccrStats.normal +
|
||||
normalDroidAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
|
||||
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
|
||||
totalGeminiAccounts: geminiAccounts.length,
|
||||
activeGeminiAccounts: normalGeminiAccounts,
|
||||
rateLimitedGeminiAccounts,
|
||||
activeGeminiAccounts: geminiStats.normal,
|
||||
rateLimitedGeminiAccounts: geminiStats.rateLimited,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
@@ -459,8 +337,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
systemHealth: {
|
||||
redisConnected: redis.isConnected,
|
||||
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
||||
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
|
||||
geminiAccountsHealthy: geminiStats.normal > 0,
|
||||
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||
uptime: process.uptime()
|
||||
},
|
||||
@@ -480,7 +358,7 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
const { period = 'daily' } = req.query // daily, monthly
|
||||
|
||||
// 获取基础API Key统计
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
const stats = apiKeys.map((key) => ({
|
||||
keyId: key.id,
|
||||
@@ -510,55 +388,48 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
||||
)
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取所有模型的统计数据
|
||||
let searchPatterns = []
|
||||
// 收集所有需要扫描的日期
|
||||
const datePatterns = []
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 自定义日期范围,生成多个日期的搜索模式
|
||||
// 自定义日期范围
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
// 确保日期范围有效
|
||||
if (start > end) {
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内所有日期的搜索模式
|
||||
const currentDate = new Date(start)
|
||||
while (currentDate <= end) {
|
||||
const dateStr = redis.getDateStringInTimezone(currentDate)
|
||||
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
|
||||
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
}
|
||||
|
||||
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
|
||||
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`)
|
||||
} else {
|
||||
// 使用默认的period
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:model:daily:*:${today}`
|
||||
: `usage:model:monthly:*:${currentMonth}`
|
||||
searchPatterns = [pattern]
|
||||
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
|
||||
}
|
||||
|
||||
logger.info('📊 Searching patterns:', searchPatterns)
|
||||
|
||||
// 获取所有匹配的keys
|
||||
const allKeys = []
|
||||
for (const pattern of searchPatterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
allKeys.push(...keys)
|
||||
// 按日期集合扫描,串行避免并行触发多次全库 SCAN
|
||||
const allResults = []
|
||||
for (const { pattern } of datePatterns) {
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
allResults.push(...results)
|
||||
}
|
||||
|
||||
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
|
||||
logger.info(`📊 Found ${allResults.length} matching keys in total`)
|
||||
|
||||
// 模型名标准化函数(与redis.js保持一致)
|
||||
const normalizeModelName = (model) => {
|
||||
@@ -568,23 +439,23 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
|
||||
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||
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()
|
||||
|
||||
for (const key of allKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||
for (const { key, data } of allResults) {
|
||||
// 支持 daily 和 monthly 两种格式
|
||||
const match =
|
||||
key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
|
||||
key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/)
|
||||
|
||||
if (!match) {
|
||||
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
||||
@@ -593,7 +464,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const rawModel = match[1]
|
||||
const normalizedModel = normalizeModelName(rawModel)
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const stats = modelStatsMap.get(normalizedModel) || {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -142,38 +143,128 @@ router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res)
|
||||
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await droidAccountService.getAllAccounts()
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 添加使用统计
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||
let groupInfos = []
|
||||
try {
|
||||
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
} catch (groupError) {
|
||||
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
||||
groupInfos = []
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'),
|
||||
redis.batchGetAccountDailyCost(accountIds)
|
||||
])
|
||||
|
||||
// 构建绑定数映射(droid 需要展开 group 绑定)
|
||||
// 1. 先构建 groupId -> accountIds 映射
|
||||
const groupToAccountIds = new Map()
|
||||
for (const [accountId, groups] of allGroupInfosMap) {
|
||||
for (const group of groups) {
|
||||
if (!groupToAccountIds.has(group.id)) {
|
||||
groupToAccountIds.set(group.id, [])
|
||||
}
|
||||
groupToAccountIds.get(group.id).push(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
// 2. 单次遍历构建绑定数
|
||||
const directBindingCount = new Map()
|
||||
const groupBindingCount = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.droidAccountId
|
||||
if (!binding) {
|
||||
return count
|
||||
}
|
||||
if (binding === account.id) {
|
||||
return count + 1
|
||||
continue
|
||||
}
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
if (groupIds.includes(groupId)) {
|
||||
return count + 1
|
||||
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)
|
||||
} else {
|
||||
directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, 0)
|
||||
|
||||
// 批量获取使用统计
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
|
||||
// 构建 accountId -> createdAt 映射用于计算 averages
|
||||
const accountCreatedAtMap = new Map()
|
||||
for (const account of accounts) {
|
||||
accountCreatedAtMap.set(
|
||||
account.id,
|
||||
account.createdAt ? new Date(account.createdAt) : new Date()
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const totalData = errTotal ? {} : parseUsage(total)
|
||||
const totalTokens = totalData.tokens || 0
|
||||
const totalRequests = totalData.requests || 0
|
||||
|
||||
// 计算 averages
|
||||
const createdAt = accountCreatedAtMap.get(accountId)
|
||||
const now = new Date()
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: totalData,
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly),
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
|
||||
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理账户数据
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
monthly: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
|
||||
}
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
|
||||
// 计算绑定数:直接绑定 + 通过 group 绑定
|
||||
let boundApiKeysCount = directBindingCount.get(account.id) || 0
|
||||
for (const group of groupInfos) {
|
||||
boundApiKeysCount += groupBindingCount.get(group.id) || 0
|
||||
}
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
@@ -182,27 +273,13 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
boundApiKeysCount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
boundApiKeysCount: 0,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -434,7 +511,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 获取绑定的 API Key 数量
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
const binding = key.droidAccountId
|
||||
@@ -524,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Droid 账户连通性
|
||||
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await droidAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await droidAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const { accessToken } = tokenResult
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = 'https://api.factory.ai/v1/messages'
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -74,7 +74,7 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||
logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||
return res.json({ success: true, data: { tokens: result.tokens } })
|
||||
} else {
|
||||
return res.json({ success: false, error: result.error })
|
||||
@@ -143,7 +143,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||
logger.success('Successfully exchanged Gemini authorization code')
|
||||
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||
@@ -498,7 +498,7 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const result = await geminiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
|
||||
logger.success(`Admin reset status for Gemini account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini account status:', error)
|
||||
@@ -506,4 +506,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Gemini 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gemini-2.5-flash' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const { accessToken } = tokenResult
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||
const payload = createGeminiTestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||
responseText = response.data.candidates[0].content.parts[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -31,53 +31,108 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
// 检查并清除过期的限流状态
|
||||
await geminiApiAccountService.checkAndClearRateLimit(account.id)
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
|
||||
usageStats = {
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'),
|
||||
redis.batchGetAccountDailyCost(accountIds),
|
||||
// 批量清除限流状态
|
||||
Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id)))
|
||||
])
|
||||
|
||||
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||
const bindingCountMap = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.geminiAccountId
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
// 处理 api: 前缀
|
||||
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
|
||||
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||
}
|
||||
|
||||
// 批量获取使用统计
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: errTotal ? {} : parseUsage(total),
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理账户数据
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
const boundCount = bindingCountMap.get(account.id) || 0
|
||||
|
||||
// 计算绑定的API Key数量(支持 api: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key.geminiAccountId) {
|
||||
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
|
||||
if (key.geminiAccountId === `api:${account.id}`) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
// 计算 averages(rpm/tpm)
|
||||
const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
|
||||
const daysSinceCreated = Math.max(
|
||||
1,
|
||||
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
const totalMinutes = daysSinceCreated * 24 * 60
|
||||
const totalRequests = usageStats.total.requests || 0
|
||||
const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
|
||||
|
||||
return {
|
||||
...account,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages || usageStats.monthly
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
|
||||
}
|
||||
},
|
||||
boundApiKeys: boundCount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -275,7 +330,7 @@ router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) =>
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`✅ ${message}`)
|
||||
logger.success(`${message}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -389,7 +444,7 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
|
||||
|
||||
const result = await geminiApiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
|
||||
logger.success(`Admin reset status for Gemini-API account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
||||
|
||||
@@ -26,6 +26,8 @@ const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
const serviceRatesRoutes = require('./serviceRates')
|
||||
const quotaCardsRoutes = require('./quotaCards')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -43,6 +45,8 @@ router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
router.use('/', serviceRatesRoutes)
|
||||
router.use('/', quotaCardsRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
@@ -80,7 +80,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
||||
|
||||
logger.success('🔗 Generated OpenAI OAuth authorization URL')
|
||||
logger.success('Generated OpenAI OAuth authorization URL')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -191,7 +191,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
// 清理 Redis 会话
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
|
||||
logger.success('✅ OpenAI OAuth token exchange successful')
|
||||
logger.success('OpenAI OAuth token exchange successful')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -386,7 +386,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
delete refreshedAccount.accessToken
|
||||
delete refreshedAccount.refreshToken
|
||||
|
||||
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -450,7 +450,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -541,7 +541,7 @@ router.put('/:id', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(`✅ Token 验证成功,继续更新账户信息`)
|
||||
logger.success(`Token 验证成功,继续更新账户信息`)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,恢复原始 token
|
||||
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
||||
@@ -755,7 +755,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||
logger.success(`Admin reset status for OpenAI account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||
|
||||
@@ -39,64 +39,84 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
// 检查是否需要重置额度
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
// 今天还没重置过,需要重置
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: today,
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
account.dailyUsage = '0'
|
||||
account.lastResetDate = today
|
||||
account.quotaStoppedAt = ''
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
|
||||
redis.batchGetAccountDailyCost(accountIds),
|
||||
// 批量清理限流状态
|
||||
Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
|
||||
])
|
||||
|
||||
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||
const bindingCountMap = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.openaiAccountId
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
// 处理 responses: 前缀
|
||||
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
|
||||
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||
}
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
|
||||
// 批量获取使用统计(不含 daily cost,已单独获取)
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
|
||||
error
|
||||
)
|
||||
usageStats = {
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: errTotal ? {} : parseUsage(total),
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 计算绑定的API Key数量(支持 responses: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
|
||||
for (const key of allKeys) {
|
||||
// 检查是否绑定了该账户(包括 responses: 前缀)
|
||||
if (
|
||||
key.openaiAccountId === account.id ||
|
||||
key.openaiAccountId === `responses:${account.id}`
|
||||
) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 调试日志:检查绑定计数
|
||||
if (boundCount > 0) {
|
||||
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
|
||||
}
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const boundCount = bindingCountMap.get(account.id) || 0
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
@@ -104,27 +124,12 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
|
||||
groupInfos,
|
||||
boundApiKeysCount: boundCount,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos: [],
|
||||
boundApiKeysCount: 0,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -413,7 +418,7 @@ router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, as
|
||||
|
||||
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
|
||||
logger.success(`Admin reset status for OpenAI-Responses account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
||||
@@ -432,7 +437,7 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||
logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -447,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 OpenAI-Responses 账户连通性
|
||||
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gpt-4o-mini' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||
const payload = createOpenAITestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
242
src/routes/admin/quotaCards.js
Normal file
242
src/routes/admin/quotaCards.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 额度卡/时间卡管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const quotaCardService = require('../../services/quotaCardService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取额度卡上限配置
|
||||
router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await quotaCardService.getLimitsConfig()
|
||||
res.json({ success: true, data: config })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card limits:', error)
|
||||
res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新额度卡上限配置
|
||||
router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body
|
||||
const config = await quotaCardService.saveLimitsConfig({
|
||||
enabled,
|
||||
maxExpiryDays,
|
||||
maxTotalCostLimit
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to save quota card limits:', error)
|
||||
res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取额度卡列表
|
||||
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getAllCards({
|
||||
status,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota cards:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取额度卡统计
|
||||
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await quotaCardService.getCardStats()
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card stats:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个额度卡详情
|
||||
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const card = await quotaCardService.getCardById(req.params.id)
|
||||
if (!card) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Card not found'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: card
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建额度卡
|
||||
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
|
||||
|
||||
if (!type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'type is required'
|
||||
})
|
||||
}
|
||||
|
||||
const createdBy = req.session?.username || 'admin'
|
||||
const options = {
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
expiresAt,
|
||||
note,
|
||||
createdBy
|
||||
}
|
||||
|
||||
let result
|
||||
if (count > 1) {
|
||||
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
|
||||
} else {
|
||||
result = await quotaCardService.createCard(options)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除未使用的额度卡
|
||||
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await quotaCardService.deleteCard(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 核销记录管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取核销记录列表
|
||||
router.get('/redemptions', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId,
|
||||
apiKeyId,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 撤销核销
|
||||
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { reason } = req.body
|
||||
const revokedBy = req.session?.username || 'admin'
|
||||
|
||||
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 延长有效期
|
||||
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { amount, unit = 'days' } = req.body
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'amount must be a positive number'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend expiry:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
72
src/routes/admin/serviceRates.js
Normal file
72
src/routes/admin/serviceRates.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 服务倍率配置管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const serviceRatesService = require('../../services/serviceRatesService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// 获取服务倍率配置
|
||||
router.get('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新服务倍率配置
|
||||
router.put('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { rates, baseService } = req.body
|
||||
|
||||
if (!rates || typeof rates !== 'object') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'rates is required and must be an object'
|
||||
})
|
||||
}
|
||||
|
||||
const updatedBy = req.session?.username || 'admin'
|
||||
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可用服务列表
|
||||
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const services = await serviceRatesService.getAvailableServices()
|
||||
res.json({
|
||||
success: true,
|
||||
data: services
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get available services:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -288,10 +288,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const openaiIds = await redis.getAllIdsByIndex(
|
||||
'openai:account:index',
|
||||
'openai:account:*',
|
||||
/^openai:account:(.+)$/
|
||||
)
|
||||
for (const id of openaiIds) {
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
@@ -390,10 +392,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const openaiResponseIds = await redis.getAllIdsByIndex(
|
||||
'openai_responses_account:index',
|
||||
'openai_responses_account:*',
|
||||
/^openai_responses_account:(.+)$/
|
||||
)
|
||||
for (const id of openaiResponseIds) {
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
|
||||
@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
apiStatsNotice: {
|
||||
enabled: false,
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -296,7 +301,7 @@ 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, apiStatsNotice } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
apiStatsNotice: {
|
||||
enabled: apiStatsNotice?.enabled === true,
|
||||
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||||
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,21 @@ const {
|
||||
} = require('../services/anthropicGeminiBridgeService')
|
||||
const router = express.Router()
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
function queueRateLimitUpdate(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return Promise.resolve({ totalTokens: 0, totalCost: 0 })
|
||||
}
|
||||
|
||||
const label = context ? ` (${context})` : ''
|
||||
|
||||
return updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
||||
return updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
|
||||
.then(({ totalTokens, totalCost }) => {
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -477,7 +484,7 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
|
||||
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
@@ -491,7 +498,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-stream'
|
||||
'claude-stream',
|
||||
_apiKeyId,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -589,7 +598,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-console-stream'
|
||||
'claude-console-stream',
|
||||
_apiKeyIdConsole,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -637,7 +648,8 @@ async function handleMessagesRequest(req, res) {
|
||||
0,
|
||||
0,
|
||||
result.model,
|
||||
accountId
|
||||
accountId,
|
||||
'bedrock'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||
@@ -652,7 +664,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens: 0
|
||||
},
|
||||
result.model,
|
||||
'bedrock-stream'
|
||||
'bedrock-stream',
|
||||
_apiKeyIdBedrock,
|
||||
'bedrock'
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -744,7 +758,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'ccr-stream'
|
||||
'ccr-stream',
|
||||
_apiKeyIdCcr,
|
||||
'ccr'
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -1104,7 +1120,8 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
responseAccountId
|
||||
responseAccountId,
|
||||
accountType
|
||||
)
|
||||
|
||||
await queueRateLimitUpdate(
|
||||
@@ -1116,7 +1133,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-non-stream'
|
||||
'claude-non-stream',
|
||||
_apiKeyIdNonStream,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageRecorded = true
|
||||
|
||||
@@ -5,10 +5,39 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const modelsConfig = require('../../config/models')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 📋 获取可用模型列表(公开接口)
|
||||
router.get('/models', (req, res) => {
|
||||
const { service } = req.query
|
||||
|
||||
if (service) {
|
||||
// 返回指定服务的模型
|
||||
const models = modelsConfig.getModelsByService(service)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: models
|
||||
})
|
||||
}
|
||||
|
||||
// 返回所有模型(按服务分组)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
claude: modelsConfig.CLAUDE_MODELS,
|
||||
gemini: modelsConfig.GEMINI_MODELS,
|
||||
openai: modelsConfig.OPENAI_MODELS,
|
||||
other: modelsConfig.OTHER_MODELS,
|
||||
all: modelsConfig.getAllModels()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 🏠 重定向页面请求到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(301, '/admin-next/api-stats')
|
||||
@@ -39,7 +68,7 @@ router.post('/api/get-key-id', async (req, res) => {
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||||
logger.security(`Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
@@ -87,7 +116,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
@@ -166,7 +195,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
} else if (apiKey) {
|
||||
// 通过 apiKey 查询(保持向后兼容)
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
@@ -191,7 +220,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
keyData = validatedKeyData
|
||||
keyId = keyData.id
|
||||
} else {
|
||||
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||||
return res.status(400).json({
|
||||
error: 'API Key or ID is required',
|
||||
message: 'Please provide your API Key or API ID'
|
||||
@@ -224,17 +253,16 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
|
||||
} else {
|
||||
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
|
||||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||
const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`)
|
||||
const modelUsageMap = new Map()
|
||||
|
||||
for (const key of allModelKeys) {
|
||||
for (const { key, data } of allModelResults) {
|
||||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (!modelMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = modelMatch[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
@@ -475,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
restrictedModels: fullKeyData.restrictedModels || [],
|
||||
enableClientRestriction: fullKeyData.enableClientRestriction || false,
|
||||
allowedClients: fullKeyData.allowedClients || []
|
||||
},
|
||||
|
||||
// Key 级别的服务倍率
|
||||
serviceRates: (() => {
|
||||
try {
|
||||
return fullKeyData.serviceRates
|
||||
? typeof fullKeyData.serviceRates === 'string'
|
||||
? JSON.parse(fullKeyData.serviceRates)
|
||||
: fullKeyData.serviceRates
|
||||
: {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return res.json({
|
||||
@@ -598,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => {
|
||||
...usage.monthly,
|
||||
cost: costStats.monthly
|
||||
},
|
||||
totalCost: costStats.total
|
||||
totalCost: costStats.total,
|
||||
serviceRates: (() => {
|
||||
try {
|
||||
return keyData.serviceRates
|
||||
? typeof keyData.serviceRates === 'string'
|
||||
? JSON.parse(keyData.serviceRates)
|
||||
: keyData.serviceRates
|
||||
: {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -702,7 +754,7 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const _client = redis.getClientSafe()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
@@ -717,9 +769,9 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
? `usage:${apiId}:model:daily:*:${today}`
|
||||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
for (const { key, data } of results) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
@@ -731,7 +783,6 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
@@ -741,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
allTokens: 0,
|
||||
realCostMicro: 0,
|
||||
ratedCostMicro: 0,
|
||||
hasStoredCost: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -752,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||
modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0
|
||||
modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0
|
||||
// 检查 Redis 数据是否包含成本字段
|
||||
if ('realCostMicro' in data || 'ratedCostMicro' in data) {
|
||||
modelUsage.hasStoredCost = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 转换为数组并计算费用
|
||||
// 转换为数组并处理费用
|
||||
const modelStats = []
|
||||
for (const [model, usage] of modelUsageMap) {
|
||||
const usageData = {
|
||||
@@ -767,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
cache_read_input_tokens: usage.cacheReadTokens
|
||||
}
|
||||
|
||||
// 优先使用存储的费用,否则回退到重新计算
|
||||
const { hasStoredCost } = usage
|
||||
const costData = CostCalculator.calculateCost(usageData, model)
|
||||
|
||||
// 如果有存储的费用,覆盖计算的费用
|
||||
if (hasStoredCost) {
|
||||
costData.costs.real = (usage.realCostMicro || 0) / 1000000
|
||||
costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000
|
||||
costData.costs.total = costData.costs.real // 保持兼容
|
||||
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||
}
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: usage.requests,
|
||||
@@ -779,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
allTokens: usage.allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
pricing: costData.pricing,
|
||||
isLegacy: !hasStoredCost
|
||||
})
|
||||
}
|
||||
|
||||
@@ -802,13 +873,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// maxTokens 白名单
|
||||
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
|
||||
const sanitizeMaxTokens = (value) =>
|
||||
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
|
||||
|
||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||
router.post('/api-key/test', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
@@ -841,7 +918,7 @@ router.post('/api-key/test', async (req, res) => {
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
@@ -851,13 +928,317 @@ router.post('/api-key/test', async (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message || 'Internal server error'
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 Gemini API Key 端点测试接口
|
||||
router.post('/api-key/test-gemini', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 Gemini 权限
|
||||
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have Gemini permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// Gemini 格式: candidates[0].content.parts[0].text
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
if (text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Gemini API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 OpenAI/Codex API Key 端点测试接口
|
||||
router.post('/api-key/test-openai', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 OpenAI 权限
|
||||
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have OpenAI permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'User-Agent': 'codex_cli_rs/1.0.0'
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// OpenAI Responses 格式: output[].content[].text 或 delta
|
||||
if (data.type === 'response.output_text.delta' && data.delta) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
|
||||
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
@@ -886,7 +1267,7 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
@@ -942,33 +1323,37 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
)
|
||||
|
||||
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
||||
const client = redis.getClientSafe()
|
||||
const _client = redis.getClientSafe()
|
||||
// 使用与管理页面相同的时区处理逻辑
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:${keyId}:model:daily:*:${today}`
|
||||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
let pattern
|
||||
let matchRegex
|
||||
if (period === 'daily') {
|
||||
pattern = `usage:${keyId}:model:daily:*:${today}`
|
||||
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
} else if (period === 'alltime') {
|
||||
pattern = `usage:${keyId}:model:alltime:*`
|
||||
matchRegex = /usage:.+:model:alltime:(.+)$/
|
||||
} else {
|
||||
// monthly
|
||||
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
}
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
const modelStats = []
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
)
|
||||
for (const { key, data } of results) {
|
||||
const match = key.match(matchRegex)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const usage = {
|
||||
@@ -978,8 +1363,30 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
||||
}
|
||||
|
||||
// 优先使用存储的费用,否则回退到重新计算
|
||||
// 检查字段是否存在(而非 > 0),以支持真正的零成本场景
|
||||
const realCostMicro = parseInt(data.realCostMicro) || 0
|
||||
const ratedCostMicro = parseInt(data.ratedCostMicro) || 0
|
||||
const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data
|
||||
const costData = CostCalculator.calculateCost(usage, model)
|
||||
|
||||
// 如果有存储的费用,覆盖计算的费用
|
||||
if (hasStoredCost) {
|
||||
costData.costs.real = realCostMicro / 1000000
|
||||
costData.costs.rated = ratedCostMicro / 1000000
|
||||
costData.costs.total = costData.costs.real
|
||||
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||
}
|
||||
|
||||
// alltime 键不存储 allTokens,需要计算
|
||||
const allTokens =
|
||||
period === 'alltime'
|
||||
? usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
: parseInt(data.allTokens) || 0
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: parseInt(data.requests) || 0,
|
||||
@@ -987,10 +1394,11 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
allTokens: parseInt(data.allTokens) || 0,
|
||||
allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
pricing: costData.pricing,
|
||||
isLegacy: !hasStoredCost
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1018,4 +1426,170 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取服务倍率配置(公开接口)
|
||||
router.get('/service-rates', async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve service rates'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份)
|
||||
router.post('/api/redeem-card', async (req, res) => {
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
try {
|
||||
const { apiId, code } = req.body
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
const hour = new Date().toISOString().slice(0, 13)
|
||||
|
||||
// 防暴力破解:检查失败锁定
|
||||
const failKey = `redeem_card:fail:${clientIP}`
|
||||
const failCount = parseInt((await redis.client.get(failKey)) || '0')
|
||||
if (failCount >= 5) {
|
||||
logger.security(`🔒 Card redemption locked for IP: ${clientIP}`)
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '失败次数过多,请1小时后再试'
|
||||
})
|
||||
}
|
||||
|
||||
// 防暴力破解:检查 IP 速率限制
|
||||
const ipKey = `redeem_card:ip:${clientIP}:${hour}`
|
||||
const ipCount = await redis.client.incr(ipKey)
|
||||
await redis.client.expire(ipKey, 3600)
|
||||
if (ipCount > 10) {
|
||||
logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`)
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
error: '请求过于频繁,请稍后再试'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiId || !code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '请输入卡号'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 apiId 格式
|
||||
if (
|
||||
typeof apiId !== 'string' ||
|
||||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'API ID 格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 存在且有效
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API Key 不存在'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.isActive !== 'true') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'API Key 已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 调用兑换服务
|
||||
const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats')
|
||||
|
||||
// 成功时清除失败计数(静默处理,不影响成功响应)
|
||||
redis.client.del(failKey).catch(() => {})
|
||||
|
||||
logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
// 失败时增加失败计数(静默处理,不影响错误响应)
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
const failKey = `redeem_card:fail:${clientIP}`
|
||||
redis.client
|
||||
.incr(failKey)
|
||||
.then(() => redis.client.expire(failKey, 3600))
|
||||
.catch(() => {})
|
||||
|
||||
logger.error('❌ Failed to redeem card:', error)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📋 公开的兑换记录查询接口(通过 apiId 验证身份)
|
||||
router.get('/api/redemption-history', async (req, res) => {
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
try {
|
||||
const { apiId, limit = 50, offset = 0 } = req.query
|
||||
|
||||
if (!apiId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少 API ID'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 apiId 格式
|
||||
if (
|
||||
typeof apiId !== 'string' ||
|
||||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'API ID 格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 存在
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API Key 不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取该 API Key 的兑换记录
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
apiKeyId: apiId,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemption history:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -86,7 +86,8 @@ class AtomicUsageReporter {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
accountId,
|
||||
'azure-openai'
|
||||
)
|
||||
|
||||
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
|
||||
|
||||
@@ -13,6 +13,7 @@ const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const pricingService = require('../services/pricingService')
|
||||
@@ -23,14 +24,21 @@ function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
function queueRateLimitUpdate(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = context ? ` (${context})` : ''
|
||||
|
||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
|
||||
.then(({ totalTokens, totalCost }) => {
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -300,7 +308,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
`openai-${accountType}-stream`
|
||||
`openai-${accountType}-stream`,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -425,7 +435,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
claudeRequest.model,
|
||||
`openai-${accountType}-non-stream`
|
||||
`openai-${accountType}-non-stream`,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
|
||||
@@ -452,7 +464,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const status = error.status || 500
|
||||
res.status(status).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error),
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
|
||||
@@ -539,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
@@ -640,7 +641,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`
|
||||
|
||||
@@ -9,9 +9,12 @@ const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const { IncrementalSSEParser } = require('../utils/sseParser')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
|
||||
// 创建代理 Agent(使用统一的代理工具)
|
||||
function createProxyAgent(proxy) {
|
||||
@@ -67,7 +70,7 @@ function extractCodexUsageHeaders(headers) {
|
||||
return hasData ? snapshot : null
|
||||
}
|
||||
|
||||
async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) {
|
||||
if (!req.rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -78,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
req.rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -577,7 +582,6 @@ const handleResponses = async (req, res) => {
|
||||
}
|
||||
|
||||
// 处理响应并捕获 usage 数据和真实的 model
|
||||
let buffer = ''
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let usageReported = false
|
||||
@@ -613,7 +617,8 @@ const handleResponses = async (req, res) => {
|
||||
0, // OpenAI没有cache_creation_tokens
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
accountId
|
||||
accountId,
|
||||
'openai'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -629,7 +634,8 @@ const handleResponses = async (req, res) => {
|
||||
cacheReadTokens
|
||||
},
|
||||
actualModel,
|
||||
'openai-non-stream'
|
||||
'openai-non-stream',
|
||||
'openai'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -645,21 +651,11 @@ const handleResponses = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据和 model
|
||||
const parseSSEForUsage = (data) => {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: response.completed')) {
|
||||
// 下一行应该是数据
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
// 使用增量 SSE 解析器
|
||||
const sseParser = new IncrementalSSEParser()
|
||||
|
||||
// 处理解析出的事件
|
||||
const processSSEEvent = (eventData) => {
|
||||
// 检查是否是 response.completed 事件
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
// 从响应中获取真实的 model
|
||||
@@ -685,34 +681,20 @@ const handleResponses = async (req, res) => {
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstream.data.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 转发数据给客户端
|
||||
if (!res.destroyed) {
|
||||
res.write(chunk)
|
||||
}
|
||||
|
||||
// 同时解析数据以捕获 usage 信息
|
||||
buffer += chunkStr
|
||||
|
||||
// 处理完整的 SSE 事件
|
||||
if (buffer.includes('\n\n')) {
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
|
||||
|
||||
// 使用增量解析器处理数据
|
||||
const events = sseParser.feed(chunk.toString())
|
||||
for (const event of events) {
|
||||
if (event.trim()) {
|
||||
parseSSEForUsage(event)
|
||||
}
|
||||
if (event.type === 'data' && event.data) {
|
||||
processSSEEvent(event.data)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -722,8 +704,14 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
upstream.data.on('end', async () => {
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim()) {
|
||||
parseSSEForUsage(buffer)
|
||||
const remaining = sseParser.getRemaining()
|
||||
if (remaining.trim()) {
|
||||
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
|
||||
for (const event of events) {
|
||||
if (event.type === 'data' && event.data) {
|
||||
processSSEEvent(event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录使用统计
|
||||
@@ -745,7 +733,8 @@ const handleResponses = async (req, res) => {
|
||||
0, // OpenAI没有cache_creation_tokens
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
accountId,
|
||||
'openai'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -762,7 +751,8 @@ const handleResponses = async (req, res) => {
|
||||
cacheReadTokens
|
||||
},
|
||||
modelToRecord,
|
||||
'openai-stream'
|
||||
'openai-stream',
|
||||
'openai'
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record OpenAI usage:', error)
|
||||
@@ -852,13 +842,15 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
let responsePayload = error.response?.data
|
||||
if (!responsePayload) {
|
||||
responsePayload = { error: { message: error.message || 'Internal server error' } }
|
||||
responsePayload = { error: { message: getSafeMessage(error) } }
|
||||
} else if (typeof responsePayload === 'string') {
|
||||
responsePayload = { error: { message: responsePayload } }
|
||||
responsePayload = { error: { message: getSafeMessage(responsePayload) } }
|
||||
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
|
||||
responsePayload = {
|
||||
error: { message: responsePayload.message || error.message || 'Internal server error' }
|
||||
error: { message: getSafeMessage(responsePayload.message || error) }
|
||||
}
|
||||
} else if (responsePayload.error?.message) {
|
||||
responsePayload.error.message = getSafeMessage(responsePayload.error.message)
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
@@ -876,16 +868,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -902,25 +896,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
usage: {
|
||||
total: keyData.usage.total,
|
||||
daily: keyData.usage.daily,
|
||||
monthly: keyData.usage.monthly
|
||||
total: usage?.total || {},
|
||||
daily: usage?.daily || {},
|
||||
monthly: usage?.monthly || {}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡核销相关路由
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
// 🎫 核销额度卡
|
||||
router.post('/redeem-card', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { code, apiKeyId } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing card code',
|
||||
message: 'Card code is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only redeem cards to your own API keys'
|
||||
})
|
||||
}
|
||||
|
||||
// 执行核销
|
||||
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
|
||||
|
||||
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Redeem card error:', error)
|
||||
res.status(400).json({
|
||||
error: 'Redeem failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📋 获取用户的核销历史
|
||||
router.get('/redemption-history', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0 } = req.query
|
||||
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId: req.user.id,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get redemption history error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get redemption history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户的额度信息
|
||||
router.get('/quota-info', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.query
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only view your own API key quota'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否为聚合 Key
|
||||
if (keyData.isAggregated !== 'true') {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: false,
|
||||
message: 'This is a traditional API key, not using quota system'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 解析聚合 Key 数据
|
||||
let permissions = []
|
||||
let serviceQuotaLimits = {}
|
||||
let serviceQuotaUsed = {}
|
||||
|
||||
try {
|
||||
permissions = JSON.parse(keyData.permissions || '[]')
|
||||
} catch (e) {
|
||||
permissions = [keyData.permissions]
|
||||
}
|
||||
|
||||
try {
|
||||
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
|
||||
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||
} catch (e) {
|
||||
// 解析失败使用默认值
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: true,
|
||||
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
|
||||
permissions,
|
||||
serviceQuotaLimits,
|
||||
serviceQuotaUsed,
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get quota info error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get quota info',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -74,7 +74,7 @@ router.post('/auth/login', async (req, res) => {
|
||||
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
|
||||
|
||||
if (!isValidUsername || !isValidPassword) {
|
||||
logger.security(`🔒 Failed login attempt for username: ${username}`)
|
||||
logger.security(`Failed login attempt for username: ${username}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid credentials',
|
||||
message: 'Invalid username or password'
|
||||
@@ -96,7 +96,7 @@ router.post('/auth/login', async (req, res) => {
|
||||
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
|
||||
// init.json 是唯一真实数据源
|
||||
|
||||
logger.success(`🔐 Admin login successful: ${username}`)
|
||||
logger.success(`Admin login successful: ${username}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -197,7 +197,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 验证当前密码
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
|
||||
if (!isValidPassword) {
|
||||
logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`)
|
||||
logger.security(`Invalid current password attempt for user: ${sessionData.username}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid current password',
|
||||
message: 'Current password is incorrect'
|
||||
@@ -253,7 +253,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 清除当前会话(强制用户重新登录)
|
||||
await redis.deleteSession(token)
|
||||
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`)
|
||||
logger.success(`Admin password changed successfully for user: ${updatedUsername}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -294,7 +294,7 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
@@ -352,7 +352,7 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
|
||||
@@ -7,6 +7,62 @@ class AccountGroupService {
|
||||
this.GROUPS_KEY = 'account_groups'
|
||||
this.GROUP_PREFIX = 'account_group:'
|
||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
|
||||
this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:'
|
||||
this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated'
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保反向索引存在(启动时自动调用)
|
||||
* 检查是否已迁移,如果没有则自动回填
|
||||
*/
|
||||
async ensureReverseIndexes() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已迁移
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated === 'true') {
|
||||
logger.debug('📁 账户分组反向索引已存在,跳过回填')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('📁 开始回填账户分组反向索引...')
|
||||
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
if (allGroupIds.length === 0) {
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
return
|
||||
}
|
||||
|
||||
let totalOperations = 0
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!group || !group.platform) {
|
||||
continue
|
||||
}
|
||||
|
||||
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
if (members.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of members) {
|
||||
pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId)
|
||||
}
|
||||
await pipeline.exec()
|
||||
totalOperations += members.length
|
||||
}
|
||||
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations} 条`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 账户分组反向索引回填失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +106,7 @@ class AccountGroupService {
|
||||
// 添加到分组集合
|
||||
await client.sadd(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`)
|
||||
logger.success(`创建账户分组成功: ${name} (${platform})`)
|
||||
|
||||
return group
|
||||
} catch (error) {
|
||||
@@ -101,7 +157,7 @@ class AccountGroupService {
|
||||
// 返回更新后的完整数据
|
||||
const updatedGroup = await client.hgetall(groupKey)
|
||||
|
||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`)
|
||||
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
|
||||
|
||||
return updatedGroup
|
||||
} catch (error) {
|
||||
@@ -143,7 +199,7 @@ class AccountGroupService {
|
||||
// 从分组集合中移除
|
||||
await client.srem(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 删除账户分组成功: ${group.name}`)
|
||||
logger.success(`删除账户分组成功: ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 删除账户分组失败:', error)
|
||||
throw error
|
||||
@@ -234,7 +290,10 @@ class AccountGroupService {
|
||||
// 添加到分组成员集合
|
||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
// 维护反向索引
|
||||
await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId)
|
||||
|
||||
logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 添加账户到分组失败:', error)
|
||||
throw error
|
||||
@@ -245,15 +304,26 @@ class AccountGroupService {
|
||||
* 从分组移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {string} platform - 平台(可选,如果不传则从分组获取)
|
||||
*/
|
||||
async removeAccountFromGroup(accountId, groupId) {
|
||||
async removeAccountFromGroup(accountId, groupId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从分组成员集合中移除
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`)
|
||||
// 维护反向索引
|
||||
let groupPlatform = platform
|
||||
if (!groupPlatform) {
|
||||
const group = await this.getGroup(groupId)
|
||||
groupPlatform = group?.platform
|
||||
}
|
||||
if (groupPlatform) {
|
||||
await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId)
|
||||
}
|
||||
|
||||
logger.success(`从分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从分组移除账户失败:', error)
|
||||
throw error
|
||||
@@ -399,7 +469,7 @@ class AccountGroupService {
|
||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||
}
|
||||
|
||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量设置账户分组失败:', error)
|
||||
throw error
|
||||
@@ -409,8 +479,9 @@ class AccountGroupService {
|
||||
/**
|
||||
* 从所有分组中移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台(可选,用于清理反向索引)
|
||||
*/
|
||||
async removeAccountFromAllGroups(accountId) {
|
||||
async removeAccountFromAllGroups(accountId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
@@ -419,12 +490,155 @@ class AccountGroupService {
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
}
|
||||
|
||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
||||
// 清理反向索引
|
||||
if (platform) {
|
||||
await client.del(`account_groups_reverse:${platform}:${accountId}`)
|
||||
} else {
|
||||
// 如果没有指定平台,清理所有可能的平台
|
||||
const platforms = ['claude', 'gemini', 'openai', 'droid']
|
||||
const pipeline = client.pipeline()
|
||||
for (const p of platforms) {
|
||||
pipeline.del(`account_groups_reverse:${p}:${accountId}`)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.success(`从所有分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个账户的分组信息(性能优化版本,使用反向索引)
|
||||
* @param {Array<string>} accountIds - 账户ID数组
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {Object} options - 选项
|
||||
* @param {boolean} options.skipMemberCount - 是否跳过 memberCount(默认 true)
|
||||
* @returns {Map<string, Array>} accountId -> 分组信息数组的映射
|
||||
*/
|
||||
async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) {
|
||||
const { skipMemberCount = true } = options
|
||||
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// Pipeline 批量获取所有账户的分组ID
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`)
|
||||
}
|
||||
const groupIdResults = await pipeline.exec()
|
||||
|
||||
// 收集所有需要的分组ID
|
||||
const uniqueGroupIds = new Set()
|
||||
const accountGroupIdsMap = new Map()
|
||||
let hasAnyGroups = false
|
||||
accountIds.forEach((accountId, i) => {
|
||||
const [err, groupIds] = groupIdResults[i]
|
||||
const ids = err ? [] : groupIds || []
|
||||
accountGroupIdsMap.set(accountId, ids)
|
||||
ids.forEach((id) => {
|
||||
uniqueGroupIds.add(id)
|
||||
hasAnyGroups = true
|
||||
})
|
||||
})
|
||||
|
||||
// 如果反向索引全空,回退到原方法(兼容未迁移的数据)
|
||||
if (!hasAnyGroups) {
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated !== 'true') {
|
||||
logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups')
|
||||
const result = new Map()
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
result.set(accountId, groups)
|
||||
} catch {
|
||||
result.set(accountId, [])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
|
||||
const emptyIndexAccountIds = []
|
||||
for (const accountId of accountIds) {
|
||||
const ids = accountGroupIdsMap.get(accountId) || []
|
||||
if (ids.length === 0) {
|
||||
emptyIndexAccountIds.push(accountId)
|
||||
}
|
||||
}
|
||||
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
|
||||
// 部分账户索引缺失,逐个查询并补建
|
||||
for (const accountId of emptyIndexAccountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
if (groups.length > 0) {
|
||||
const groupIds = groups.map((g) => g.id)
|
||||
accountGroupIdsMap.set(accountId, groupIds)
|
||||
groupIds.forEach((id) => uniqueGroupIds.add(id))
|
||||
// 异步补建反向索引
|
||||
client
|
||||
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,保持空数组
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取分组详情
|
||||
const groupDetailsMap = new Map()
|
||||
if (uniqueGroupIds.size > 0) {
|
||||
const detailPipeline = client.pipeline()
|
||||
const groupIdArray = Array.from(uniqueGroupIds)
|
||||
for (const groupId of groupIdArray) {
|
||||
detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!skipMemberCount) {
|
||||
detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
}
|
||||
}
|
||||
const detailResults = await detailPipeline.exec()
|
||||
|
||||
const step = skipMemberCount ? 1 : 2
|
||||
for (let i = 0; i < groupIdArray.length; i++) {
|
||||
const groupId = groupIdArray[i]
|
||||
const [err1, groupData] = detailResults[i * step]
|
||||
if (!err1 && groupData && Object.keys(groupData).length > 0) {
|
||||
const group = { ...groupData }
|
||||
if (!skipMemberCount) {
|
||||
const [err2, memberCount] = detailResults[i * step + 1]
|
||||
group.memberCount = err2 ? 0 : memberCount || 0
|
||||
}
|
||||
groupDetailsMap.set(groupId, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建最终结果
|
||||
const result = new Map()
|
||||
for (const [accountId, groupIds] of accountGroupIdsMap) {
|
||||
const groups = groupIds
|
||||
.map((gid) => groupDetailsMap.get(gid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
result.set(accountId, groups)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量获取账户分组失败:', error)
|
||||
return new Map(accountIds.map((id) => [id, []]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService()
|
||||
|
||||
@@ -1800,7 +1800,13 @@ function dumpToolsPayload({ vendor, model, tools, toolChoice }) {
|
||||
* 更新速率限制计数器
|
||||
* 跟踪 token 使用量和成本
|
||||
*/
|
||||
async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
|
||||
async function applyRateLimitTracking(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -1811,7 +1817,9 @@ async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, contex
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
keyId,
|
||||
'gemini'
|
||||
)
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -2134,13 +2142,15 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
|
||||
0,
|
||||
0,
|
||||
effectiveModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
await applyRateLimitTracking(
|
||||
req.rateLimitInfo,
|
||||
{ inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 },
|
||||
effectiveModel,
|
||||
'anthropic-messages'
|
||||
'anthropic-messages',
|
||||
req.apiKey?.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2672,7 +2682,8 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
|
||||
0,
|
||||
0,
|
||||
effectiveModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
await applyRateLimitTracking(
|
||||
req.rateLimitInfo,
|
||||
|
||||
@@ -82,7 +82,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
usageRecorded = true
|
||||
}
|
||||
@@ -103,7 +104,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +160,8 @@ async function sendAntigravityRequest({
|
||||
0,
|
||||
0,
|
||||
requestedModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
654
src/services/apiKeyIndexService.js
Normal file
654
src/services/apiKeyIndexService.js
Normal file
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* API Key 索引服务
|
||||
* 维护 Sorted Set 索引以支持高效分页查询
|
||||
*/
|
||||
|
||||
const { randomUUID } = require('crypto')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ApiKeyIndexService {
|
||||
constructor() {
|
||||
this.redis = null
|
||||
this.INDEX_VERSION_KEY = 'apikey:index:version'
|
||||
this.CURRENT_VERSION = 2 // 版本升级,触发重建
|
||||
this.isBuilding = false
|
||||
this.buildProgress = { current: 0, total: 0 }
|
||||
|
||||
// 索引键名
|
||||
this.INDEX_KEYS = {
|
||||
CREATED_AT: 'apikey:idx:createdAt',
|
||||
LAST_USED_AT: 'apikey:idx:lastUsedAt',
|
||||
NAME: 'apikey:idx:name',
|
||||
ACTIVE_SET: 'apikey:set:active',
|
||||
DELETED_SET: 'apikey:set:deleted',
|
||||
ALL_SET: 'apikey:idx:all',
|
||||
TAGS_ALL: 'apikey:tags:all' // 所有标签的集合
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务
|
||||
*/
|
||||
init(redis) {
|
||||
this.redis = redis
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时检查并重建索引
|
||||
*/
|
||||
async checkAndRebuild() {
|
||||
if (!this.redis) {
|
||||
logger.warn('⚠️ ApiKeyIndexService: Redis not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 始终检查并回填 hash_map(幂等操作,确保升级兼容)
|
||||
this.rebuildHashMap().catch((err) => {
|
||||
logger.error('❌ API Key hash_map 回填失败:', err)
|
||||
})
|
||||
|
||||
if (parseInt(version) >= this.CURRENT_VERSION) {
|
||||
logger.info('✅ API Key 索引已是最新版本')
|
||||
return
|
||||
}
|
||||
|
||||
// 后台异步重建,不阻塞启动
|
||||
this.rebuildIndexes().catch((err) => {
|
||||
logger.error('❌ API Key 索引重建失败:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ 检查 API Key 索引版本失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填 apikey:hash_map(升级兼容)
|
||||
* 扫描所有 API Key,确保 hash -> keyId 映射存在
|
||||
*/
|
||||
async rebuildHashMap() {
|
||||
if (!this.redis) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
|
||||
let rebuilt = 0
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 批量获取 API Key 数据
|
||||
for (const keyId of batch) {
|
||||
pipeline.hgetall(`apikey:${keyId}`)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 检查并回填缺失的映射
|
||||
const fillPipeline = client.pipeline()
|
||||
let needFill = false
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const keyData = results[j]?.[1]
|
||||
if (keyData && keyData.apiKey) {
|
||||
// keyData.apiKey 存储的是哈希值
|
||||
const exists = await client.hexists('apikey:hash_map', keyData.apiKey)
|
||||
if (!exists) {
|
||||
fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j])
|
||||
rebuilt++
|
||||
needFill = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needFill) {
|
||||
await fillPipeline.exec()
|
||||
}
|
||||
}
|
||||
|
||||
if (rebuilt > 0) {
|
||||
logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 回填 hash_map 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查索引是否可用
|
||||
*/
|
||||
async isIndexReady() {
|
||||
if (!this.redis || this.isBuilding) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
return parseInt(version) >= this.CURRENT_VERSION
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建所有索引
|
||||
*/
|
||||
async rebuildIndexes() {
|
||||
if (this.isBuilding) {
|
||||
logger.warn('⚠️ API Key 索引正在重建中,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
this.isBuilding = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
logger.info('🔨 开始重建 API Key 索引...')
|
||||
|
||||
// 0. 先删除版本号,让 _checkIndexReady 返回 false,查询回退到 SCAN
|
||||
await client.del(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 1. 清除旧索引
|
||||
const indexKeys = Object.values(this.INDEX_KEYS)
|
||||
for (const key of indexKeys) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清除标签索引(用 SCAN 避免阻塞)
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
// 2. 扫描所有 API Key
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
this.buildProgress = { current: 0, total: keyIds.length }
|
||||
|
||||
logger.info(`📊 发现 ${keyIds.length} 个 API Key,开始建立索引...`)
|
||||
|
||||
// 3. 批量处理(每批 500 个)
|
||||
const BATCH_SIZE = 500
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const apiKeys = await this.redis.batchGetApiKeys(batch)
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
if (!apiKey || !apiKey.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
// 创建时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
|
||||
// 最后使用时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
|
||||
// 名称索引(用于排序,存储格式:name\0keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
|
||||
// 全部集合
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
// 状态集合
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length)
|
||||
|
||||
// 每批次后短暂让出 CPU
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
// 4. 更新版本号
|
||||
await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION)
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`)
|
||||
} catch (error) {
|
||||
logger.error('❌ API Key 索引重建失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.isBuilding = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单个 API Key 到索引
|
||||
*/
|
||||
async addToIndex(apiKey) {
|
||||
if (!this.redis || !apiKey || !apiKey.id) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now()
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
} catch (error) {
|
||||
logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新索引(状态、名称、标签变化时调用)
|
||||
*/
|
||||
async updateIndex(keyId, updates, oldData = {}) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 更新名称索引
|
||||
if (updates.name !== undefined) {
|
||||
const oldName = (oldData.name || '').toLowerCase()
|
||||
const newName = (updates.name || '').toLowerCase()
|
||||
if (oldName !== newName) {
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间索引
|
||||
if (updates.lastUsedAt !== undefined) {
|
||||
const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
}
|
||||
|
||||
// 更新状态集合
|
||||
if (updates.isActive !== undefined || updates.isDeleted !== undefined) {
|
||||
const isActive = updates.isActive ?? oldData.isActive
|
||||
const isDeleted = updates.isDeleted ?? oldData.isDeleted
|
||||
|
||||
if (isDeleted === true || isDeleted === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive === true || isActive === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签索引
|
||||
const removedTags = []
|
||||
if (updates.tags !== undefined) {
|
||||
const oldTags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
const newTags = Array.isArray(updates.tags) ? updates.tags : []
|
||||
|
||||
// 移除旧标签
|
||||
for (const tag of oldTags) {
|
||||
if (tag && !newTags.includes(tag)) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
removedTags.push(tag)
|
||||
}
|
||||
}
|
||||
// 添加新标签
|
||||
for (const tag of newTags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查被移除的标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of removedTags) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从索引中移除 API Key
|
||||
*/
|
||||
async removeFromIndex(keyId, oldData = {}) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
const name = (oldData.name || '').toLowerCase()
|
||||
|
||||
pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`)
|
||||
pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
|
||||
// 移除标签索引
|
||||
const tags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用索引进行分页查询
|
||||
* 使用 ZINTERSTORE 优化,避免全量拉回内存
|
||||
*/
|
||||
async queryWithIndex(options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
isActive,
|
||||
tag,
|
||||
excludeDeleted = true
|
||||
} = options
|
||||
|
||||
const client = this.redis.getClientSafe()
|
||||
const tempSets = []
|
||||
|
||||
try {
|
||||
// 1. 构建筛选集合
|
||||
let filterSet = this.INDEX_KEYS.ALL_SET
|
||||
|
||||
// 状态筛选
|
||||
if (isActive === true || isActive === 'true') {
|
||||
// 筛选活跃的
|
||||
filterSet = this.INDEX_KEYS.ACTIVE_SET
|
||||
} else if (isActive === false || isActive === 'false') {
|
||||
// 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted)
|
||||
const tempKey = `apikey:tmp:inactive:${randomUUID()}`
|
||||
if (excludeDeleted) {
|
||||
await client.sdiffstore(
|
||||
tempKey,
|
||||
this.INDEX_KEYS.ALL_SET,
|
||||
this.INDEX_KEYS.ACTIVE_SET,
|
||||
this.INDEX_KEYS.DELETED_SET
|
||||
)
|
||||
} else {
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET)
|
||||
}
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
} else if (excludeDeleted) {
|
||||
// 排除已删除:ALL - DELETED
|
||||
const tempKey = `apikey:tmp:notdeleted:${randomUUID()}`
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (tag) {
|
||||
const tagSet = `apikey:tag:${tag}`
|
||||
const tempKey = `apikey:tmp:tag:${randomUUID()}`
|
||||
await client.sinterstore(tempKey, filterSet, tagSet)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 2. 获取筛选后的 keyId 集合
|
||||
const filterMembers = await client.smembers(filterSet)
|
||||
if (filterMembers.length === 0) {
|
||||
// 没有匹配的数据
|
||||
return {
|
||||
items: [],
|
||||
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||||
availableTags: await this._getAvailableTags(client)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 排序
|
||||
let sortedKeyIds
|
||||
|
||||
if (sortBy === 'name') {
|
||||
// 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of filterMembers) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'name')
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 组装并排序
|
||||
const items = filterMembers.map((keyId, i) => ({
|
||||
keyId,
|
||||
name: (results[i]?.[1] || '').toLowerCase()
|
||||
}))
|
||||
items.sort((a, b) =>
|
||||
sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||
)
|
||||
sortedKeyIds = items.map((item) => item.keyId)
|
||||
} else {
|
||||
// createdAt / lastUsedAt 索引成员是 keyId,可以用 ZINTERSTORE
|
||||
const sortIndex = this._getSortIndex(sortBy)
|
||||
const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}`
|
||||
tempSets.push(tempSortedKey)
|
||||
|
||||
// 将 filterSet 转换为 Sorted Set(所有分数为 0)
|
||||
const filterZsetKey = `apikey:tmp:filter:${randomUUID()}`
|
||||
tempSets.push(filterZsetKey)
|
||||
|
||||
const zaddArgs = []
|
||||
for (const member of filterMembers) {
|
||||
zaddArgs.push(0, member)
|
||||
}
|
||||
await client.zadd(filterZsetKey, ...zaddArgs)
|
||||
await client.expire(filterZsetKey, 60)
|
||||
|
||||
// ZINTERSTORE:取交集,使用排序索引的分数(WEIGHTS 0 1)
|
||||
await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1)
|
||||
await client.expire(tempSortedKey, 60)
|
||||
|
||||
// 获取排序后的 keyId
|
||||
sortedKeyIds =
|
||||
sortOrder === 'desc'
|
||||
? await client.zrevrange(tempSortedKey, 0, -1)
|
||||
: await client.zrange(tempSortedKey, 0, -1)
|
||||
}
|
||||
|
||||
// 4. 分页
|
||||
const total = sortedKeyIds.length
|
||||
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
|
||||
const validPage = Math.min(Math.max(1, page), totalPages)
|
||||
const start = (validPage - 1) * pageSize
|
||||
const pageKeyIds = sortedKeyIds.slice(start, start + pageSize)
|
||||
|
||||
// 5. 获取数据
|
||||
const items = await this.redis.batchGetApiKeys(pageKeyIds)
|
||||
|
||||
// 6. 获取所有标签
|
||||
const availableTags = await this._getAvailableTags(client)
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: validPage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages
|
||||
},
|
||||
availableTags
|
||||
}
|
||||
} finally {
|
||||
// 7. 清理临时集合
|
||||
for (const tempKey of tempSets) {
|
||||
client.del(tempKey).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排序索引键名
|
||||
*/
|
||||
_getSortIndex(sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'createdAt':
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
case 'lastUsedAt':
|
||||
return this.INDEX_KEYS.LAST_USED_AT
|
||||
case 'name':
|
||||
return this.INDEX_KEYS.NAME
|
||||
default:
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用标签(从 tags:all 集合)
|
||||
*/
|
||||
async _getAvailableTags(client) {
|
||||
try {
|
||||
const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL)
|
||||
return tags.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 lastUsedAt 索引(供 recordUsage 调用)
|
||||
*/
|
||||
async updateLastUsedAt(keyId, lastUsedAt) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now()
|
||||
await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引状态
|
||||
*/
|
||||
async getStatus() {
|
||||
if (!this.redis) {
|
||||
return { ready: false, building: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET)
|
||||
|
||||
return {
|
||||
ready: parseInt(version) >= this.CURRENT_VERSION,
|
||||
building: this.isBuilding,
|
||||
progress: this.buildProgress,
|
||||
version: parseInt(version) || 0,
|
||||
currentVersion: this.CURRENT_VERSION,
|
||||
totalIndexed: totalCount
|
||||
}
|
||||
} catch {
|
||||
return { ready: false, building: this.isBuilding }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例
|
||||
const apiKeyIndexService = new ApiKeyIndexService()
|
||||
|
||||
module.exports = apiKeyIndexService
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
|
||||
// 从Redis中删除账户数据
|
||||
await client.del(accountKey)
|
||||
|
||||
// 从索引中移除
|
||||
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'azure_openai:account:index',
|
||||
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^azure_openai:account:(.+)$/
|
||||
)
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 不返回敏感数据给前端
|
||||
delete accountData.apiKey
|
||||
|
||||
@@ -79,6 +79,7 @@ class BedrockAccountService {
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||
await redis.addToIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
|
||||
|
||||
@@ -208,12 +209,18 @@ class BedrockAccountService {
|
||||
// 📋 获取所有账户列表
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys('bedrock_account:*')
|
||||
const _client = redis.getClientSafe()
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'bedrock_account:index',
|
||||
'bedrock_account:*',
|
||||
/^bedrock_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `bedrock_account:${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData) {
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
@@ -375,6 +382,7 @@ class BedrockAccountService {
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(`bedrock_account:${accountId}`)
|
||||
await redis.removeFromIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ class BillingEventPublisher {
|
||||
// MKSTREAM: 如果 stream 不存在则创建
|
||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||
|
||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
||||
logger.success(`Created consumer group: ${groupName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('ccr-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -106,6 +96,7 @@ class CcrAccountService {
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('ccr_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -139,12 +130,17 @@ class CcrAccountService {
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'ccr_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^ccr_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
@@ -331,6 +327,9 @@ class CcrAccountService {
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('ccr_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
@@ -403,7 +402,7 @@ class CcrAccountService {
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
logger.success(`Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
@@ -488,7 +487,7 @@ class CcrAccountService {
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
logger.success(`Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
@@ -606,70 +605,12 @@ class CcrAccountService {
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
return this._encryptor.encrypt(data)
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
return this._encryptor.decrypt(encryptedData)
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
@@ -843,7 +784,7 @@ class CcrAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
@@ -915,7 +856,7 @@ class CcrAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
logger.success(`Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
|
||||
@@ -1570,7 +1570,7 @@ class ClaudeAccountService {
|
||||
'rateLimitAutoStopped'
|
||||
)
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -2242,7 +2242,7 @@ class ClaudeAccountService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
@@ -2310,11 +2310,11 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success('✅ Session window initialization completed:')
|
||||
logger.success(` 📊 Total accounts: ${accounts.length}`)
|
||||
logger.success(` ✅ Valid windows: ${validWindowCount}`)
|
||||
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` 📭 No windows: ${noWindowCount}`)
|
||||
logger.success('Session window initialization completed:')
|
||||
logger.success(` Total accounts: ${accounts.length}`)
|
||||
logger.success(` Valid windows: ${validWindowCount}`)
|
||||
logger.success(` Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` No windows: ${noWindowCount}`)
|
||||
|
||||
return {
|
||||
total: accounts.length,
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
getCachedConfig,
|
||||
setCachedConfig,
|
||||
deleteCachedConfig
|
||||
} = require('../utils/performanceOptimizer')
|
||||
|
||||
class ClaudeCodeHeadersService {
|
||||
constructor() {
|
||||
@@ -41,6 +46,9 @@ class ClaudeCodeHeadersService {
|
||||
'sec-fetch-mode',
|
||||
'accept-encoding'
|
||||
]
|
||||
|
||||
// Headers 缓存 TTL(60秒)
|
||||
this.headersCacheTtl = 60000
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +155,9 @@ class ClaudeCodeHeadersService {
|
||||
|
||||
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
|
||||
|
||||
// 更新内存缓存,避免延迟
|
||||
setCachedConfig(key, extractedHeaders, this.headersCacheTtl)
|
||||
|
||||
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
|
||||
@@ -154,18 +165,27 @@ class ClaudeCodeHeadersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号的 Claude Code headers
|
||||
* 获取账号的 Claude Code headers(带内存缓存)
|
||||
*/
|
||||
async getAccountHeaders(accountId) {
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
|
||||
// 检查内存缓存
|
||||
const cached = getCachedConfig(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
const data = await redis.getClient().get(key)
|
||||
const data = await redis.getClient().get(cacheKey)
|
||||
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data)
|
||||
logger.debug(
|
||||
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
|
||||
)
|
||||
// 缓存到内存
|
||||
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
|
||||
return parsed.headers
|
||||
}
|
||||
|
||||
@@ -183,8 +203,10 @@ class ClaudeCodeHeadersService {
|
||||
*/
|
||||
async clearAccountHeaders(accountId) {
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(key)
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(cacheKey)
|
||||
// 删除内存缓存
|
||||
deleteCachedConfig(cacheKey)
|
||||
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
|
||||
@@ -192,12 +214,12 @@ class ClaudeCodeHeadersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有账号的 headers 信息
|
||||
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys)
|
||||
*/
|
||||
async getAllAccountHeaders() {
|
||||
try {
|
||||
const pattern = 'claude_code_headers:*'
|
||||
const keys = await redis.getClient().keys(pattern)
|
||||
const keys = await redis.scanKeys(pattern)
|
||||
|
||||
const results = {}
|
||||
for (const key of keys) {
|
||||
|
||||
@@ -129,6 +129,7 @@ class ClaudeConsoleAccountService {
|
||||
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -167,11 +168,18 @@ class ClaudeConsoleAccountService {
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'claude_console_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^claude_console_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
if (!accountData.id) {
|
||||
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
||||
@@ -449,6 +457,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 从Redis删除
|
||||
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redis.removeFromIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -577,7 +586,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
||||
@@ -585,7 +594,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -858,7 +867,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
||||
@@ -866,7 +875,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -967,7 +976,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
||||
|
||||
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -1416,7 +1425,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset all daily usage:', error)
|
||||
}
|
||||
@@ -1489,7 +1498,7 @@ class ClaudeConsoleAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||
logger.success(`Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
|
||||
@@ -18,8 +18,8 @@ const DEFAULT_CONFIG = {
|
||||
// 用户消息队列配置
|
||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长TTL
|
||||
userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
|
||||
userMessageQueueLockTtlMs: 120000, // 锁TTL(毫秒)
|
||||
// 并发请求排队配置
|
||||
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
|
||||
concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const https = require('https')
|
||||
const zlib = require('zlib')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
@@ -17,6 +16,15 @@ const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getPricingData
|
||||
} = require('../utils/performanceOptimizer')
|
||||
|
||||
// structuredClone polyfill for Node < 17
|
||||
const safeClone =
|
||||
typeof structuredClone === 'function' ? structuredClone : (obj) => JSON.parse(JSON.stringify(obj))
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -946,8 +954,8 @@ class ClaudeRelayService {
|
||||
return body
|
||||
}
|
||||
|
||||
// 深拷贝请求体
|
||||
const processedBody = JSON.parse(JSON.stringify(body))
|
||||
// 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能
|
||||
const processedBody = safeClone(body)
|
||||
|
||||
// 验证并限制max_tokens参数
|
||||
this._validateAndLimitMaxTokens(processedBody)
|
||||
@@ -1077,15 +1085,15 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取模型定价配置文件
|
||||
// 使用缓存的定价数据
|
||||
const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json')
|
||||
const pricingData = getPricingData(pricingFilePath)
|
||||
|
||||
if (!fs.existsSync(pricingFilePath)) {
|
||||
if (!pricingData) {
|
||||
logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation')
|
||||
return
|
||||
}
|
||||
|
||||
const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
|
||||
const model = body.model || 'claude-sonnet-4-20250514'
|
||||
|
||||
// 查找对应模型的配置
|
||||
@@ -1251,20 +1259,20 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🌐 获取代理Agent(使用统一的代理工具)
|
||||
async _getProxyAgent(accountId) {
|
||||
async _getProxyAgent(accountId, account = null) {
|
||||
try {
|
||||
const accountData = await claudeAccountService.getAllAccounts()
|
||||
const account = accountData.find((acc) => acc.id === accountId)
|
||||
// 优先使用传入的 account 对象,避免重复查询
|
||||
const accountData = account || (await claudeAccountService.getAccount(accountId))
|
||||
|
||||
if (!account || !account.proxy) {
|
||||
if (!accountData || !accountData.proxy) {
|
||||
logger.debug('🌐 No proxy configured for Claude account')
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(accountData.proxy)
|
||||
if (proxyAgent) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}`
|
||||
)
|
||||
}
|
||||
return proxyAgent
|
||||
@@ -1361,7 +1369,7 @@ class ClaudeRelayService {
|
||||
headers['User-Agent'] = userAgent
|
||||
headers['Accept'] = acceptHeader
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
@@ -1456,19 +1464,22 @@ class ClaudeRelayService {
|
||||
path: requestPath + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForNonStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = Buffer.alloc(0)
|
||||
// 使用数组收集 chunks,避免 O(n²) 的 Buffer.concat
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData = Buffer.concat([responseData, chunk])
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// 一次性合并所有 chunks
|
||||
const responseData = Buffer.concat(chunks)
|
||||
let responseBody = ''
|
||||
|
||||
// 根据Content-Encoding处理响应数据
|
||||
@@ -1871,7 +1882,7 @@ class ClaudeRelayService {
|
||||
path: url.pathname + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
const redis = require('../models/redis')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// HMGET 需要的字段
|
||||
const USAGE_FIELDS = [
|
||||
'totalInputTokens',
|
||||
'inputTokens',
|
||||
'totalOutputTokens',
|
||||
'outputTokens',
|
||||
'totalCacheCreateTokens',
|
||||
'cacheCreateTokens',
|
||||
'totalCacheReadTokens',
|
||||
'cacheReadTokens'
|
||||
]
|
||||
|
||||
class CostInitService {
|
||||
/**
|
||||
* 带并发限制的并行执行
|
||||
*/
|
||||
async parallelLimit(items, fn, concurrency = 20) {
|
||||
let index = 0
|
||||
const results = []
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
try {
|
||||
results[currentIndex] = await fn(items[currentIndex], currentIndex)
|
||||
} catch (error) {
|
||||
results[currentIndex] = { error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker))
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 SCAN 获取匹配的 keys(带去重)
|
||||
*/
|
||||
async scanKeysWithDedup(client, pattern, count = 500) {
|
||||
const seen = new Set()
|
||||
const allKeys = []
|
||||
let cursor = '0'
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
|
||||
cursor = newCursor
|
||||
|
||||
for (const key of keys) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
allKeys.push(key)
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return allKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有API Key的费用数据
|
||||
* 扫描历史使用记录并计算费用
|
||||
@@ -12,25 +68,57 @@ class CostInitService {
|
||||
try {
|
||||
logger.info('💰 Starting cost initialization for all API Keys...')
|
||||
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
// 用 scanApiKeyIds 获取 ID,然后过滤已删除的
|
||||
const allKeyIds = await redis.scanApiKeyIds()
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 批量检查 isDeleted 状态,过滤已删除的 key
|
||||
const FILTER_BATCH = 100
|
||||
const apiKeyIds = []
|
||||
|
||||
for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) {
|
||||
const batch = allKeyIds.slice(i, i + FILTER_BATCH)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const keyId of batch) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'isDeleted')
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, isDeleted] = results[j]
|
||||
if (!err && isDeleted !== 'true') {
|
||||
apiKeyIds.push(batch[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`
|
||||
)
|
||||
|
||||
let processedCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
// 优化6: 并行处理 + 并发限制
|
||||
await this.parallelLimit(
|
||||
apiKeyIds,
|
||||
async (apiKeyId) => {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKey.id, client)
|
||||
await this.initializeApiKeyCosts(apiKeyId, client)
|
||||
processedCount++
|
||||
|
||||
if (processedCount % 10 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount} API Keys...`)
|
||||
if (processedCount % 100 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
|
||||
}
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
|
||||
}
|
||||
},
|
||||
20 // 并发数
|
||||
)
|
||||
|
||||
logger.success(
|
||||
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
|
||||
@@ -46,16 +134,55 @@ class CostInitService {
|
||||
* 初始化单个API Key的费用数据
|
||||
*/
|
||||
async initializeApiKeyCosts(apiKeyId, client) {
|
||||
// 获取所有时间的模型使用统计
|
||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`)
|
||||
// 优化4: 使用 SCAN 获取 keys(带去重)
|
||||
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`)
|
||||
|
||||
if (modelKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 优化5: 使用 Pipeline + HMGET 批量获取数据
|
||||
const BATCH_SIZE = 100
|
||||
const allData = []
|
||||
|
||||
for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) {
|
||||
const batch = modelKeys.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const key of batch) {
|
||||
pipeline.hmget(key, ...USAGE_FIELDS)
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, values] = results[j]
|
||||
if (err) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 将数组转换为对象
|
||||
const data = {}
|
||||
let hasData = false
|
||||
for (let k = 0; k < USAGE_FIELDS.length; k++) {
|
||||
if (values[k] !== null) {
|
||||
data[USAGE_FIELDS[k]] = values[k]
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasData) {
|
||||
allData.push({ key: batch[j], data })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期分组统计
|
||||
const dailyCosts = new Map() // date -> cost
|
||||
const monthlyCosts = new Map() // month -> cost
|
||||
const hourlyCosts = new Map() // hour -> cost
|
||||
const dailyCosts = new Map()
|
||||
const monthlyCosts = new Map()
|
||||
const hourlyCosts = new Map()
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
||||
for (const { key, data } of allData) {
|
||||
const match = key.match(
|
||||
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
|
||||
)
|
||||
@@ -65,13 +192,6 @@ class CostInitService {
|
||||
|
||||
const [, , period, model, dateStr] = match
|
||||
|
||||
// 获取使用数据
|
||||
const data = await client.hgetall(key)
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||
@@ -84,47 +204,34 @@ class CostInitService {
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
const cost = costResult.costs.total
|
||||
|
||||
// 根据period分组累加费用
|
||||
if (period === 'daily') {
|
||||
const currentCost = dailyCosts.get(dateStr) || 0
|
||||
dailyCosts.set(dateStr, currentCost + cost)
|
||||
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'monthly') {
|
||||
const currentCost = monthlyCosts.get(dateStr) || 0
|
||||
monthlyCosts.set(dateStr, currentCost + cost)
|
||||
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'hourly') {
|
||||
const currentCost = hourlyCosts.get(dateStr) || 0
|
||||
hourlyCosts.set(dateStr, currentCost + cost)
|
||||
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
|
||||
}
|
||||
}
|
||||
|
||||
// 将计算出的费用写入Redis
|
||||
const promises = []
|
||||
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 写入每日费用
|
||||
// 写入每日费用(只补缺失)
|
||||
for (const [date, cost] of dailyCosts) {
|
||||
const key = `usage:cost:daily:${apiKeyId}:${date}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 30) // 30天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
|
||||
}
|
||||
|
||||
// 写入每月费用
|
||||
// 写入每月费用(只补缺失)
|
||||
for (const [month, cost] of monthlyCosts) {
|
||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 90) // 90天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
|
||||
}
|
||||
|
||||
// 写入每小时费用
|
||||
// 写入每小时费用(只补缺失)
|
||||
for (const [hour, cost] of hourlyCosts) {
|
||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 7) // 7天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
|
||||
}
|
||||
|
||||
// 计算总费用
|
||||
@@ -133,37 +240,25 @@ class CostInitService {
|
||||
totalCost += cost
|
||||
}
|
||||
|
||||
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
|
||||
// 写入总费用(只补缺失)
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`
|
||||
// 先检查总费用是否已存在
|
||||
const existingTotal = await client.get(totalKey)
|
||||
|
||||
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
||||
// 仅在总费用不存在或为0时才初始化
|
||||
promises.push(client.set(totalKey, totalCost.toString()))
|
||||
pipeline.set(totalKey, totalCost.toString())
|
||||
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
||||
} else {
|
||||
// 如果总费用已存在,保持不变,避免覆盖累计值
|
||||
// 注意:这个逻辑防止因每日费用键过期(30天)导致的错误覆盖
|
||||
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
|
||||
const existing = parseFloat(existingTotal)
|
||||
const calculated = totalCost
|
||||
|
||||
if (calculated > existing * 1.1) {
|
||||
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
|
||||
if (totalCost > existing * 1.1) {
|
||||
logger.warn(
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
await pipeline.exec()
|
||||
|
||||
logger.debug(
|
||||
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
|
||||
@@ -172,41 +267,70 @@ class CostInitService {
|
||||
|
||||
/**
|
||||
* 检查是否需要初始化费用数据
|
||||
* 使用 SCAN 代替 KEYS,正确处理 cursor
|
||||
*/
|
||||
async needsInitialization() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 检查是否有任何费用数据
|
||||
const costKeys = await client.keys('usage:cost:*')
|
||||
// 正确循环 SCAN 检查是否有任何费用数据
|
||||
let cursor = '0'
|
||||
let hasCostData = false
|
||||
|
||||
// 如果没有费用数据,需要初始化
|
||||
if (costKeys.length === 0) {
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
hasCostData = true
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
if (!hasCostData) {
|
||||
logger.info('💰 No cost data found, initialization needed')
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否有使用数据但没有对应的费用数据
|
||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*')
|
||||
if (sampleKeys.length > 10) {
|
||||
// 抽样检查
|
||||
const sampleSize = Math.min(10, sampleKeys.length)
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
|
||||
// 抽样检查使用数据是否有对应的费用数据
|
||||
cursor = '0'
|
||||
let samplesChecked = 0
|
||||
const maxSamples = 10
|
||||
|
||||
do {
|
||||
const [newCursor, usageKeys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:*:model:daily:*:*',
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
for (const usageKey of usageKeys) {
|
||||
if (samplesChecked >= maxSamples) {
|
||||
break
|
||||
}
|
||||
|
||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, , date] = match
|
||||
const costKey = `usage:cost:daily:${keyId}:${date}`
|
||||
const hasCost = await client.exists(costKey)
|
||||
|
||||
if (!hasCost) {
|
||||
logger.info(
|
||||
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
|
||||
)
|
||||
return true
|
||||
}
|
||||
samplesChecked++
|
||||
}
|
||||
}
|
||||
|
||||
if (samplesChecked >= maxSamples) {
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
logger.info('💰 Cost data appears to be up to date')
|
||||
return false
|
||||
|
||||
@@ -103,7 +103,7 @@ class CostRankService {
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
logger.success('✅ CostRankService initialized')
|
||||
logger.success('CostRankService initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize CostRankService:', error)
|
||||
throw error
|
||||
@@ -391,10 +391,24 @@ class CostRankService {
|
||||
return {}
|
||||
}
|
||||
|
||||
const status = {}
|
||||
|
||||
// 使用 Pipeline 批量获取
|
||||
const pipeline = client.pipeline()
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
|
||||
pipeline.hgetall(RedisKeys.metaKey(timeRange))
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const status = {}
|
||||
VALID_TIME_RANGES.forEach((timeRange, i) => {
|
||||
const [err, meta] = results[i]
|
||||
if (err || !meta) {
|
||||
status[timeRange] = {
|
||||
lastUpdate: null,
|
||||
keyCount: 0,
|
||||
status: 'unknown',
|
||||
updateDuration: 0
|
||||
}
|
||||
} else {
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
@@ -402,6 +416,7 @@ class CostRankService {
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Droid 账户管理服务
|
||||
@@ -26,21 +25,14 @@ class DroidAccountService {
|
||||
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
||||
this.tokenValidHours = 8 // Token 有效期8小时
|
||||
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'droid-account-salt'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('droid-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -69,92 +61,19 @@ class DroidAccountService {
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
// 使用 commonHelper 的 isTruthy
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false
|
||||
}
|
||||
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
|
||||
}
|
||||
return Boolean(value)
|
||||
return isTruthy(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成加密密钥(缓存优化)
|
||||
*/
|
||||
_generateEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
logger.info('🔑 Droid encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感数据
|
||||
*/
|
||||
// 加密敏感数据
|
||||
_encryptSensitiveData(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
return this._encryptor.encrypt(text)
|
||||
}
|
||||
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密敏感数据(带缓存)
|
||||
*/
|
||||
// 解密敏感数据(带缓存)
|
||||
_decryptSensitiveData(encryptedText) {
|
||||
if (!encryptedText) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const parts = encryptedText.split(':')
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to decrypt Droid data:', error)
|
||||
return ''
|
||||
}
|
||||
return this._encryptor.decrypt(encryptedText)
|
||||
}
|
||||
|
||||
_parseApiKeyEntries(rawEntries) {
|
||||
@@ -683,7 +602,7 @@ class DroidAccountService {
|
||||
|
||||
lastRefreshAt = new Date().toISOString()
|
||||
status = 'active'
|
||||
logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
||||
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
||||
@@ -1368,7 +1287,7 @@ class DroidAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Droid account token refreshed successfully: ${accountId}`)
|
||||
logger.success(`Droid account token refreshed successfully: ${accountId}`)
|
||||
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
|
||||
@@ -90,7 +90,7 @@ class DroidRelayService {
|
||||
return normalizedBody
|
||||
}
|
||||
|
||||
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
|
||||
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -99,7 +99,9 @@ class DroidRelayService {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
keyId,
|
||||
'droid'
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -403,6 +405,7 @@ class DroidRelayService {
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(apiUrl)
|
||||
const keyId = apiKeyData?.id
|
||||
const bodyString = JSON.stringify(processedBody)
|
||||
const contentLength = Buffer.byteLength(bodyString)
|
||||
const requestHeaders = {
|
||||
@@ -606,10 +609,11 @@ class DroidRelayService {
|
||||
clientRequest?.rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
' [stream]'
|
||||
' [stream]',
|
||||
keyId
|
||||
)
|
||||
|
||||
logger.success(`✅ Droid stream completed - Account: ${account.name}`)
|
||||
logger.success(`Droid stream completed - Account: ${account.name}`)
|
||||
} else {
|
||||
logger.success(
|
||||
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
|
||||
@@ -1195,6 +1199,7 @@ class DroidRelayService {
|
||||
skipUsageRecord = false
|
||||
) {
|
||||
const { data } = response
|
||||
const keyId = apiKeyData?.id
|
||||
|
||||
// 从响应中提取 usage 数据
|
||||
const usage = data.usage || {}
|
||||
@@ -1225,7 +1230,8 @@ class DroidRelayService {
|
||||
clientRequest?.rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
endpointLabel
|
||||
endpointLabel,
|
||||
keyId
|
||||
)
|
||||
|
||||
logger.success(
|
||||
|
||||
@@ -2,103 +2,40 @@ const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
isTruthy,
|
||||
isAccountHealthy,
|
||||
sortAccountsByPriority,
|
||||
normalizeEndpointType
|
||||
} = require('../utils/commonHelper')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
this.STICKY_PREFIX = 'droid'
|
||||
}
|
||||
|
||||
_normalizeEndpointType(endpointType) {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
if (normalized === 'openai') {
|
||||
return 'openai'
|
||||
}
|
||||
if (normalized === 'comm') {
|
||||
return 'comm'
|
||||
}
|
||||
if (normalized === 'anthropic') {
|
||||
return 'anthropic'
|
||||
}
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
_isAccountActive(account) {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
const isActive = this._isTruthy(account.isActive)
|
||||
if (!isActive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||
return !unhealthyStatuses.has(status)
|
||||
}
|
||||
|
||||
_isAccountSchedulable(account) {
|
||||
return this._isTruthy(account?.schedulable ?? true)
|
||||
return isTruthy(account?.schedulable ?? true)
|
||||
}
|
||||
|
||||
_matchesEndpoint(account, endpointType) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
// comm 端点可以使用任何类型的账户
|
||||
if (normalizedEndpoint === 'comm') {
|
||||
return true
|
||||
}
|
||||
|
||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||
}
|
||||
|
||||
_sortCandidates(candidates) {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
}
|
||||
|
||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const apiKeyPart = apiKeyId || 'default'
|
||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||
}
|
||||
@@ -121,7 +58,7 @@ class DroidScheduler {
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,7 +82,7 @@ class DroidScheduler {
|
||||
}
|
||||
|
||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||
|
||||
let candidates = []
|
||||
@@ -175,7 +112,7 @@ class DroidScheduler {
|
||||
const filtered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
this._isAccountActive(account) &&
|
||||
isAccountHealthy(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
this._matchesEndpoint(account, normalizedEndpoint)
|
||||
)
|
||||
@@ -203,7 +140,7 @@ class DroidScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = this._sortCandidates(filtered)
|
||||
const sorted = sortAccountsByPriority(filtered)
|
||||
const selected = sorted[0]
|
||||
|
||||
if (!selected) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { OAuth2Client } = require('google-auth-library')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
@@ -15,9 +13,14 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||
|
||||
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
@@ -85,6 +88,10 @@ const keepAliveAgent = new https.Agent({
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('gemini-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
async function fetchAvailableModelsAntigravity(
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
@@ -196,91 +203,11 @@ async function countTokensAntigravity(client, contents, model, proxyConfig = nul
|
||||
return response
|
||||
}
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -605,6 +532,7 @@ async function createAccount(accountData) {
|
||||
// 保存到 Redis
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
|
||||
await redisClient.addToIndex('gemini_account:index', id)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -806,19 +734,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('gemini_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`gemini_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted Gemini account: ${accountId}`)
|
||||
@@ -827,12 +756,18 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const _client = redisClient.getClientSafe()
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'gemini_account:index',
|
||||
`${GEMINI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||
@@ -935,6 +870,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -994,6 +931,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
// 创建粘性会话映射
|
||||
if (sessionHash) {
|
||||
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
|
||||
await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1950,8 +1889,7 @@ module.exports = {
|
||||
setupUser,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
encryptor, // 暴露加密器以便测试和监控
|
||||
countTokens,
|
||||
countTokensAntigravity,
|
||||
generateContent,
|
||||
|
||||
@@ -85,7 +85,7 @@ class GeminiApiAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
|
||||
logger.success(`Created Gemini-API account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
const allAccountIds = await redis.getAllIdsByIndex(
|
||||
'gemini_api_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_api_account:(.+)$/
|
||||
)
|
||||
const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
for (let i = 0; i < allAccountIds.length; i++) {
|
||||
const accountId = allAccountIds[i]
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const accountData = dataList[i]
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null
|
||||
0, // cacheCreateTokens (Gemini 没有这个概念)
|
||||
0, // cacheReadTokens (Gemini 没有这个概念)
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Gemini usage:', error)
|
||||
@@ -317,7 +318,8 @@ async function sendGeminiRequest({
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Gemini usage:', error)
|
||||
|
||||
@@ -18,7 +18,7 @@ class ModelService {
|
||||
(sum, config) => sum + config.models.length,
|
||||
0
|
||||
)
|
||||
logger.success(`✅ Model service initialized with ${totalModels} models`)
|
||||
logger.success(`Model service initialized with ${totalModels} models`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const config = require('../../config/config')
|
||||
@@ -13,104 +12,23 @@ const {
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('openai-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
// OpenAI 账户键前缀
|
||||
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
||||
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
|
||||
if (text.length < 33 || text.charAt(32) !== ':') {
|
||||
logger.warn('Invalid encrypted text format, returning empty string', {
|
||||
textLength: text ? text.length : 0,
|
||||
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
|
||||
first50: text ? text.substring(0, 50) : 'N/A'
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`openai_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted OpenAI account: ${accountId}`)
|
||||
@@ -746,12 +666,18 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const _client = redisClient.getClientSafe()
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'openai:account:index',
|
||||
`${OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai:account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||
|
||||
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
// 反向索引:accountId -> sessionHash(用于删除账户时快速清理)
|
||||
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
selectedAccount.id
|
||||
)
|
||||
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1278,6 +1209,5 @@ module.exports = {
|
||||
updateCodexUsageSnapshot,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache // 暴露缓存对象以便测试和监控
|
||||
encryptor // 暴露加密器以便测试和监控
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -191,71 +194,48 @@ class OpenAIResponsesAccountService {
|
||||
// 获取所有账户
|
||||
async getAllAccounts(includeInactive = false) {
|
||||
const client = redis.getClientSafe()
|
||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
||||
|
||||
// 使用索引获取所有账户ID
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'openai_responses_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai_responses_account:(.+)$/
|
||||
)
|
||||
if (accountIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
// Pipeline 批量查询所有账户数据
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const accounts = []
|
||||
results.forEach(([err, accountData]) => {
|
||||
if (err || !accountData || !accountData.id) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (account) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || account.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: account.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
if (!includeInactive && accountData.isActive !== 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
account.schedulable = account.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
account.expiresAt = account.subscriptionExpiresAt || null
|
||||
account.platform = account.platform || 'openai-responses'
|
||||
|
||||
accounts.push(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
@@ -268,20 +248,14 @@ class OpenAIResponsesAccountService {
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
// 转换字段类型
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return accounts
|
||||
}
|
||||
@@ -644,6 +618,9 @@ class OpenAIResponsesAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const config = require('../../config/config')
|
||||
const crypto = require('crypto')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
|
||||
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
|
||||
const LAST_USED_AT_THROTTLE_MS = 60000
|
||||
|
||||
// 抽取缓存写入 token,兼容多种字段命名
|
||||
function extractCacheCreationTokens(usageData) {
|
||||
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
|
||||
this.defaultTimeout = config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 节流更新 lastUsedAt
|
||||
async _throttledUpdateLastUsedAt(accountId) {
|
||||
const now = Date.now()
|
||||
const lastUpdate = lastUsedAtThrottle.get(accountId)
|
||||
|
||||
if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) {
|
||||
return // 跳过更新
|
||||
}
|
||||
|
||||
lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS)
|
||||
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 处理请求转发
|
||||
async handleRequest(req, res, account, apiKeyData) {
|
||||
let abortController = null
|
||||
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
|
||||
return res.status(response.status).json(errorData)
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
// 更新最后使用时间(节流)
|
||||
await this._throttledUpdateLastUsedAt(account.id)
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
||||
@@ -539,7 +557,8 @@ class OpenAIResponsesRelayService {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
account.id
|
||||
account.id,
|
||||
'openai-responses'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -667,7 +686,8 @@ class OpenAIResponsesRelayService {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
account.id
|
||||
account.id,
|
||||
'openai-responses'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -105,7 +105,7 @@ class PricingService {
|
||||
// 设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
|
||||
logger.success('💰 Pricing service initialized successfully')
|
||||
logger.success('Pricing service initialized successfully')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize pricing service:', error)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ class PricingService {
|
||||
this.pricingData = jsonData
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
|
||||
// 设置或重新设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
@@ -762,7 +762,7 @@ class PricingService {
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`)
|
||||
logger.success(`Reloaded pricing data for ${modelCount} models from file`)
|
||||
|
||||
// 显示一些统计信息
|
||||
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
||||
|
||||
698
src/services/quotaCardService.js
Normal file
698
src/services/quotaCardService.js
Normal file
@@ -0,0 +1,698 @@
|
||||
/**
|
||||
* 额度卡/时间卡服务
|
||||
* 管理员生成卡,用户核销,管理员可撤销
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class QuotaCardService {
|
||||
constructor() {
|
||||
this.CARD_PREFIX = 'quota_card:'
|
||||
this.REDEMPTION_PREFIX = 'redemption:'
|
||||
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
|
||||
this.LIMITS_CONFIG_KEY = 'system:quota_card_limits'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取额度卡上限配置
|
||||
*/
|
||||
async getLimitsConfig() {
|
||||
try {
|
||||
const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY)
|
||||
if (configStr) {
|
||||
return JSON.parse(configStr)
|
||||
}
|
||||
// 没有 Redis 配置时,使用 config.js 默认值
|
||||
const config = require('../../config/config')
|
||||
return (
|
||||
config.quotaCardLimits || {
|
||||
enabled: true,
|
||||
maxExpiryDays: 90,
|
||||
maxTotalCostLimit: 1000
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get limits config:', error)
|
||||
return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存额度卡上限配置
|
||||
*/
|
||||
async saveLimitsConfig(config) {
|
||||
try {
|
||||
const parsedDays = parseInt(config.maxExpiryDays)
|
||||
const parsedCost = parseFloat(config.maxTotalCostLimit)
|
||||
const newConfig = {
|
||||
enabled: config.enabled !== false,
|
||||
maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays,
|
||||
maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig))
|
||||
logger.info('✅ Quota card limits config saved')
|
||||
return newConfig
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to save limits config:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX)
|
||||
*/
|
||||
_generateCardCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
|
||||
let code = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
code += chars.charAt(crypto.randomInt(chars.length))
|
||||
}
|
||||
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建额度卡/时间卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
|
||||
* @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填)
|
||||
* @param {number} options.timeAmount - 时间数量(time/combo 类型必填)
|
||||
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
|
||||
* @param {string} options.expiresAt - 卡本身的有效期(可选)
|
||||
* @param {string} options.note - 备注
|
||||
* @param {string} options.createdBy - 创建者 ID
|
||||
* @returns {Object} 创建的卡信息
|
||||
*/
|
||||
async createCard(options = {}) {
|
||||
try {
|
||||
const {
|
||||
type = 'quota',
|
||||
quotaAmount = 0,
|
||||
timeAmount = 0,
|
||||
timeUnit = 'days',
|
||||
expiresAt = null,
|
||||
note = '',
|
||||
createdBy = 'admin'
|
||||
} = options
|
||||
|
||||
// 验证
|
||||
if (!['quota', 'time', 'combo'].includes(type)) {
|
||||
throw new Error('Invalid card type')
|
||||
}
|
||||
|
||||
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
|
||||
throw new Error('quotaAmount is required for quota/combo cards')
|
||||
}
|
||||
|
||||
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
|
||||
throw new Error('timeAmount is required for time/combo cards')
|
||||
}
|
||||
|
||||
const cardId = uuidv4()
|
||||
const cardCode = this._generateCardCode()
|
||||
|
||||
const cardData = {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: String(quotaAmount || 0),
|
||||
timeAmount: String(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
status: 'unused', // unused | redeemed | revoked | expired
|
||||
createdBy,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: expiresAt || '',
|
||||
note: note || '',
|
||||
// 核销信息
|
||||
redeemedBy: '',
|
||||
redeemedByUsername: '',
|
||||
redeemedApiKeyId: '',
|
||||
redeemedApiKeyName: '',
|
||||
redeemedAt: '',
|
||||
// 撤销信息
|
||||
revokedAt: '',
|
||||
revokedBy: '',
|
||||
revokeReason: ''
|
||||
}
|
||||
|
||||
// 保存卡数据
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
|
||||
|
||||
// 建立卡号到 ID 的映射(用于快速查找)
|
||||
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
|
||||
|
||||
// 添加到卡列表索引
|
||||
await redis.client.sadd('quota_cards:all', cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
|
||||
|
||||
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
|
||||
|
||||
return {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit,
|
||||
status: 'unused',
|
||||
createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {number} count - 创建数量
|
||||
* @returns {Array} 创建的卡列表
|
||||
*/
|
||||
async createCardsBatch(options = {}, count = 1) {
|
||||
const cards = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = await this.createCard(options)
|
||||
cards.push(card)
|
||||
}
|
||||
logger.success(`🎫 Batch created ${count} cards`)
|
||||
return cards
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过卡号获取卡信息
|
||||
*/
|
||||
async getCardByCode(code) {
|
||||
try {
|
||||
const cardId = await redis.client.get(`quota_card_code:${code}`)
|
||||
if (!cardId) {
|
||||
return null
|
||||
}
|
||||
return await this.getCardById(cardId)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card by code:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取卡信息
|
||||
*/
|
||||
async getCardById(cardId) {
|
||||
try {
|
||||
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
|
||||
if (!cardData || Object.keys(cardData).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: cardData.id,
|
||||
code: cardData.code,
|
||||
type: cardData.type,
|
||||
quotaAmount: parseFloat(cardData.quotaAmount || 0),
|
||||
timeAmount: parseInt(cardData.timeAmount || 0),
|
||||
timeUnit: cardData.timeUnit,
|
||||
status: cardData.status,
|
||||
createdBy: cardData.createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note: cardData.note,
|
||||
redeemedBy: cardData.redeemedBy,
|
||||
redeemedByUsername: cardData.redeemedByUsername,
|
||||
redeemedApiKeyId: cardData.redeemedApiKeyId,
|
||||
redeemedApiKeyName: cardData.redeemedApiKeyName,
|
||||
redeemedAt: cardData.redeemedAt,
|
||||
revokedAt: cardData.revokedAt,
|
||||
revokedBy: cardData.revokedBy,
|
||||
revokeReason: cardData.revokeReason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有卡列表
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.status - 按状态筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getAllCards(options = {}) {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = options
|
||||
|
||||
let cardIds
|
||||
if (status) {
|
||||
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
|
||||
} else {
|
||||
cardIds = await redis.client.smembers('quota_cards:all')
|
||||
}
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
const cards = []
|
||||
for (const cardId of cardIds) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (card) {
|
||||
cards.push(card)
|
||||
}
|
||||
}
|
||||
|
||||
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
// 分页
|
||||
const total = cards.length
|
||||
const paginatedCards = cards.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
cards: paginatedCards,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get all cards:', error)
|
||||
return { cards: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销卡
|
||||
* @param {string} code - 卡号
|
||||
* @param {string} apiKeyId - 目标 API Key ID
|
||||
* @param {string} userId - 核销用户 ID
|
||||
* @param {string} username - 核销用户名
|
||||
* @returns {Object} 核销结果
|
||||
*/
|
||||
async redeemCard(code, apiKeyId, userId, username = '') {
|
||||
try {
|
||||
// 获取卡信息
|
||||
const card = await this.getCardByCode(code)
|
||||
if (!card) {
|
||||
throw new Error('卡号不存在')
|
||||
}
|
||||
|
||||
// 检查卡状态
|
||||
if (card.status !== 'unused') {
|
||||
const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' }
|
||||
throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`)
|
||||
}
|
||||
|
||||
// 检查卡是否过期
|
||||
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
|
||||
// 更新卡状态为过期
|
||||
await this._updateCardStatus(card.id, 'expired')
|
||||
throw new Error('卡片已过期')
|
||||
}
|
||||
|
||||
// 获取 API Key 信息
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API Key 不存在')
|
||||
}
|
||||
|
||||
// 获取上限配置
|
||||
const limits = await this.getLimitsConfig()
|
||||
|
||||
// 执行核销
|
||||
const redemptionId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 记录核销前状态
|
||||
const beforeLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||
const beforeExpiry = keyData.expiresAt || ''
|
||||
|
||||
// 应用卡效果
|
||||
let afterLimit = beforeLimit
|
||||
let afterExpiry = beforeExpiry
|
||||
let quotaAdded = 0
|
||||
let timeAdded = 0
|
||||
let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days)
|
||||
const warnings = [] // 截断警告信息
|
||||
|
||||
if (card.type === 'quota' || card.type === 'combo') {
|
||||
let amountToAdd = card.quotaAmount
|
||||
|
||||
// 上限保护:检查是否超过最大额度限制
|
||||
if (limits.enabled && limits.maxTotalCostLimit > 0) {
|
||||
const maxAllowed = limits.maxTotalCostLimit - beforeLimit
|
||||
if (amountToAdd > maxAllowed) {
|
||||
amountToAdd = Math.max(0, maxAllowed)
|
||||
warnings.push(
|
||||
`额度已达上限,本次仅增加 ${amountToAdd} CC(原卡面 ${card.quotaAmount} CC)`
|
||||
)
|
||||
logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (amountToAdd > 0) {
|
||||
const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd)
|
||||
afterLimit = result.newTotalCostLimit
|
||||
quotaAdded = amountToAdd
|
||||
}
|
||||
}
|
||||
|
||||
if (card.type === 'time' || card.type === 'combo') {
|
||||
// 计算新的过期时间
|
||||
let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date()
|
||||
if (baseDate < new Date()) {
|
||||
baseDate = new Date()
|
||||
}
|
||||
|
||||
let newExpiry = new Date(baseDate)
|
||||
switch (card.timeUnit) {
|
||||
case 'hours':
|
||||
newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000)
|
||||
break
|
||||
case 'days':
|
||||
newExpiry.setDate(newExpiry.getDate() + card.timeAmount)
|
||||
break
|
||||
case 'months':
|
||||
newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount)
|
||||
break
|
||||
}
|
||||
|
||||
// 上限保护:检查是否超过最大有效期
|
||||
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||
const maxExpiry = new Date()
|
||||
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||
if (newExpiry > maxExpiry) {
|
||||
newExpiry = maxExpiry
|
||||
warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`)
|
||||
logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
|
||||
// 如果有上限保护,使用截断后的时间
|
||||
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||
const maxExpiry = new Date()
|
||||
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||
if (new Date(result.newExpiresAt) > maxExpiry) {
|
||||
await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString())
|
||||
afterExpiry = maxExpiry.toISOString()
|
||||
// 计算实际增加的天数,截断时统一用天
|
||||
const actualDays = Math.max(
|
||||
0,
|
||||
Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
timeAdded = actualDays
|
||||
actualTimeUnit = 'days'
|
||||
} else {
|
||||
afterExpiry = result.newExpiresAt
|
||||
timeAdded = card.timeAmount
|
||||
}
|
||||
} else {
|
||||
afterExpiry = result.newExpiresAt
|
||||
timeAdded = card.timeAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
|
||||
status: 'redeemed',
|
||||
redeemedBy: userId,
|
||||
redeemedByUsername: username,
|
||||
redeemedApiKeyId: apiKeyId,
|
||||
redeemedApiKeyName: keyData.name || '',
|
||||
redeemedAt: now
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:unused`, card.id)
|
||||
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
|
||||
|
||||
// 创建核销记录
|
||||
const redemptionData = {
|
||||
id: redemptionId,
|
||||
cardId: card.id,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
userId,
|
||||
username,
|
||||
apiKeyId,
|
||||
apiKeyName: keyData.name || '',
|
||||
quotaAdded: String(quotaAdded),
|
||||
timeAdded: String(timeAdded),
|
||||
timeUnit: actualTimeUnit,
|
||||
beforeLimit: String(beforeLimit),
|
||||
afterLimit: String(afterLimit),
|
||||
beforeExpiry,
|
||||
afterExpiry,
|
||||
timestamp: now,
|
||||
status: 'active' // active | revoked
|
||||
}
|
||||
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
|
||||
|
||||
// 添加到核销记录索引
|
||||
await redis.client.sadd('redemptions:all', redemptionId)
|
||||
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
|
||||
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
|
||||
|
||||
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
warnings,
|
||||
redemptionId,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
quotaAdded,
|
||||
timeAdded,
|
||||
timeUnit: actualTimeUnit,
|
||||
beforeLimit,
|
||||
afterLimit,
|
||||
beforeExpiry,
|
||||
afterExpiry
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to redeem card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销核销
|
||||
* @param {string} redemptionId - 核销记录 ID
|
||||
* @param {string} revokedBy - 撤销者 ID
|
||||
* @param {string} reason - 撤销原因
|
||||
* @returns {Object} 撤销结果
|
||||
*/
|
||||
async revokeRedemption(redemptionId, revokedBy, reason = '') {
|
||||
try {
|
||||
// 获取核销记录
|
||||
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
|
||||
if (!redemptionData || Object.keys(redemptionData).length === 0) {
|
||||
throw new Error('Redemption record not found')
|
||||
}
|
||||
|
||||
if (redemptionData.status !== 'active') {
|
||||
throw new Error('Redemption is already revoked')
|
||||
}
|
||||
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 撤销效果
|
||||
let actualDeducted = 0
|
||||
if (parseFloat(redemptionData.quotaAdded) > 0) {
|
||||
const result = await apiKeyService.deductTotalCostLimit(
|
||||
redemptionData.apiKeyId,
|
||||
parseFloat(redemptionData.quotaAdded)
|
||||
)
|
||||
;({ actualDeducted } = result)
|
||||
}
|
||||
|
||||
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
|
||||
// 如果需要回退时间,可以在这里添加逻辑
|
||||
|
||||
// 更新核销记录状态
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason,
|
||||
actualDeducted: String(actualDeducted)
|
||||
})
|
||||
|
||||
// 更新卡状态
|
||||
const { cardId } = redemptionData
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
|
||||
|
||||
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redemptionId,
|
||||
cardCode: redemptionData.cardCode,
|
||||
actualDeducted,
|
||||
reason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取核销记录
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.userId - 按用户筛选
|
||||
* @param {string} options.apiKeyId - 按 API Key 筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getRedemptions(options = {}) {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = options
|
||||
|
||||
let redemptionIds
|
||||
if (userId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
|
||||
} else if (apiKeyId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
|
||||
} else {
|
||||
redemptionIds = await redis.client.smembers('redemptions:all')
|
||||
}
|
||||
|
||||
const redemptions = []
|
||||
for (const id of redemptionIds) {
|
||||
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
redemptions.push({
|
||||
id: data.id,
|
||||
cardId: data.cardId,
|
||||
cardCode: data.cardCode,
|
||||
cardType: data.cardType,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
apiKeyId: data.apiKeyId,
|
||||
apiKeyName: data.apiKeyName,
|
||||
quotaAdded: parseFloat(data.quotaAdded || 0),
|
||||
timeAdded: parseInt(data.timeAdded || 0),
|
||||
timeUnit: data.timeUnit,
|
||||
beforeLimit: parseFloat(data.beforeLimit || 0),
|
||||
afterLimit: parseFloat(data.afterLimit || 0),
|
||||
beforeExpiry: data.beforeExpiry,
|
||||
afterExpiry: data.afterExpiry,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status,
|
||||
revokedAt: data.revokedAt,
|
||||
revokedBy: data.revokedBy,
|
||||
revokeReason: data.revokeReason,
|
||||
actualDeducted: parseFloat(data.actualDeducted || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 排序(按时间倒序)
|
||||
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
|
||||
// 分页
|
||||
const total = redemptions.length
|
||||
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
redemptions: paginatedRedemptions,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
return { redemptions: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未使用的卡
|
||||
*/
|
||||
async deleteCard(cardId) {
|
||||
try {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
throw new Error('Card not found')
|
||||
}
|
||||
|
||||
if (card.status !== 'unused') {
|
||||
throw new Error('Only unused cards can be deleted')
|
||||
}
|
||||
|
||||
// 删除卡数据
|
||||
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
|
||||
await redis.client.del(`quota_card_code:${card.code}`)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.client.srem('quota_cards:all', cardId)
|
||||
await redis.client.srem(`quota_cards:status:unused`, cardId)
|
||||
|
||||
logger.success(`🗑️ Deleted card ${card.code}`)
|
||||
|
||||
return { success: true, cardCode: card.code }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新卡状态(内部方法)
|
||||
*/
|
||||
async _updateCardStatus(cardId, newStatus) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldStatus = card.status
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡统计信息
|
||||
*/
|
||||
async getCardStats() {
|
||||
try {
|
||||
const [unused, redeemed, revoked, expired] = await Promise.all([
|
||||
redis.client.scard('quota_cards:status:unused'),
|
||||
redis.client.scard('quota_cards:status:redeemed'),
|
||||
redis.client.scard('quota_cards:status:revoked'),
|
||||
redis.client.scard('quota_cards:status:expired')
|
||||
])
|
||||
|
||||
return {
|
||||
total: unused + redeemed + revoked + expired,
|
||||
unused,
|
||||
redeemed,
|
||||
revoked,
|
||||
expired
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card stats:', error)
|
||||
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new QuotaCardService()
|
||||
259
src/services/serviceRatesService.js
Normal file
259
src/services/serviceRatesService.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 服务倍率配置服务
|
||||
* 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0)
|
||||
* 用于聚合 Key 的虚拟额度计算
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ServiceRatesService {
|
||||
constructor() {
|
||||
this.CONFIG_KEY = 'system:service_rates'
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
this.CACHE_TTL = 60 * 1000 // 1分钟缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认倍率配置
|
||||
*/
|
||||
getDefaultRates() {
|
||||
return {
|
||||
baseService: 'claude',
|
||||
rates: {
|
||||
claude: 1.0, // 基准:1 USD = 1 CC额度
|
||||
codex: 1.0,
|
||||
gemini: 1.0,
|
||||
droid: 1.0,
|
||||
bedrock: 1.0,
|
||||
azure: 1.0,
|
||||
ccr: 1.0
|
||||
},
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取倍率配置(带缓存)
|
||||
*/
|
||||
async getRates() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (this.cachedRates && Date.now() < this.cacheExpiry) {
|
||||
return this.cachedRates
|
||||
}
|
||||
|
||||
const configStr = await redis.client.get(this.CONFIG_KEY)
|
||||
if (!configStr) {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
this.cachedRates = defaultRates
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return defaultRates
|
||||
}
|
||||
|
||||
const storedConfig = JSON.parse(configStr)
|
||||
// 合并默认值,确保新增服务有默认倍率
|
||||
const defaultRates = this.getDefaultRates()
|
||||
storedConfig.rates = {
|
||||
...defaultRates.rates,
|
||||
...storedConfig.rates
|
||||
}
|
||||
|
||||
this.cachedRates = storedConfig
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return storedConfig
|
||||
} catch (error) {
|
||||
logger.error('获取服务倍率配置失败:', error)
|
||||
return this.getDefaultRates()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存倍率配置
|
||||
*/
|
||||
async saveRates(config, updatedBy = 'admin') {
|
||||
try {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
|
||||
// 验证配置
|
||||
this.validateRates(config)
|
||||
|
||||
const newConfig = {
|
||||
baseService: config.baseService || defaultRates.baseService,
|
||||
rates: {
|
||||
...defaultRates.rates,
|
||||
...config.rates
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig))
|
||||
|
||||
// 清除缓存
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
|
||||
logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`)
|
||||
return newConfig
|
||||
} catch (error) {
|
||||
logger.error('保存服务倍率配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证倍率配置
|
||||
*/
|
||||
validateRates(config) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('无效的配置格式')
|
||||
}
|
||||
|
||||
if (config.rates) {
|
||||
for (const [service, rate] of Object.entries(config.rates)) {
|
||||
if (typeof rate !== 'number' || rate <= 0) {
|
||||
throw new Error(`服务 ${service} 的倍率必须是正数`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务的倍率
|
||||
*/
|
||||
async getServiceRate(service) {
|
||||
const config = await this.getRates()
|
||||
return config.rates[service] || 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算消费的 CC 额度
|
||||
* @param {number} costUSD - 真实成本(USD)
|
||||
* @param {string} service - 服务类型
|
||||
* @returns {number} CC 额度消耗
|
||||
*/
|
||||
async calculateQuotaConsumption(costUSD, service) {
|
||||
const rate = await this.getServiceRate(service)
|
||||
return costUSD * rate
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型名称获取服务类型
|
||||
*/
|
||||
getServiceFromModel(model) {
|
||||
if (!model) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
const modelLower = model.toLowerCase()
|
||||
|
||||
// Claude 系列
|
||||
if (
|
||||
modelLower.includes('claude') ||
|
||||
modelLower.includes('anthropic') ||
|
||||
modelLower.includes('opus') ||
|
||||
modelLower.includes('sonnet') ||
|
||||
modelLower.includes('haiku')
|
||||
) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// OpenAI / Codex 系列
|
||||
if (
|
||||
modelLower.includes('gpt') ||
|
||||
modelLower.includes('o1') ||
|
||||
modelLower.includes('o3') ||
|
||||
modelLower.includes('o4') ||
|
||||
modelLower.includes('codex') ||
|
||||
modelLower.includes('davinci') ||
|
||||
modelLower.includes('curie') ||
|
||||
modelLower.includes('babbage') ||
|
||||
modelLower.includes('ada')
|
||||
) {
|
||||
return 'codex'
|
||||
}
|
||||
|
||||
// Gemini 系列
|
||||
if (
|
||||
modelLower.includes('gemini') ||
|
||||
modelLower.includes('palm') ||
|
||||
modelLower.includes('bard')
|
||||
) {
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
// Droid 系列
|
||||
if (modelLower.includes('droid') || modelLower.includes('factory')) {
|
||||
return 'droid'
|
||||
}
|
||||
|
||||
// Bedrock 系列(通常带有 aws 或特定前缀)
|
||||
if (
|
||||
modelLower.includes('bedrock') ||
|
||||
modelLower.includes('amazon') ||
|
||||
modelLower.includes('titan')
|
||||
) {
|
||||
return 'bedrock'
|
||||
}
|
||||
|
||||
// Azure 系列
|
||||
if (modelLower.includes('azure')) {
|
||||
return 'azure'
|
||||
}
|
||||
|
||||
// 默认返回 claude
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户类型获取服务类型(优先级高于模型推断)
|
||||
*/
|
||||
getServiceFromAccountType(accountType) {
|
||||
if (!accountType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapping = {
|
||||
claude: 'claude',
|
||||
'claude-official': 'claude',
|
||||
'claude-console': 'claude',
|
||||
ccr: 'ccr',
|
||||
bedrock: 'bedrock',
|
||||
gemini: 'gemini',
|
||||
'openai-responses': 'codex',
|
||||
openai: 'codex',
|
||||
azure: 'azure',
|
||||
'azure-openai': 'azure',
|
||||
droid: 'droid'
|
||||
}
|
||||
|
||||
return mapping[accountType] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务类型(优先 accountType,后备 model)
|
||||
*/
|
||||
getService(accountType, model) {
|
||||
return this.getServiceFromAccountType(accountType) || this.getServiceFromModel(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的服务列表
|
||||
*/
|
||||
async getAvailableServices() {
|
||||
const config = await this.getRates()
|
||||
return Object.keys(config.rates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ServiceRatesService()
|
||||
@@ -6,6 +6,7 @@ const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -38,16 +39,6 @@ class UnifiedClaudeScheduler {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否支持请求的模型
|
||||
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
|
||||
if (!requestedModel) {
|
||||
@@ -286,7 +277,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
@@ -319,7 +310,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -354,7 +345,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -436,7 +427,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -496,7 +487,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
|
||||
)
|
||||
@@ -530,7 +521,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 主动触发一次额度检查
|
||||
try {
|
||||
@@ -579,7 +570,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
||||
@@ -609,7 +600,7 @@ class UnifiedClaudeScheduler {
|
||||
account.status !== 'blocked' &&
|
||||
account.status !== 'temp_error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -691,7 +682,7 @@ class UnifiedClaudeScheduler {
|
||||
currentAccount.isActive === true &&
|
||||
currentAccount.status === 'active' &&
|
||||
currentAccount.accountType === 'shared' &&
|
||||
this._isSchedulable(currentAccount.schedulable)
|
||||
isSchedulable(currentAccount.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -826,7 +817,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -870,7 +861,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
@@ -949,21 +940,6 @@ class UnifiedClaudeScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType, requestedModel = null) {
|
||||
try {
|
||||
@@ -978,7 +954,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1029,7 +1005,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1093,7 +1069,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(accountResult.data.schedulable)) {
|
||||
if (!isSchedulable(accountResult.data.schedulable)) {
|
||||
logger.info(`🚫 Bedrock account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1113,7 +1089,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1544,7 +1520,7 @@ class UnifiedClaudeScheduler {
|
||||
? account.status === 'active'
|
||||
: account.status === 'active'
|
||||
|
||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||
if (isActive && status && isSchedulable(account.schedulable)) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
|
||||
continue
|
||||
@@ -1594,7 +1570,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -1664,7 +1640,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 3. 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableCcrAccounts)
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 4. 建立会话映射
|
||||
@@ -1710,7 +1686,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const geminiApiAccountService = require('./geminiApiAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
@@ -44,9 +45,9 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
|
||||
_isActive(isActive) {
|
||||
_isActive(activeValue) {
|
||||
// 兼容布尔值 true 和字符串 'true'
|
||||
return isActive === true || isActive === 'true'
|
||||
return activeValue === true || activeValue === 'true'
|
||||
}
|
||||
|
||||
// 🎯 统一调度Gemini账号
|
||||
@@ -66,11 +67,7 @@ class UnifiedGeminiScheduler {
|
||||
if (apiKeyData.geminiAccountId.startsWith('api:')) {
|
||||
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
|
||||
logger.info(
|
||||
`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -183,7 +180,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -243,11 +240,7 @@ class UnifiedGeminiScheduler {
|
||||
if (apiKeyData.geminiAccountId.startsWith('api:')) {
|
||||
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
|
||||
const isRateLimited = await this.isAccountRateLimited(accountId)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持
|
||||
@@ -349,10 +342,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiAccounts = await geminiAccountService.getAllAccounts()
|
||||
for (const account of geminiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
@@ -405,10 +398,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiApiAccounts = await geminiApiAccountService.getAllAccounts()
|
||||
for (const account of geminiApiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
@@ -445,42 +438,27 @@ class UnifiedGeminiScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'gemini') {
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await this.isAccountRateLimited(accountId))
|
||||
} else if (accountType === 'gemini-api') {
|
||||
const account = await geminiApiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -738,9 +716,9 @@ class UnifiedGeminiScheduler {
|
||||
|
||||
// 检查账户是否可用
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 对于 Gemini OAuth 账户,检查 token 是否过期
|
||||
if (accountType === 'gemini') {
|
||||
@@ -787,7 +765,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -3,42 +3,13 @@ const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
class UnifiedOpenAIScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
const aPriority = Number.parseInt(a.priority, 10)
|
||||
const bPriority = Number.parseInt(b.priority, 10)
|
||||
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
|
||||
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
|
||||
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (normalizedAPriority !== normalizedBPriority) {
|
||||
return normalizedAPriority - normalizedBPriority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式)
|
||||
_isRateLimited(rateLimitStatus) {
|
||||
if (!rateLimitStatus) {
|
||||
@@ -85,9 +56,9 @@ class UnifiedOpenAIScheduler {
|
||||
let rateLimitChecked = false
|
||||
let stillLimited = false
|
||||
|
||||
let isSchedulable = this._isSchedulable(account.schedulable)
|
||||
const accountSchedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!isSchedulable) {
|
||||
if (!accountSchedulable) {
|
||||
if (!hasRateLimitFlag) {
|
||||
return { canUse: false, reason: 'not_schedulable' }
|
||||
}
|
||||
@@ -104,7 +75,6 @@ class UnifiedOpenAIScheduler {
|
||||
} else {
|
||||
account.schedulable = 'true'
|
||||
}
|
||||
isSchedulable = true
|
||||
logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`)
|
||||
}
|
||||
|
||||
@@ -224,7 +194,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
@@ -336,7 +306,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -451,11 +421,12 @@ class UnifiedOpenAIScheduler {
|
||||
if (
|
||||
(account.isActive === true || account.isActive === 'true') &&
|
||||
account.status !== 'error' &&
|
||||
account.status !== 'rateLimited' &&
|
||||
(account.accountType === 'shared' || !account.accountType)
|
||||
) {
|
||||
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus)
|
||||
const schedulable = this._isSchedulable(account.schedulable)
|
||||
// 检查 rateLimitStatus 或 status === 'rateLimited'
|
||||
const hasRateLimitFlag =
|
||||
this._hasRateLimitFlag(account.rateLimitStatus) || account.status === 'rateLimited'
|
||||
const schedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!schedulable && !hasRateLimitFlag) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`)
|
||||
@@ -464,9 +435,23 @@ class UnifiedOpenAIScheduler {
|
||||
|
||||
let isRateLimitCleared = false
|
||||
if (hasRateLimitFlag) {
|
||||
// 区分正常限流和历史遗留数据
|
||||
if (this._hasRateLimitFlag(account.rateLimitStatus)) {
|
||||
// 有 rateLimitStatus,走正常清理逻辑
|
||||
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
} else {
|
||||
// 只有 status=rateLimited 但没有 rateLimitStatus,是历史遗留数据,直接清除
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
status: 'active',
|
||||
schedulable: 'true'
|
||||
})
|
||||
isRateLimitCleared = true
|
||||
logger.info(
|
||||
`✅ OpenAI-Responses账号 ${account.name} 清除历史遗留限流状态(status=rateLimited 但无 rateLimitStatus)`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRateLimitCleared) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
|
||||
@@ -544,7 +529,7 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -905,7 +890,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -10,6 +10,7 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { getCachedConfig, setCachedConfig } = require('../utils/performanceOptimizer')
|
||||
|
||||
// 清理任务间隔
|
||||
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||
@@ -19,6 +20,9 @@ const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||
|
||||
// 配置缓存 key
|
||||
const CONFIG_CACHE_KEY = 'user_message_queue_config'
|
||||
|
||||
class UserMessageQueueService {
|
||||
constructor() {
|
||||
this.cleanupTimer = null
|
||||
@@ -64,18 +68,23 @@ class UserMessageQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置(支持 Web 界面配置优先)
|
||||
* 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
// 检查缓存
|
||||
const cached = getCachedConfig(CONFIG_CACHE_KEY)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||
const queueConfig = config.userMessageQueue || {}
|
||||
const defaults = {
|
||||
enabled: queueConfig.enabled ?? false,
|
||||
delayMs: queueConfig.delayMs ?? 200,
|
||||
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||
timeoutMs: queueConfig.timeoutMs ?? 60000,
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 120000
|
||||
}
|
||||
|
||||
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||
@@ -83,7 +92,7 @@ class UserMessageQueueService {
|
||||
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||
const webConfig = await claudeRelayConfigService.getConfig()
|
||||
|
||||
return {
|
||||
const result = {
|
||||
enabled:
|
||||
webConfig.userMessageQueueEnabled !== undefined
|
||||
? webConfig.userMessageQueueEnabled
|
||||
@@ -101,8 +110,13 @@ class UserMessageQueueService {
|
||||
? webConfig.userMessageQueueLockTtlMs
|
||||
: defaults.lockTtlMs
|
||||
}
|
||||
|
||||
// 缓存配置 30 秒
|
||||
setCachedConfig(CONFIG_CACHE_KEY, result, 30000)
|
||||
return result
|
||||
} catch {
|
||||
// 回退到环境变量配置
|
||||
// 回退到环境变量配置,也缓存
|
||||
setCachedConfig(CONFIG_CACHE_KEY, defaults, 30000)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserService {
|
||||
// 保存用户信息
|
||||
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
||||
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
||||
await redis.addToIndex('user:index', user.id)
|
||||
|
||||
// 如果是新用户,尝试转移匹配的API Keys
|
||||
if (isNewUser) {
|
||||
@@ -167,8 +168,8 @@ class UserService {
|
||||
`📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys`
|
||||
)
|
||||
|
||||
// Count only non-deleted API keys for the user's active count
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
|
||||
// Count only non-deleted API keys for the user's active count(布尔值比较)
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => !key.isDeleted).length
|
||||
|
||||
return {
|
||||
totalUsage,
|
||||
@@ -191,14 +192,18 @@ class UserService {
|
||||
// 📋 获取所有用户列表(管理员功能)
|
||||
async getAllUsers(options = {}) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const { page = 1, limit = 20, role, isActive } = options
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const users = []
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
|
||||
@@ -398,14 +403,15 @@ class UserService {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userSessionPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const keys = await redis.scanKeys(pattern)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const sessionData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const sessionData = dataList[i]
|
||||
if (sessionData) {
|
||||
const session = JSON.parse(sessionData)
|
||||
if (session.userId === userId) {
|
||||
await client.del(key)
|
||||
await client.del(keys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,9 +460,13 @@ class UserService {
|
||||
// 📊 获取用户统计信息
|
||||
async getUserStats() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const stats = {
|
||||
totalUsers: 0,
|
||||
@@ -472,8 +482,8 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
stats.totalUsers++
|
||||
@@ -522,7 +532,7 @@ class UserService {
|
||||
const { displayName, username, email } = user
|
||||
|
||||
// 获取所有API Keys
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找到没有用户ID的API Keys(即由Admin创建的)
|
||||
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
|
||||
|
||||
283
src/services/weeklyClaudeCostInitService.js
Normal file
283
src/services/weeklyClaudeCostInitService.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const pricingService = require('./pricingService')
|
||||
const serviceRatesService = require('./serviceRatesService')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// 生成配置时区下的 YYYY-MM-DD 字符串。
|
||||
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
|
||||
function formatTzDateYmd(tzDate) {
|
||||
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
|
||||
}
|
||||
|
||||
class WeeklyClaudeCostInitService {
|
||||
_getCurrentWeekDatesInTimezone() {
|
||||
const tzNow = redis.getDateInTimezone(new Date())
|
||||
const tzToday = new Date(tzNow)
|
||||
tzToday.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// ISO 周:周一=1 ... 周日=7
|
||||
const isoDay = tzToday.getUTCDay() || 7
|
||||
const tzMonday = new Date(tzToday)
|
||||
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
|
||||
|
||||
const dates = []
|
||||
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
dates.push(formatTzDateYmd(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
_buildWeeklyOpusKey(keyId, weekString) {
|
||||
return `usage:opus:weekly:${keyId}:${weekString}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动回填:把"本周(周一到今天)Claude 全模型"周费用从按日/按模型统计里反算出来,
|
||||
* 写入 `usage:opus:weekly:*`,保证周限额在重启后不归零。
|
||||
*
|
||||
* 说明:
|
||||
* - 只回填本周,不做历史回填(符合"只要本周数据"诉求)
|
||||
* - 会加分布式锁,避免多实例重复跑
|
||||
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key)
|
||||
*/
|
||||
async backfillCurrentWeekClaudeCosts() {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用')
|
||||
return { success: false, reason: 'redis_unavailable' }
|
||||
}
|
||||
|
||||
if (!pricingService || !pricingService.pricingData) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化')
|
||||
return { success: false, reason: 'pricing_uninitialized' }
|
||||
}
|
||||
|
||||
const weekString = redis.getWeekStringInTimezone()
|
||||
const doneKey = `init:weekly_opus_cost:${weekString}:done`
|
||||
|
||||
try {
|
||||
const alreadyDone = await client.get(doneKey)
|
||||
if (alreadyDone) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
} catch (e) {
|
||||
// 尽力而为:读取失败不阻断启动回填流程。
|
||||
}
|
||||
|
||||
const lockKey = `lock:init:weekly_opus_cost:${weekString}`
|
||||
const lockValue = `${process.pid}:${Date.now()}`
|
||||
const lockTtlMs = 15 * 60 * 1000
|
||||
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
|
||||
if (!lockAcquired) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
|
||||
return { success: true, skipped: true, reason: 'locked' }
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
|
||||
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
const dates = this._getCurrentWeekDatesInTimezone()
|
||||
|
||||
// 预加载所有 API Key 数据和全局倍率(避免循环内重复查询)
|
||||
const keyDataCache = new Map()
|
||||
const globalRateCache = new Map()
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
pipeline.hgetall(`apikey:${keyId}`)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const [, data] = results[j] || []
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
keyDataCache.set(batch[j], data)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`💰 预加载 ${keyDataCache.size} 个 API Key 数据`)
|
||||
|
||||
// 推断账户类型的辅助函数(与运行时 recordOpusCost 一致,只统计 claude-official/claude-console/ccr)
|
||||
const OPUS_ACCOUNT_TYPES = ['claude-official', 'claude-console', 'ccr']
|
||||
const inferAccountType = (keyData) => {
|
||||
if (keyData?.ccrAccountId) {
|
||||
return 'ccr'
|
||||
}
|
||||
if (keyData?.claudeConsoleAccountId) {
|
||||
return 'claude-console'
|
||||
}
|
||||
if (keyData?.claudeAccountId) {
|
||||
return 'claude-official'
|
||||
}
|
||||
// bedrock/azure/gemini 等不计入周费用
|
||||
return null
|
||||
}
|
||||
|
||||
const costByKeyId = new Map()
|
||||
let scannedKeys = 0
|
||||
let matchedClaudeKeys = 0
|
||||
|
||||
const toInt = (v) => {
|
||||
const n = parseInt(v || '0', 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。
|
||||
for (const dateStr of dates) {
|
||||
let cursor = '0'
|
||||
const pattern = `usage:*:model:daily:*:${dateStr}`
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
|
||||
cursor = nextCursor
|
||||
scannedKeys += keys.length
|
||||
|
||||
const entries = []
|
||||
for (const usageKey of keys) {
|
||||
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
|
||||
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const keyId = match[1]
|
||||
const model = match[2]
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
continue
|
||||
}
|
||||
matchedClaudeKeys++
|
||||
entries.push({ usageKey, keyId, model })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const entry of entries) {
|
||||
pipeline.hgetall(entry.usageKey)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
|
||||
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
|
||||
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
|
||||
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
|
||||
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
|
||||
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
|
||||
|
||||
const cacheCreationTotal =
|
||||
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
|
||||
? ephemeral5mTokens + ephemeral1hTokens
|
||||
: cacheCreateTokens
|
||||
|
||||
const usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreationTotal,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usage.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
const costInfo = pricingService.calculateCost(usage, entry.model)
|
||||
const realCost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
|
||||
if (realCost <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 应用倍率:全局倍率 × Key 倍率(使用缓存数据)
|
||||
const keyData = keyDataCache.get(entry.keyId)
|
||||
const accountType = inferAccountType(keyData)
|
||||
|
||||
// 与运行时 recordOpusCost 一致:只统计 claude-official/claude-console/ccr 账户
|
||||
if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const service = serviceRatesService.getService(accountType, entry.model)
|
||||
|
||||
// 获取全局倍率(带缓存)
|
||||
let globalRate = globalRateCache.get(service)
|
||||
if (globalRate === undefined) {
|
||||
globalRate = await serviceRatesService.getServiceRate(service)
|
||||
globalRateCache.set(service, globalRate)
|
||||
}
|
||||
|
||||
// 获取 Key 倍率
|
||||
let keyRates = {}
|
||||
try {
|
||||
keyRates = JSON.parse(keyData?.serviceRates || '{}')
|
||||
} catch (e) {
|
||||
keyRates = {}
|
||||
}
|
||||
const keyRate = keyRates[service] ?? 1.0
|
||||
const ratedCost = realCost * globalRate * keyRate
|
||||
|
||||
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + ratedCost)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
}
|
||||
|
||||
// 为所有 API Key 写入本周 opus:weekly key
|
||||
const ttlSeconds = 14 * 24 * 3600
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
const weeklyKey = this._buildWeeklyOpusKey(keyId, weekString)
|
||||
const cost = costByKeyId.get(keyId) || 0
|
||||
pipeline.set(weeklyKey, String(cost))
|
||||
pipeline.expire(weeklyKey, ttlSeconds)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
|
||||
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
logger.info(
|
||||
`✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
weekString,
|
||||
keyCount: keyIds.length,
|
||||
scannedKeys,
|
||||
matchedClaudeKeys,
|
||||
filledKeys: costByKeyId.size,
|
||||
durationMs
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeeklyClaudeCostInitService()
|
||||
408
src/utils/commonHelper.js
Normal file
408
src/utils/commonHelper.js
Normal file
@@ -0,0 +1,408 @@
|
||||
// 通用工具函数集合
|
||||
// 抽取自各服务的重复代码,统一管理
|
||||
|
||||
const crypto = require('crypto')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('./lruCache')
|
||||
|
||||
// ============================================
|
||||
// 加密相关 - 工厂模式支持不同 salt
|
||||
// ============================================
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 缓存不同 salt 的加密实例
|
||||
const _encryptorCache = new Map()
|
||||
|
||||
// 创建加密器实例(每个 salt 独立缓存)
|
||||
const createEncryptor = (salt) => {
|
||||
if (_encryptorCache.has(salt)) {
|
||||
return _encryptorCache.get(salt)
|
||||
}
|
||||
|
||||
let keyCache = null
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
const getKey = () => {
|
||||
if (!keyCache) {
|
||||
keyCache = crypto.scryptSync(config.security.encryptionKey, salt, 32)
|
||||
}
|
||||
return keyCache
|
||||
}
|
||||
|
||||
const encrypt = (text) => {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = getKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
}
|
||||
|
||||
const decrypt = (text, useCache = true) => {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (!text.includes(':')) {
|
||||
return text
|
||||
}
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
if (useCache) {
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
try {
|
||||
const key = getKey()
|
||||
const [ivHex, encrypted] = text.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
if (useCache) {
|
||||
decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
}
|
||||
return decrypted
|
||||
} catch (e) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
const instance = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
getKey,
|
||||
clearCache: () => decryptCache.clear(),
|
||||
getStats: () => decryptCache.getStats?.() || { size: decryptCache.size }
|
||||
}
|
||||
|
||||
_encryptorCache.set(salt, instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 默认加密器(向后兼容)
|
||||
const defaultEncryptor = createEncryptor('claude-relay-salt')
|
||||
const { encrypt } = defaultEncryptor
|
||||
const { decrypt } = defaultEncryptor
|
||||
const getEncryptionKey = defaultEncryptor.getKey
|
||||
const clearDecryptCache = defaultEncryptor.clearCache
|
||||
const getDecryptCacheStats = defaultEncryptor.getStats
|
||||
|
||||
// ============================================
|
||||
// 布尔值处理
|
||||
// ============================================
|
||||
|
||||
// 转换为布尔值(宽松模式)
|
||||
const toBoolean = (value) =>
|
||||
value === true ||
|
||||
value === 'true' ||
|
||||
(typeof value === 'string' && value.toLowerCase() === 'true')
|
||||
|
||||
// 检查是否为真值(null/undefined 返回 false)
|
||||
const isTruthy = (value) => value !== null && value !== undefined && toBoolean(value)
|
||||
|
||||
// 检查是否可调度(默认 true,只有明确 false 才返回 false)
|
||||
const isSchedulable = (value) => value !== false && value !== 'false'
|
||||
|
||||
// 检查是否激活
|
||||
const isActive = (value) => value === true || value === 'true'
|
||||
|
||||
// 检查账户是否健康(激活且状态正常)
|
||||
const isAccountHealthy = (account) => {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
if (!isTruthy(account.isActive)) {
|
||||
return false
|
||||
}
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
return !['error', 'unauthorized', 'blocked', 'temp_error'].includes(status)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// JSON 处理
|
||||
// ============================================
|
||||
|
||||
// 安全解析 JSON
|
||||
const safeParseJson = (value, fallback = null) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 安全解析 JSON 为对象
|
||||
const safeParseJsonObject = (value, fallback = null) => {
|
||||
const parsed = safeParseJson(value, fallback)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
// 安全解析 JSON 为数组
|
||||
const safeParseJsonArray = (value, fallback = []) => {
|
||||
const parsed = safeParseJson(value, fallback)
|
||||
return Array.isArray(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 模型名称处理
|
||||
// ============================================
|
||||
|
||||
// 规范化模型名称(用于统计聚合)
|
||||
const normalizeModelName = (model) => {
|
||||
if (!model || model === 'unknown') {
|
||||
return model
|
||||
}
|
||||
// Bedrock 模型: us-east-1.anthropic.claude-3-5-sonnet-v1:0
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
return model
|
||||
.replace(/^[a-z0-9-]+\./, '')
|
||||
.replace('anthropic.', '')
|
||||
.replace(/-v\d+:\d+$/, '')
|
||||
}
|
||||
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||
}
|
||||
|
||||
// 规范化端点类型
|
||||
const normalizeEndpointType = (endpointType) => {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
return ['openai', 'comm', 'anthropic'].includes(normalized) ? normalized : 'anthropic'
|
||||
}
|
||||
|
||||
// 检查模型是否在映射表中
|
||||
const isModelInMapping = (modelMapping, requestedModel) => {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
const lower = requestedModel.toLowerCase()
|
||||
return Object.keys(modelMapping).some((k) => k.toLowerCase() === lower)
|
||||
}
|
||||
|
||||
// 获取映射后的模型名称
|
||||
const getMappedModelName = (modelMapping, requestedModel) => {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return requestedModel
|
||||
}
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
const lower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 账户调度相关
|
||||
// ============================================
|
||||
|
||||
// 按优先级和最后使用时间排序账户
|
||||
const sortAccountsByPriority = (accounts) =>
|
||||
[...accounts].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
|
||||
// 生成粘性会话 Key
|
||||
const composeStickySessionKey = (prefix, sessionHash, apiKeyId = null) => {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
return `sticky:${prefix}:${apiKeyId || 'default'}:${sessionHash}`
|
||||
}
|
||||
|
||||
// 过滤可用账户(激活 + 健康 + 可调度)
|
||||
const filterAvailableAccounts = (accounts) =>
|
||||
accounts.filter((acc) => acc && isAccountHealthy(acc) && isSchedulable(acc.schedulable))
|
||||
|
||||
// ============================================
|
||||
// 字符串处理
|
||||
// ============================================
|
||||
|
||||
// 截断字符串
|
||||
const truncate = (str, maxLen = 100, suffix = '...') => {
|
||||
if (!str || str.length <= maxLen) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLen - suffix.length) + suffix
|
||||
}
|
||||
|
||||
// 掩码敏感信息(保留前后几位)
|
||||
const maskSensitive = (str, keepStart = 4, keepEnd = 4, maskChar = '*') => {
|
||||
if (!str || str.length <= keepStart + keepEnd) {
|
||||
return str
|
||||
}
|
||||
const maskLen = Math.min(str.length - keepStart - keepEnd, 8)
|
||||
return str.slice(0, keepStart) + maskChar.repeat(maskLen) + str.slice(-keepEnd)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 数值处理
|
||||
// ============================================
|
||||
|
||||
// 安全解析整数
|
||||
const safeParseInt = (value, fallback = 0) => {
|
||||
const parsed = parseInt(value, 10)
|
||||
return isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
|
||||
// 安全解析浮点数
|
||||
const safeParseFloat = (value, fallback = 0) => {
|
||||
const parsed = parseFloat(value)
|
||||
return isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
|
||||
// 限制数值范围
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
// ============================================
|
||||
// 时间处理
|
||||
// ============================================
|
||||
|
||||
// 获取时区偏移后的日期
|
||||
const getDateInTimezone = (date = new Date(), offset = config.system?.timezoneOffset || 8) =>
|
||||
new Date(date.getTime() + offset * 3600000)
|
||||
|
||||
// 获取时区日期字符串 YYYY-MM-DD
|
||||
const getDateStringInTimezone = (date = new Date()) => {
|
||||
const d = getDateInTimezone(date)
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
const isExpired = (expiresAt) => {
|
||||
if (!expiresAt) {
|
||||
return false
|
||||
}
|
||||
return new Date(expiresAt).getTime() < Date.now()
|
||||
}
|
||||
|
||||
// 计算剩余时间(秒)
|
||||
const getTimeRemaining = (expiresAt) => {
|
||||
if (!expiresAt) {
|
||||
return Infinity
|
||||
}
|
||||
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 版本处理
|
||||
// ============================================
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 获取应用版本号
|
||||
const getAppVersion = () => {
|
||||
if (process.env.APP_VERSION) {
|
||||
return process.env.APP_VERSION
|
||||
}
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION
|
||||
}
|
||||
try {
|
||||
const versionFile = path.join(__dirname, '..', '..', 'VERSION')
|
||||
if (fs.existsSync(versionFile)) {
|
||||
return fs.readFileSync(versionFile, 'utf8').trim()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
return require('../../package.json').version
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return '1.0.0'
|
||||
}
|
||||
|
||||
// 版本比较: a > b
|
||||
const versionGt = (a, b) => {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) {
|
||||
return true
|
||||
}
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 版本比较: a >= b
|
||||
const versionGte = (a, b) => a === b || versionGt(a, b)
|
||||
|
||||
module.exports = {
|
||||
// 加密
|
||||
createEncryptor,
|
||||
encrypt,
|
||||
decrypt,
|
||||
getEncryptionKey,
|
||||
clearDecryptCache,
|
||||
getDecryptCacheStats,
|
||||
// 布尔值
|
||||
toBoolean,
|
||||
isTruthy,
|
||||
isSchedulable,
|
||||
isActive,
|
||||
isAccountHealthy,
|
||||
// JSON
|
||||
safeParseJson,
|
||||
safeParseJsonObject,
|
||||
safeParseJsonArray,
|
||||
// 模型
|
||||
normalizeModelName,
|
||||
normalizeEndpointType,
|
||||
isModelInMapping,
|
||||
getMappedModelName,
|
||||
// 调度
|
||||
sortAccountsByPriority,
|
||||
composeStickySessionKey,
|
||||
filterAvailableAccounts,
|
||||
// 字符串
|
||||
truncate,
|
||||
maskSensitive,
|
||||
// 数值
|
||||
safeParseInt,
|
||||
safeParseFloat,
|
||||
clamp,
|
||||
// 时间
|
||||
getDateInTimezone,
|
||||
getDateStringInTimezone,
|
||||
isExpired,
|
||||
getTimeRemaining,
|
||||
// 版本
|
||||
getAppVersion,
|
||||
versionGt,
|
||||
versionGte
|
||||
}
|
||||
@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
|
||||
title: 'Claude Code Compact System Prompt Agent SDK2',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
||||
},
|
||||
claudeOtherSystemPrompt5: {
|
||||
category: 'system',
|
||||
title: 'Claude CLI Billing Header',
|
||||
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
|
||||
},
|
||||
claudeOtherSystemPromptCompact: {
|
||||
category: 'system',
|
||||
title: 'Claude Code Compact System Prompt',
|
||||
|
||||
@@ -1,217 +1,260 @@
|
||||
/**
|
||||
* 错误消息清理工具
|
||||
* 用于移除上游错误中的供应商特定信息(如 URL、引用等)
|
||||
* 错误消息清理工具 - 白名单错误码制
|
||||
* 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清理错误消息中的 URL 和供应商引用
|
||||
* @param {string} message - 原始错误消息
|
||||
* @returns {string} - 清理后的消息
|
||||
*/
|
||||
function sanitizeErrorMessage(message) {
|
||||
if (typeof message !== 'string') {
|
||||
return message
|
||||
}
|
||||
const logger = require('./logger')
|
||||
|
||||
// 移除 URL(http:// 或 https://)
|
||||
let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '')
|
||||
|
||||
// 移除常见的供应商引用模式
|
||||
cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '')
|
||||
cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx"
|
||||
cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx"
|
||||
cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息
|
||||
|
||||
// 移除供应商特定关键词(包括整个单词)
|
||||
cleaned = cleaned.replace(/88code\S*/gi, '')
|
||||
cleaned = cleaned.replace(/duck\S*/gi, '')
|
||||
cleaned = cleaned.replace(/packy\S*/gi, '')
|
||||
cleaned = cleaned.replace(/ikun\S*/gi, '')
|
||||
cleaned = cleaned.replace(/privnode\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yescode\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yes.vg\S*/gi, '')
|
||||
cleaned = cleaned.replace(/share\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yhlxj\S*/gi, '')
|
||||
cleaned = cleaned.replace(/gac\S*/gi, '')
|
||||
cleaned = cleaned.replace(/driod\S*/gi, '')
|
||||
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// 如果消息被清理得太短或为空,返回通用消息
|
||||
if (cleaned.length < 5) {
|
||||
return 'The requested model is currently unavailable'
|
||||
}
|
||||
|
||||
return cleaned
|
||||
// 标准错误码定义
|
||||
const ERROR_CODES = {
|
||||
E001: { message: 'Service temporarily unavailable', status: 503 },
|
||||
E002: { message: 'Network connection failed', status: 502 },
|
||||
E003: { message: 'Authentication failed', status: 401 },
|
||||
E004: { message: 'Rate limit exceeded', status: 429 },
|
||||
E005: { message: 'Invalid request', status: 400 },
|
||||
E006: { message: 'Model not available', status: 503 },
|
||||
E007: { message: 'Upstream service error', status: 502 },
|
||||
E008: { message: 'Request timeout', status: 504 },
|
||||
E009: { message: 'Permission denied', status: 403 },
|
||||
E010: { message: 'Resource not found', status: 404 },
|
||||
E011: { message: 'Account temporarily unavailable', status: 503 },
|
||||
E012: { message: 'Server overloaded', status: 529 },
|
||||
E013: { message: 'Invalid API key', status: 401 },
|
||||
E014: { message: 'Quota exceeded', status: 429 },
|
||||
E015: { message: 'Internal server error', status: 500 }
|
||||
}
|
||||
|
||||
// 错误特征匹配规则(按优先级排序)
|
||||
const ERROR_MATCHERS = [
|
||||
// 网络层错误
|
||||
{ pattern: /ENOTFOUND|DNS|getaddrinfo/i, code: 'E002' },
|
||||
{ pattern: /ECONNREFUSED|ECONNRESET|connection refused/i, code: 'E002' },
|
||||
{ pattern: /ETIMEDOUT|timeout/i, code: 'E008' },
|
||||
{ pattern: /ECONNABORTED|aborted/i, code: 'E002' },
|
||||
|
||||
// 认证错误
|
||||
{ pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' },
|
||||
{ pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' },
|
||||
{ pattern: /authentication|auth.*fail/i, code: 'E003' },
|
||||
|
||||
// 权限错误
|
||||
{ pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' },
|
||||
{ pattern: /does not have.*permission/i, code: 'E009' },
|
||||
|
||||
// 限流错误
|
||||
{ pattern: /rate.*limit|too many requests|429/i, code: 'E004' },
|
||||
{ pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' },
|
||||
|
||||
// 过载错误
|
||||
{ pattern: /overloaded|529|capacity/i, code: 'E012' },
|
||||
|
||||
// 账户错误
|
||||
{ pattern: /account.*disabled|organization.*disabled/i, code: 'E011' },
|
||||
{ pattern: /too many active sessions/i, code: 'E011' },
|
||||
|
||||
// 模型错误
|
||||
{ pattern: /model.*not.*found|model.*unavailable|unsupported.*model/i, code: 'E006' },
|
||||
|
||||
// 请求错误
|
||||
{ pattern: /bad.*request|invalid.*request|malformed/i, code: 'E005' },
|
||||
{ pattern: /not.*found|404/i, code: 'E010' },
|
||||
|
||||
// 上游错误
|
||||
{ pattern: /upstream|502|bad.*gateway/i, code: 'E007' },
|
||||
{ pattern: /503|service.*unavailable/i, code: 'E001' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 递归清理对象中的所有错误消息字段
|
||||
* @param {Object} errorData - 原始错误数据对象
|
||||
* @returns {Object} - 清理后的错误数据
|
||||
* 根据原始错误匹配标准错误码
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @param {string} options.context - 错误上下文(用于日志)
|
||||
* @param {boolean} options.logOriginal - 是否记录原始错误(默认true)
|
||||
* @returns {{ code: string, message: string, status: number }}
|
||||
*/
|
||||
function sanitizeUpstreamError(errorData) {
|
||||
if (!errorData || typeof errorData !== 'object') {
|
||||
return errorData
|
||||
function mapToErrorCode(error, options = {}) {
|
||||
const { context = 'unknown', logOriginal = true } = options
|
||||
|
||||
// 提取原始错误信息
|
||||
const originalMessage = extractOriginalMessage(error)
|
||||
const errorCode = error?.code || error?.response?.status
|
||||
const statusCode = error?.response?.status || error?.status || error?.statusCode
|
||||
|
||||
// 记录原始错误到日志(供调试)
|
||||
if (logOriginal && originalMessage) {
|
||||
logger.debug(`[ErrorSanitizer] Original error (${context}):`, {
|
||||
message: originalMessage,
|
||||
code: errorCode,
|
||||
status: statusCode
|
||||
})
|
||||
}
|
||||
|
||||
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||
const looksLikeAxiosError =
|
||||
errorData.isAxiosError ||
|
||||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
|
||||
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
|
||||
// 匹配错误码
|
||||
let matchedCode = 'E015' // 默认:内部服务器错误
|
||||
|
||||
if (looksLikeAxiosError || looksLikeError) {
|
||||
const statusCode = errorData.response?.status
|
||||
const upstreamBody = errorData.response?.data
|
||||
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
|
||||
// 先按 HTTP 状态码快速匹配
|
||||
if (statusCode) {
|
||||
if (statusCode === 401) {
|
||||
matchedCode = 'E003'
|
||||
} else if (statusCode === 403) {
|
||||
matchedCode = 'E009'
|
||||
} else if (statusCode === 404) {
|
||||
matchedCode = 'E010'
|
||||
} else if (statusCode === 429) {
|
||||
matchedCode = 'E004'
|
||||
} else if (statusCode === 502) {
|
||||
matchedCode = 'E007'
|
||||
} else if (statusCode === 503) {
|
||||
matchedCode = 'E001'
|
||||
} else if (statusCode === 504) {
|
||||
matchedCode = 'E008'
|
||||
} else if (statusCode === 529) {
|
||||
matchedCode = 'E012'
|
||||
}
|
||||
}
|
||||
|
||||
// 再按消息内容精确匹配(可能覆盖状态码匹配)
|
||||
if (originalMessage) {
|
||||
for (const matcher of ERROR_MATCHERS) {
|
||||
if (matcher.pattern.test(originalMessage)) {
|
||||
matchedCode = matcher.code
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按错误 code 匹配(网络错误)
|
||||
if (errorCode) {
|
||||
const codeStr = String(errorCode).toUpperCase()
|
||||
if (codeStr === 'ENOTFOUND' || codeStr === 'EAI_AGAIN') {
|
||||
matchedCode = 'E002'
|
||||
} else if (codeStr === 'ECONNREFUSED' || codeStr === 'ECONNRESET') {
|
||||
matchedCode = 'E002'
|
||||
} else if (codeStr === 'ETIMEDOUT' || codeStr === 'ESOCKETTIMEDOUT') {
|
||||
matchedCode = 'E008'
|
||||
} else if (codeStr === 'ECONNABORTED') {
|
||||
matchedCode = 'E002'
|
||||
}
|
||||
}
|
||||
|
||||
const result = ERROR_CODES[matchedCode]
|
||||
return {
|
||||
name: errorData.name || 'Error',
|
||||
code: errorData.code,
|
||||
statusCode,
|
||||
message: sanitizeErrorMessage(errorData.message || ''),
|
||||
upstreamMessage: upstreamMessage || undefined,
|
||||
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
|
||||
code: matchedCode,
|
||||
message: result.message,
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
|
||||
// 递归清理嵌套的错误对象
|
||||
const visited = new WeakSet()
|
||||
|
||||
const shouldRedactKey = (key) => {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const lowerKey = String(key).toLowerCase()
|
||||
return (
|
||||
lowerKey === 'authorization' ||
|
||||
lowerKey === 'cookie' ||
|
||||
lowerKey.includes('api_key') ||
|
||||
lowerKey.includes('apikey') ||
|
||||
lowerKey.includes('access_token') ||
|
||||
lowerKey.includes('refresh_token') ||
|
||||
lowerKey.endsWith('token') ||
|
||||
lowerKey.includes('secret') ||
|
||||
lowerKey.includes('password')
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizeObject = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (visited.has(obj)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(obj)
|
||||
|
||||
// 主动剔除常见“超大且敏感”的字段
|
||||
if (obj.config || obj.request || obj.response) {
|
||||
return '[Redacted]'
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (shouldRedactKey(key)) {
|
||||
obj[key] = '[REDACTED]'
|
||||
continue
|
||||
}
|
||||
|
||||
// 清理所有字符串字段,不仅仅是 message
|
||||
if (typeof obj[key] === 'string') {
|
||||
obj[key] = sanitizeErrorMessage(obj[key])
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
sanitizeObject(obj[key])
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// 尽量不修改原对象:浅拷贝后递归清理
|
||||
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||
return sanitizeObject(clone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取错误消息(支持多种错误格式)
|
||||
* @param {*} body - 错误响应体(字符串或对象)
|
||||
* @returns {string} - 提取的错误消息
|
||||
* 提取原始错误消息
|
||||
*/
|
||||
function extractOriginalMessage(error) {
|
||||
if (!error) {
|
||||
return ''
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
if (error.response?.data?.error?.message) {
|
||||
return error.response.data.error.message
|
||||
}
|
||||
if (error.response?.data?.error) {
|
||||
return String(error.response.data.error)
|
||||
}
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全的错误响应对象
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {{ error: { code: string, message: string }, status: number }}
|
||||
*/
|
||||
function createSafeErrorResponse(error, options = {}) {
|
||||
const mapped = mapToErrorCode(error, options)
|
||||
return {
|
||||
error: {
|
||||
code: mapped.code,
|
||||
message: mapped.message
|
||||
},
|
||||
status: mapped.status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全的 SSE 错误事件
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {string} - SSE 格式的错误事件
|
||||
*/
|
||||
function createSafeSSEError(error, options = {}) {
|
||||
const mapped = mapToErrorCode(error, options)
|
||||
return `event: error\ndata: ${JSON.stringify({
|
||||
error: mapped.message,
|
||||
code: mapped.code,
|
||||
timestamp: new Date().toISOString()
|
||||
})}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全的错误消息(用于替换 error.message)
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSafeMessage(error, options = {}) {
|
||||
return mapToErrorCode(error, options).message
|
||||
}
|
||||
|
||||
// 兼容旧接口
|
||||
function sanitizeErrorMessage(message) {
|
||||
if (!message) {
|
||||
return 'Service temporarily unavailable'
|
||||
}
|
||||
return mapToErrorCode({ message }, { logOriginal: false }).message
|
||||
}
|
||||
|
||||
function sanitizeUpstreamError(errorData) {
|
||||
return createSafeErrorResponse(errorData, { logOriginal: false })
|
||||
}
|
||||
|
||||
function extractErrorMessage(body) {
|
||||
if (!body) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 处理字符串类型
|
||||
if (typeof body === 'string') {
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return extractErrorMessage(parsed)
|
||||
} catch (error) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对象类型
|
||||
if (typeof body === 'object') {
|
||||
// 常见错误格式: { error: "message" }
|
||||
if (typeof body.error === 'string') {
|
||||
return body.error
|
||||
}
|
||||
// 嵌套错误格式: { error: { message: "..." } }
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
if (typeof body.error.message === 'string') {
|
||||
return body.error.message
|
||||
}
|
||||
if (typeof body.error.error === 'string') {
|
||||
return body.error.error
|
||||
}
|
||||
}
|
||||
// 直接消息格式: { message: "..." }
|
||||
if (typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
return extractOriginalMessage(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为账户被禁用或不可用的 400 错误
|
||||
* @param {number} statusCode - HTTP 状态码
|
||||
* @param {*} body - 响应体
|
||||
* @returns {boolean} - 是否为账户禁用错误
|
||||
*/
|
||||
function isAccountDisabledError(statusCode, body) {
|
||||
if (statusCode !== 400) {
|
||||
return false
|
||||
}
|
||||
|
||||
const message = extractErrorMessage(body)
|
||||
const message = extractOriginalMessage(body)
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
// 将消息全部转换为小写,进行模糊匹配(避免大小写问题)
|
||||
const lowerMessage = message.toLowerCase()
|
||||
// 检测常见的账户禁用/不可用模式
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lowerMessage.includes('organization has been disabled') ||
|
||||
lowerMessage.includes('account has been disabled') ||
|
||||
lowerMessage.includes('account is disabled') ||
|
||||
lowerMessage.includes('no account supporting') ||
|
||||
lowerMessage.includes('account not found') ||
|
||||
lowerMessage.includes('invalid account') ||
|
||||
lowerMessage.includes('too many active sessions')
|
||||
lower.includes('organization has been disabled') ||
|
||||
lower.includes('account has been disabled') ||
|
||||
lower.includes('account is disabled') ||
|
||||
lower.includes('no account supporting') ||
|
||||
lower.includes('account not found') ||
|
||||
lower.includes('invalid account') ||
|
||||
lower.includes('too many active sessions')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ERROR_CODES,
|
||||
mapToErrorCode,
|
||||
createSafeErrorResponse,
|
||||
createSafeSSEError,
|
||||
getSafeMessage,
|
||||
// 兼容旧接口
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamError,
|
||||
extractErrorMessage,
|
||||
|
||||
@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
|
||||
*
|
||||
* 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名:
|
||||
* - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
|
||||
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
|
||||
* - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列
|
||||
*
|
||||
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
|
||||
*/
|
||||
function isClaudeFamilyModel(modelName) {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { baseModel } = parseVendorPrefixedModel(modelName)
|
||||
const m = (baseModel || '').trim().toLowerCase()
|
||||
if (!m) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bedrock 模型格式
|
||||
if (
|
||||
m.includes('.anthropic.claude-') ||
|
||||
m.startsWith('anthropic.claude-') ||
|
||||
m.includes('.claude-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 标准 Anthropic 模型 ID
|
||||
if (m.startsWith('claude-') || m.includes('claude-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
|
||||
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
isOpus45OrNewer,
|
||||
isClaudeFamilyModel
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ OAuth token exchange successful', {
|
||||
logger.success('OAuth token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
hasRefreshToken: !!response.data?.refresh_token,
|
||||
@@ -431,7 +431,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ Setup Token exchange successful', {
|
||||
logger.success('Setup Token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
scopes: response.data?.scope,
|
||||
@@ -661,7 +661,7 @@ async function getOrganizationInfo(sessionKey, proxyConfig = null) {
|
||||
throw new Error('未找到具有chat能力的组织')
|
||||
}
|
||||
|
||||
logger.success('✅ Found organization', {
|
||||
logger.success('Found organization', {
|
||||
uuid: bestOrg.uuid,
|
||||
capabilities: maxCapabilities
|
||||
})
|
||||
@@ -778,7 +778,7 @@ async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyCon
|
||||
// 构建完整的授权码(包含state,如果有的话)
|
||||
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
|
||||
|
||||
logger.success('✅ Got authorization code via Cookie', {
|
||||
logger.success('Got authorization code via Cookie', {
|
||||
codeLength: authorizationCode.length,
|
||||
codePrefix: `${authorizationCode.substring(0, 10)}...`
|
||||
})
|
||||
@@ -854,7 +854,7 @@ async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = fa
|
||||
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
|
||||
logger.success('✅ Cookie-based OAuth flow completed', {
|
||||
logger.success('Cookie-based OAuth flow completed', {
|
||||
isSetupToken,
|
||||
organizationUuid,
|
||||
hasAccessToken: !!tokenData.accessToken,
|
||||
|
||||
168
src/utils/performanceOptimizer.js
Normal file
168
src/utils/performanceOptimizer.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 性能优化工具模块
|
||||
* 提供 HTTP keep-alive 连接池、定价数据缓存等优化功能
|
||||
*/
|
||||
|
||||
const https = require('https')
|
||||
const http = require('http')
|
||||
const fs = require('fs')
|
||||
const LRUCache = require('./lruCache')
|
||||
|
||||
// 连接池配置(从环境变量读取)
|
||||
const STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_STREAM) || 65535
|
||||
const NON_STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_NON_STREAM) || 16384
|
||||
const MAX_FREE_SOCKETS = parseInt(process.env.HTTPS_MAX_FREE_SOCKETS) || 2048
|
||||
const FREE_SOCKET_TIMEOUT = parseInt(process.env.HTTPS_FREE_SOCKET_TIMEOUT) || 30000
|
||||
|
||||
// 流式请求 agent:高 maxSockets,timeout=0(不限制)
|
||||
const httpsAgentStream = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0,
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// 非流式请求 agent:较小 maxSockets
|
||||
const httpsAgentNonStream = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// HTTP agent(非流式)
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// 定价数据缓存(按文件路径区分)
|
||||
const pricingDataCache = new Map()
|
||||
const PRICING_CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||
|
||||
// Redis 配置缓存(短 TTL)
|
||||
const configCache = new LRUCache(100)
|
||||
const CONFIG_CACHE_TTL = 30 * 1000 // 30秒
|
||||
|
||||
/**
|
||||
* 获取流式请求的 HTTPS agent
|
||||
*/
|
||||
function getHttpsAgentForStream() {
|
||||
return httpsAgentStream
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取非流式请求的 HTTPS agent
|
||||
*/
|
||||
function getHttpsAgentForNonStream() {
|
||||
return httpsAgentNonStream
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取定价数据(带缓存,按路径区分)
|
||||
* @param {string} pricingFilePath - 定价文件路径
|
||||
* @returns {Object|null} 定价数据
|
||||
*/
|
||||
function getPricingData(pricingFilePath) {
|
||||
const now = Date.now()
|
||||
const cached = pricingDataCache.get(pricingFilePath)
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (cached && now - cached.loadTime < PRICING_CACHE_TTL) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
// 重新加载
|
||||
try {
|
||||
if (!fs.existsSync(pricingFilePath)) {
|
||||
return null
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
|
||||
pricingDataCache.set(pricingFilePath, { data, loadTime: now })
|
||||
return data
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除定价数据缓存(用于热更新)
|
||||
* @param {string} pricingFilePath - 可选,指定路径则只清除该路径缓存
|
||||
*/
|
||||
function clearPricingCache(pricingFilePath = null) {
|
||||
if (pricingFilePath) {
|
||||
pricingDataCache.delete(pricingFilePath)
|
||||
} else {
|
||||
pricingDataCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的配置
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {*} 缓存值
|
||||
*/
|
||||
function getCachedConfig(key) {
|
||||
return configCache.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置缓存
|
||||
* @param {string} key - 缓存键
|
||||
* @param {*} value - 值
|
||||
* @param {number} ttl - TTL(毫秒)
|
||||
*/
|
||||
function setCachedConfig(key, value, ttl = CONFIG_CACHE_TTL) {
|
||||
configCache.set(key, value, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置缓存
|
||||
* @param {string} key - 缓存键
|
||||
*/
|
||||
function deleteCachedConfig(key) {
|
||||
configCache.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接池统计信息
|
||||
*/
|
||||
function getAgentStats() {
|
||||
return {
|
||||
httpsStream: {
|
||||
sockets: Object.keys(httpsAgentStream.sockets).length,
|
||||
freeSockets: Object.keys(httpsAgentStream.freeSockets).length,
|
||||
requests: Object.keys(httpsAgentStream.requests).length,
|
||||
maxSockets: STREAM_MAX_SOCKETS
|
||||
},
|
||||
httpsNonStream: {
|
||||
sockets: Object.keys(httpsAgentNonStream.sockets).length,
|
||||
freeSockets: Object.keys(httpsAgentNonStream.freeSockets).length,
|
||||
requests: Object.keys(httpsAgentNonStream.requests).length,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS
|
||||
},
|
||||
http: {
|
||||
sockets: Object.keys(httpAgent.sockets).length,
|
||||
freeSockets: Object.keys(httpAgent.freeSockets).length,
|
||||
requests: Object.keys(httpAgent.requests).length
|
||||
},
|
||||
configCache: configCache.getStats()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getHttpAgent: () => httpAgent,
|
||||
getPricingData,
|
||||
clearPricingCache,
|
||||
getCachedConfig,
|
||||
setCachedConfig,
|
||||
deleteCachedConfig,
|
||||
getAgentStats
|
||||
}
|
||||
@@ -7,9 +7,16 @@ function toNumber(value) {
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) {
|
||||
// keyId 和 accountType 用于计算倍率成本
|
||||
async function updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return { totalTokens: 0, totalCost: 0 }
|
||||
return { totalTokens: 0, totalCost: 0, ratedCost: 0 }
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
@@ -59,11 +66,25 @@ async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCost > 0 && rateLimitInfo.costCountKey) {
|
||||
await client.incrbyfloat(rateLimitInfo.costCountKey, totalCost)
|
||||
// 计算倍率成本(用于限流计数)
|
||||
let ratedCost = totalCost
|
||||
if (totalCost > 0 && keyId) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const service = serviceRatesService.getService(accountType, model)
|
||||
ratedCost = await apiKeyService.calculateRatedCost(keyId, service, totalCost)
|
||||
} catch (error) {
|
||||
// 倍率计算失败时使用真实成本
|
||||
ratedCost = totalCost
|
||||
}
|
||||
}
|
||||
|
||||
return { totalTokens, totalCost }
|
||||
if (ratedCost > 0 && rateLimitInfo.costCountKey) {
|
||||
await client.incrbyfloat(rateLimitInfo.costCountKey, ratedCost)
|
||||
}
|
||||
|
||||
return { totalTokens, totalCost, ratedCost }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -47,6 +47,72 @@ function parseSSELine(line) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSSELine
|
||||
/**
|
||||
* 增量 SSE 解析器类
|
||||
* 用于处理流式数据,避免每次都 split 整个 buffer
|
||||
*/
|
||||
class IncrementalSSEParser {
|
||||
constructor() {
|
||||
this.buffer = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加数据块并返回完整的事件
|
||||
* @param {string} chunk - 数据块
|
||||
* @returns {Array<Object>} 解析出的完整事件数组
|
||||
*/
|
||||
feed(chunk) {
|
||||
this.buffer += chunk
|
||||
const events = []
|
||||
|
||||
// 查找完整的事件(以 \n\n 分隔)
|
||||
let idx
|
||||
while ((idx = this.buffer.indexOf('\n\n')) !== -1) {
|
||||
const event = this.buffer.slice(0, idx)
|
||||
this.buffer = this.buffer.slice(idx + 2)
|
||||
|
||||
if (event.trim()) {
|
||||
// 解析事件中的每一行
|
||||
const lines = event.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6)
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
events.push({ type: 'data', data: JSON.parse(jsonStr) })
|
||||
} catch (e) {
|
||||
events.push({ type: 'invalid', raw: jsonStr, error: e })
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
events.push({ type: 'done' })
|
||||
}
|
||||
} else if (line.startsWith('event: ')) {
|
||||
events.push({ type: 'event', name: line.slice(7).trim() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余的 buffer 内容
|
||||
* @returns {string}
|
||||
*/
|
||||
getRemaining() {
|
||||
return this.buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置解析器
|
||||
*/
|
||||
reset() {
|
||||
this.buffer = ''
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSSELine,
|
||||
IncrementalSSEParser
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ function generateSessionString() {
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {boolean} options.stream - 是否流式(默认false)
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 1000)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||
const { stream, prompt = 'hi', maxTokens = 1000 } = options
|
||||
const payload = {
|
||||
model,
|
||||
messages: [
|
||||
@@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
text: prompt,
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
@@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
metadata: {
|
||||
user_id: generateSessionString()
|
||||
},
|
||||
max_tokens: 21333,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
|
||||
if (options.stream) {
|
||||
if (stream) {
|
||||
payload.stream = true
|
||||
}
|
||||
|
||||
@@ -234,9 +237,58 @@ async function sendStreamTestRequest(options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Gemini 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createGeminiTestPayload(_model = 'gemini-2.5-pro', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAI Responses 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_output_tokens: maxTokens,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
createGeminiTestPayload,
|
||||
createOpenAITestPayload,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ async function startDeviceAuthorization(proxyConfig = null) {
|
||||
throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)')
|
||||
}
|
||||
|
||||
logger.success('✅ 成功获取 WorkOS 设备码授权信息', {
|
||||
logger.success('成功获取 WorkOS 设备码授权信息', {
|
||||
verificationUri: data.verification_uri,
|
||||
userCode: data.user_code
|
||||
})
|
||||
|
||||
@@ -5,20 +5,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Relay Service - 管理后台</title>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 预连接到CDN域名,加速资源加载 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1352,7 +1351,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1589,7 +1587,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -3063,15 +3060,13 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -3623,7 +3618,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3770,7 +3764,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4035,7 +4028,6 @@
|
||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4533,7 +4525,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4924,7 +4915,6 @@
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -5125,7 +5115,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.18",
|
||||
"@vue/compiler-sfc": "3.5.18",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,12 +12,10 @@ import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import ToastNotification from '@/components/common/ToastNotification.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const toastRef = ref()
|
||||
const confirmRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题
|
||||
|
||||
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
46
web/admin-spa/src/assets/fonts/inter/inter.css
Normal file
46
web/admin-spa/src/assets/fonts/inter/inter.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/* Inter 字体本地化 - 仅包含项目使用的字重 (300, 400, 500, 600, 700) */
|
||||
|
||||
/* Inter Light - 300 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Light.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Regular - 400 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Medium - 500 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter SemiBold - 600 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Bold - 700 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
@@ -49,8 +49,8 @@
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -87,18 +87,6 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
@@ -106,10 +94,6 @@
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -160,15 +144,15 @@
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@@ -202,7 +186,7 @@
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
background: linear-gradient(135deg, var(--text-secondary) 0%, var(--bg-gradient-end) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(107, 114, 128, 0.3),
|
||||
@@ -231,7 +215,7 @@
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
@@ -251,7 +235,7 @@
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
|
||||
@@ -276,8 +260,8 @@
|
||||
}
|
||||
|
||||
.dark .modal-content {
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid rgba(75, 85, 99, 0.3);
|
||||
background: var(--glass-strong-color);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
@@ -419,8 +403,8 @@
|
||||
/* 玻璃态容器 */
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
|
||||
@@ -47,6 +47,70 @@
|
||||
--table-hover: rgba(129, 140, 248, 0.1);
|
||||
}
|
||||
|
||||
/* 覆盖 Tailwind v3 的暗黑模式背景色使用主题色 */
|
||||
.dark .bg-gray-800,
|
||||
.dark\:bg-gray-800:is(.dark *) {
|
||||
background-color: var(--glass-strong-color) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-700,
|
||||
.dark\:bg-gray-700:is(.dark *) {
|
||||
background-color: var(--bg-gradient-mid) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-900,
|
||||
.dark\:bg-gray-900:is(.dark *) {
|
||||
background-color: var(--bg-gradient-start) !important;
|
||||
}
|
||||
|
||||
/* 覆盖带透明度的背景色 */
|
||||
.dark\:bg-gray-800\/40:is(.dark *) {
|
||||
background-color: color-mix(in srgb, var(--glass-strong-color) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark\:bg-gray-700\/30:is(.dark *) {
|
||||
background-color: color-mix(in srgb, var(--bg-gradient-mid) 30%, transparent) !important;
|
||||
}
|
||||
|
||||
/* 覆盖 Tailwind v3 的暗黑模式渐变色 */
|
||||
.dark\:from-gray-700:is(.dark *) {
|
||||
--tw-gradient-from: var(--bg-gradient-mid) !important;
|
||||
--tw-gradient-to: var(--bg-gradient-mid) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
.dark\:to-gray-800\/90:is(.dark *) {
|
||||
--tw-gradient-to: var(--glass-strong-color) !important;
|
||||
}
|
||||
|
||||
/* 覆盖 Tailwind v3 的暗黑模式悬停背景色 */
|
||||
.dark\:hover\:bg-gray-600:is(.dark *):hover {
|
||||
background-color: var(--bg-gradient-end) !important;
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-gray-700:is(.dark *):hover {
|
||||
background-color: var(--bg-gradient-mid) !important;
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-gray-500:is(.dark *):hover {
|
||||
background-color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-700,
|
||||
.dark\:border-gray-700:is(.dark *) {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-600,
|
||||
.dark\:border-gray-600:is(.dark *) {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* 覆盖悬停边框色 */
|
||||
.dark\:hover\:border-gray-500:is(.dark *):hover {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* 优化后的transition - 避免布局跳动 */
|
||||
button,
|
||||
input,
|
||||
@@ -99,9 +163,9 @@ body::before {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -174,8 +238,8 @@ body::before {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -215,15 +279,15 @@ body::before {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@@ -275,7 +339,7 @@ body::before {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #1f2937;
|
||||
@@ -283,9 +347,9 @@ body::before {
|
||||
|
||||
.dark .form-input:focus {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(129, 140, 248, 0.2),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
background: var(--glass-strong-color);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@@ -332,19 +396,7 @@ body::before {
|
||||
}
|
||||
|
||||
.dark .stat-card {
|
||||
background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(135deg, var(--glass-strong-color) 0%, var(--bg-gradient-start) 100%);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
@@ -354,10 +406,6 @@ body::before {
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -407,15 +455,15 @@ body::before {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@@ -466,7 +514,7 @@ body::before {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #1f2937;
|
||||
@@ -474,9 +522,9 @@ body::before {
|
||||
|
||||
.dark .form-input:focus {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(129, 140, 248, 0.2),
|
||||
0 0 0 3px rgba(var(--primary-rgb), 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
background: var(--glass-strong-color);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@@ -527,8 +575,8 @@ body::before {
|
||||
}
|
||||
|
||||
.dark .modal-content {
|
||||
background: #1f2937;
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
background: var(--bg-gradient-start);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
@@ -633,11 +681,11 @@ body::before {
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: rgba(129, 140, 248, 0.3) rgba(129, 140, 248, 0.05);
|
||||
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
@@ -646,38 +694,62 @@ body::before {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(129, 140, 248, 0.05);
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.4) 0%,
|
||||
rgba(var(--secondary-rgb), 0.4) 100%
|
||||
);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.4) 0%, rgba(167, 139, 250, 0.4) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.4) 0%,
|
||||
rgba(var(--secondary-rgb), 0.4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.6) 0%,
|
||||
rgba(var(--secondary-rgb), 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.6) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.6) 0%,
|
||||
rgba(var(--secondary-rgb), 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.8) 0%,
|
||||
rgba(var(--secondary-rgb), 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.8) 0%, rgba(167, 139, 250, 0.8) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.8) 0%,
|
||||
rgba(var(--secondary-rgb), 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
@@ -690,8 +762,8 @@ body::before {
|
||||
@media (max-width: 768px) {
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
|
||||
@@ -36,9 +36,9 @@ body::before {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ h6 {
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
@@ -89,22 +89,34 @@ h6 {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.4) 0%,
|
||||
rgba(var(--secondary-rgb), 0.4) 100%
|
||||
);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.6) 0%,
|
||||
rgba(var(--secondary-rgb), 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.8) 0%,
|
||||
rgba(var(--secondary-rgb), 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Vue过渡动画 */
|
||||
@@ -137,8 +149,8 @@ h6 {
|
||||
@media (max-width: 768px) {
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
|
||||
@@ -125,8 +125,13 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
import {
|
||||
getAccountBalanceScriptApi,
|
||||
updateAccountBalanceScriptApi,
|
||||
testAccountBalanceScriptApi
|
||||
} from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -200,55 +205,41 @@ const resetForm = () => {
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||
)
|
||||
const res = await getAccountBalanceScriptApi(props.account.id, props.account.platform)
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载脚本配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
saving.value = true
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
const res = await updateAccountBalanceScriptApi(props.account.id, props.account.platform, {
|
||||
...form
|
||||
})
|
||||
if (res?.success) {
|
||||
showToast('已保存', 'success')
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
showToast(error.message || '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
} else {
|
||||
showToast(res?.message || '保存失败', 'error')
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
const res = await testAccountBalanceScriptApi(props.account.id, props.account.platform, {
|
||||
...form
|
||||
})
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '测试失败', 'error')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
|
||||
@@ -3978,10 +3978,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
@@ -3998,7 +3998,28 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'success', 'platform-changed'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
|
||||
// 确认弹窗状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmOptions = ref({ title: '', message: '', confirmText: '继续', cancelText: '取消' })
|
||||
let confirmResolve = null
|
||||
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmOptions.value = { title, message, confirmText, cancelText }
|
||||
confirmResolve = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve?.(true)
|
||||
confirmResolve = null
|
||||
}
|
||||
const handleCancel = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve?.(false)
|
||||
confirmResolve = null
|
||||
}
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!props.account)
|
||||
@@ -4287,20 +4308,20 @@ const allowedModels = ref([
|
||||
'claude-3-5-haiku-20241022'
|
||||
]) // 白名单模式下选中的模型列表
|
||||
|
||||
// 常用模型列表
|
||||
const commonModels = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', color: 'blue' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' },
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', color: 'emerald' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' },
|
||||
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' },
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' },
|
||||
{ value: 'Qwen', label: 'Qwen', color: 'orange' },
|
||||
{ value: 'Kimi', label: 'Kimi', color: 'pink' },
|
||||
{ value: 'GLM', label: 'GLM', color: 'teal' }
|
||||
]
|
||||
// 常用模型列表(从 API 获取)
|
||||
const commonModels = ref([])
|
||||
|
||||
// 加载模型列表
|
||||
const loadCommonModels = async () => {
|
||||
try {
|
||||
const result = await httpApis.getModelsApi()
|
||||
if (result.success && result.data?.all) {
|
||||
commonModels.value = result.data.all
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型映射表数据
|
||||
const modelMappings = ref([])
|
||||
@@ -4508,7 +4529,7 @@ const loadAccountUsage = async () => {
|
||||
if (!isEdit.value || !props.account?.id) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
|
||||
const response = await httpApis.getClaudeConsoleAccountUsageApi(props.account.id)
|
||||
if (response) {
|
||||
// 更新表单中的使用量数据
|
||||
form.value.dailyUsage = response.dailyUsage || 0
|
||||
@@ -5960,7 +5981,7 @@ const filteredGroups = computed(() => {
|
||||
const loadGroups = async () => {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
const response = await httpApis.getAccountGroupsApi()
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
@@ -6413,7 +6434,7 @@ watch(
|
||||
// 否则查找账户所属的分组
|
||||
const checkPromises = groups.value.map(async (group) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/account-groups/${group.id}/members`)
|
||||
const response = await httpApis.getAccountGroupMembersApi(group.id)
|
||||
const members = response.data || []
|
||||
if (members.some((m) => m.id === newAccount.id)) {
|
||||
foundGroupIds.push(group.id)
|
||||
@@ -6441,7 +6462,7 @@ watch(
|
||||
// 获取统一 User-Agent 信息
|
||||
const fetchUnifiedUserAgent = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/claude-code-version')
|
||||
const response = await httpApis.getClaudeCodeVersionApi()
|
||||
if (response.success && response.userAgent) {
|
||||
unifiedUserAgent.value = response.userAgent
|
||||
} else {
|
||||
@@ -6457,7 +6478,7 @@ const fetchUnifiedUserAgent = async () => {
|
||||
const clearUnifiedCache = async () => {
|
||||
clearingCache.value = true
|
||||
try {
|
||||
const response = await apiClient.post('/admin/claude-code-version/clear')
|
||||
const response = await httpApis.clearClaudeCodeVersionApi()
|
||||
if (response.success) {
|
||||
unifiedUserAgent.value = ''
|
||||
showToast('统一User-Agent缓存已清除', 'success')
|
||||
@@ -6563,6 +6584,9 @@ onMounted(() => {
|
||||
initModelMappings()
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
loadCommonModels()
|
||||
|
||||
// 获取Claude Code统一User-Agent信息
|
||||
fetchUnifiedUserAgent()
|
||||
// 如果是编辑模式且是Claude Console账户,加载使用情况
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user