Compare commits

...

42 Commits

Author SHA1 Message Date
github-actions[bot]
2180c42b84 chore: sync VERSION file with release v1.1.263 [skip ci] 2026-01-22 13:57:22 +00:00
Wesley Liddick
0883bb6b39 Merge pull request #859 from SunSeekerX/feat/optimize
feat: 大规模性能优化 - Redis Pipeline 批量操作、索引系统、连接池优化
2026-01-22 21:57:07 +08:00
SunSeekerX
ea6d1f1b36 1 2026-01-22 17:07:52 +08:00
SunSeekerX
4367fa47da 1 2026-01-22 17:02:00 +08:00
root
55c876fad5 fix: unify weekly cost key to usage:opus:*
- redis.getWeeklyOpusCost: read only usage:opus:weekly:* (remove claude fallback)
- weeklyClaudeCostInitService: write to usage:opus:weekly:* instead of claude

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:09:01 +08:00
root
f9df276d0c merge: resolve conflicts from main branch
- auth.js: keep 402 status code with Opus message
- redis.js: keep dual-cost tracking (rated/real) with opus key prefix, add setWeeklyOpusCost method
- apiKeyService.js: keep both imports, serviceRates handling, and 5-param recordOpusCost

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:27:52 +08:00
github-actions[bot]
9ebef1b116 chore: sync VERSION file with release v1.1.262 [skip ci] 2026-01-22 07:18:31 +00:00
Wesley Liddick
35f755246e Merge pull request #914 from sczheng189/main
mod: 修改opus周限额为Claude模型的周限额
2026-01-22 15:18:16 +08:00
root
83cbaf7c3e fix: resolve all ESLint errors
- droidRelayService: add missing keyId variable declaration
- quotaCardService: use object destructuring for actualDeducted
- apiKeyService: remove unused variables and duplicate requires
- redis: remove shadowed logger/config requires
- unifiedGeminiScheduler: rename isActive param to avoid shadow
- commonHelper: add comments to empty catch blocks
- testPayloadHelper: prefix unused model param with underscore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:14:22 +08:00
github-actions[bot]
338d44faee chore: sync VERSION file with release v1.1.261 [skip ci] 2026-01-22 07:08:02 +00:00
shaw
968398ffa5 fix: API Key permissions multi-select save and display issue
- Fix updateApiKey to use JSON.stringify for permissions field
- Add comma-separated string handling in normalizePermissions
- Add frontend parsing for comma-separated permissions format

Fixes issue where selecting multiple permissions (e.g. Claude + OpenAI)
would be saved as "claude,openai" instead of '["claude","openai"]'
2026-01-22 15:07:19 +08:00
shaw
645ab43675 chore: sync latest Claude Code system prompt definitions
Add claudeOtherSystemPrompt5 for CLI billing header detection
2026-01-22 15:07:10 +08:00
root
24f825f60d style: format all files with prettier
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:05:58 +08:00
root
ac7d28f9ce style: format quotaCardService.js with prettier
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:04:55 +08:00
sczheng
1027a2e3e2 mod: 修改opus周限额为Claude模型的周限额 2026-01-22 15:04:34 +08:00
SunSeekerX
cb935ea0f0 1 2026-01-22 14:56:09 +08:00
SunSeekerX
73a241df1a Merge branch 'main' into feat/optimize 2026-01-21 11:56:02 +08:00
SunSeekerX
029bdf3719 1 2026-01-21 11:55:28 +08:00
github-actions[bot]
0f5321b0ef chore: sync VERSION file with release v1.1.260 [skip ci] 2026-01-21 02:19:34 +00:00
shaw
c7d7bf47d6 fix: 更新claude账号oauth链接生成规则 2026-01-21 10:06:24 +08:00
Wesley Liddick
ebc30b6026 Merge pull request #906 from 0xRichardH/fix-bedrock-sse-stream-event [skip ci]
Fix bedrock sse stream event
2026-01-21 09:38:19 +08:00
Wesley Liddick
d5a7af2d7d Merge pull request #903 from RedwindA/main [skip ci]
feat(droid): add prompt_cache_retention and safety_identifier to fiel…
2026-01-21 09:37:19 +08:00
SunSeekerX
76ecbe18a5 1 2026-01-19 20:24:47 +08:00
Richard Hao
81a3e26e27 fix: correct Bedrock SSE stream event format to match Claude API spec
- message_start: nest fields inside 'message' object with type: 'message'
- content_block_delta: add type field to data
- message_delta: add type field to data
- message_stop: remove usage field, just return type
- Extract usage from message_delta instead of message_stop
2026-01-18 11:38:38 +08:00
Richard Hao
64db4a270d fix: handle bedrock content block start/stop events 2026-01-18 10:58:11 +08:00
RedwindA
ca027ecb90 feat(droid): add prompt_cache_retention and safety_identifier to fieldsToRemove 2026-01-16 04:22:05 +08:00
github-actions[bot]
21e6944abb chore: sync VERSION file with release v1.1.259 [skip ci] 2026-01-15 03:07:53 +00:00
Wesley Liddick
4ea3d4830f Merge pull request #858 from zengqinglei/feature/gemini-retrieve-user-quota
feat: 添加 Gemini retrieveUserQuota 接口支持
2026-01-15 11:07:41 +08:00
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +08:00
曾庆雷
944ef096b3 fix: eslint 代码风格优化 2026-01-08 18:26:45 +08:00
SunSeekerX
12fd5e1cb4 Merge branch 'main' into feat/optimize 2026-01-04 12:06:29 +08:00
SunSeekerX
f5e982632d chore 2026-01-04 12:05:53 +08:00
SunSeekerX
90023d1551 Revert: 撤销 584fa8c 之后的所有提交 2026-01-03 23:24:59 +08:00
SunSeekerX
74e71d0afc chore 2026-01-03 23:20:05 +08:00
SunSeekerX
d8a33f9aa7 chore 2026-01-01 12:42:47 +08:00
SunSeekerX
666b0120b7 chore 2025-12-31 02:28:51 +08:00
SunSeekerX
fba18000e5 chore 2025-12-31 02:24:27 +08:00
SunSeekerX
b4233033a6 chore 2025-12-31 02:17:10 +08:00
SunSeekerX
584fa8c9c1 feat: 大规模性能优化 - Redis Pipeline 批量操作、索引系统、连接池优化 2025-12-31 02:08:47 +08:00
曾庆雷
18a493e805 feat: 添加 Gemini retrieveUserQuota 接口支持
支持 Gemini CLI 0.22.2+ 的配额查询功能
实现与现有 v1internal 接口一致的 projectId 处理逻辑
2025-12-24 22:48:27 +08:00
190 changed files with 18367 additions and 8876 deletions

View File

@@ -114,6 +114,16 @@ PROXY_USE_IPV4=true
# ⏱️ 请求超时配置 # ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟 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 REQUEST_MAX_SIZE_MB=60

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -1 +1 @@
1.1.257 1.1.263

View File

@@ -103,7 +103,7 @@ program
try { try {
const [, apiKeys, accounts] = await Promise.all([ const [, apiKeys, accounts] = await Promise.all([
redis.getSystemStats(), redis.getSystemStats(),
apiKeyService.getAllApiKeys(), apiKeyService.getAllApiKeysFast(),
claudeAccountService.getAllAccounts() claudeAccountService.getAllAccounts()
]) ])
@@ -284,7 +284,7 @@ async function listApiKeys() {
const spinner = ora('正在获取 API Keys...').start() const spinner = ora('正在获取 API Keys...').start()
try { try {
const apiKeys = await apiKeyService.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`) spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
if (apiKeys.length === 0) { if (apiKeys.length === 0) {
@@ -314,7 +314,7 @@ async function listApiKeys() {
tableData.push([ tableData.push([
key.name, key.name,
key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-', key.maskedKey || '-',
key.isActive ? '🟢 活跃' : '🔴 停用', key.isActive ? '🟢 活跃' : '🔴 停用',
expiryStatus, expiryStatus,
`${(key.usage?.total?.tokens || 0).toLocaleString()}`, `${(key.usage?.total?.tokens || 0).toLocaleString()}`,
@@ -333,7 +333,7 @@ async function listApiKeys() {
async function updateApiKeyExpiry() { async function updateApiKeyExpiry() {
try { try {
// 获取所有 API Keys // 获取所有 API Keys
const apiKeys = await apiKeyService.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
if (apiKeys.length === 0) { if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys')) console.log(styles.warning('没有找到任何 API Keys'))
@@ -347,7 +347,7 @@ async function updateApiKeyExpiry() {
name: 'selectedKey', name: 'selectedKey',
message: '选择要修改的 API Key:', message: '选择要修改的 API Key:',
choices: apiKeys.map((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 value: key
})) }))
} }
@@ -463,7 +463,7 @@ async function renewApiKeys() {
const spinner = ora('正在查找即将过期的 API Keys...').start() const spinner = ora('正在查找即将过期的 API Keys...').start()
try { try {
const apiKeys = await apiKeyService.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
const now = new Date() const now = new Date()
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
@@ -562,7 +562,7 @@ async function renewApiKeys() {
async function deleteApiKey() { async function deleteApiKey() {
try { try {
const apiKeys = await apiKeyService.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
if (apiKeys.length === 0) { if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Keys')) console.log(styles.warning('没有找到任何 API Keys'))
@@ -575,7 +575,7 @@ async function deleteApiKey() {
name: 'selectedKeys', name: 'selectedKeys',
message: '选择要删除的 API Keys (空格选择,回车确认):', message: '选择要删除的 API Keys (空格选择,回车确认):',
choices: apiKeys.map((key) => ({ 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 value: key.id
})) }))
} }

View File

@@ -123,7 +123,8 @@ const config = {
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟 healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8中国时区 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界面配置 // 🎨 Web界面配置
@@ -220,6 +221,13 @@ const config = {
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒) delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待 timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL毫秒5秒足以覆盖请求发送 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
View 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]
}

View File

@@ -2,7 +2,8 @@ const repository =
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service' process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror' const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json' 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 const baseUrl = process.env.PRICE_MIRROR_BASE_URL
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '') ? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
@@ -11,7 +12,6 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
module.exports = { module.exports = {
pricingFileName, pricingFileName,
hashFileName, hashFileName,
pricingUrl: pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}` hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
} }

20
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6", "inquirer": "^8.2.6",
@@ -5399,19 +5398,6 @@
"node": ">= 0.4" "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": { "node_modules/helmet": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
@@ -7027,12 +7013,6 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC" "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": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",

View File

@@ -59,7 +59,6 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6", "inquirer": "^8.2.6",

View File

@@ -152,62 +152,110 @@ async function exportUsageStats(keyId) {
daily: {}, daily: {},
monthly: {}, monthly: {},
hourly: {}, hourly: {},
models: {} models: {},
// 费用统计String 类型)
costTotal: null,
costDaily: {},
costMonthly: {},
costHourly: {},
opusTotal: null,
opusWeekly: {}
} }
// 导出总统计 // 导出总统计Hash
const totalKey = `usage:${keyId}` const totalData = await redis.client.hgetall(`usage:${keyId}`)
const totalData = await redis.client.hgetall(totalKey)
if (totalData && Object.keys(totalData).length > 0) { if (totalData && Object.keys(totalData).length > 0) {
stats.total = totalData stats.total = totalData
} }
// 导出每日统计最近30天 // 导出费用总统计String
const today = new Date() const costTotal = await redis.client.get(`usage:cost:total:${keyId}`)
for (let i = 0; i < 30; i++) { if (costTotal) {
const date = new Date(today) stats.costTotal = costTotal
date.setDate(date.getDate() - i) }
const dateStr = date.toISOString().split('T')[0]
const dailyKey = `usage:daily:${keyId}:${dateStr}`
const dailyData = await redis.client.hgetall(dailyKey) // 导出 Opus 费用总统计String
if (dailyData && Object.keys(dailyData).length > 0) { const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`)
stats.daily[dateStr] = dailyData 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个月 // 导出每日费用(扫描现有 key
for (let i = 0; i < 12; i++) { const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`)
const date = new Date(today) for (const key of costDailyKeys) {
date.setMonth(date.getMonth() - i) const date = key.split(':').pop()
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` const value = await redis.client.get(key)
const monthlyKey = `usage:monthly:${keyId}:${monthStr}` if (value) {
stats.costDaily[date] = value
const monthlyData = await redis.client.hgetall(monthlyKey)
if (monthlyData && Object.keys(monthlyData).length > 0) {
stats.monthly[monthStr] = monthlyData
} }
} }
// 导出小时统计(最近24小时 // 导出每月统计(扫描现有 key
for (let i = 0; i < 24; i++) { const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`)
const date = new Date(today) for (const key of monthlyKeys) {
date.setHours(date.getHours() - i) const month = key.split(':').pop()
const dateStr = date.toISOString().split('T')[0] const data = await redis.client.hgetall(key)
const hour = String(date.getHours()).padStart(2, '0') if (data && Object.keys(data).length > 0) {
const hourKey = `${dateStr}:${hour}` stats.monthly[month] = data
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 costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`)
const modelDailyPattern = `usage:${keyId}:model:daily:*` for (const key of costMonthlyKeys) {
const modelDailyKeys = await redis.client.keys(modelDailyPattern) 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) { for (const key of modelDailyKeys) {
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) { if (match) {
@@ -223,9 +271,8 @@ async function exportUsageStats(keyId) {
} }
} }
// 每月模型统计 // 导出模型统计(每月)
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*` const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`)
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
for (const key of modelMonthlyKeys) { for (const key of modelMonthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (match) { if (match) {
@@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) {
const pipeline = redis.client.pipeline() const pipeline = redis.client.pipeline()
let importCount = 0 let importCount = 0
// 导入总统计 // 导入总统计Hash
if (stats.total && Object.keys(stats.total).length > 0) { if (stats.total && Object.keys(stats.total).length > 0) {
for (const [field, value] of Object.entries(stats.total)) { for (const [field, value] of Object.entries(stats.total)) {
pipeline.hset(`usage:${keyId}`, field, value) pipeline.hset(`usage:${keyId}`, field, value)
@@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) {
importCount++ 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) { if (stats.daily) {
for (const [date, data] of Object.entries(stats.daily)) { for (const [date, data] of Object.entries(stats.daily)) {
for (const [field, value] of Object.entries(data)) { 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) { if (stats.monthly) {
for (const [month, data] of Object.entries(stats.monthly)) { for (const [month, data] of Object.entries(stats.monthly)) {
for (const [field, value] of Object.entries(data)) { 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) { if (stats.hourly) {
for (const [hour, data] of Object.entries(stats.hourly)) { for (const [hour, data] of Object.entries(stats.hourly)) {
for (const [field, value] of Object.entries(data)) { 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) { if (stats.models) {
for (const [model, modelStats] of Object.entries(stats.models)) { for (const [model, modelStats] of Object.entries(stats.models)) {
// 每日模型统计
if (modelStats.daily) { if (modelStats.daily) {
for (const [date, data] of Object.entries(modelStats.daily)) { for (const [date, data] of Object.entries(modelStats.daily)) {
for (const [field, value] of Object.entries(data)) { for (const [field, value] of Object.entries(data)) {
@@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) {
} }
} }
// 每月模型统计
if (modelStats.monthly) { if (modelStats.monthly) {
for (const [month, data] of Object.entries(modelStats.monthly)) { for (const [month, data] of Object.entries(modelStats.monthly)) {
for (const [field, value] of Object.entries(data)) { for (const [field, value] of Object.entries(data)) {
@@ -547,13 +636,54 @@ async function exportData() {
const globalStats = { const globalStats = {
daily: {}, daily: {},
monthly: {}, 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 monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
const globalDailyKeys = await redis.client.keys(globalDailyPattern) 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) { 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})$/) const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) { if (match) {
const model = match[1] const model = match[1]
@@ -569,9 +699,9 @@ async function exportData() {
} }
// 导出全局每月模型统计 // 导出全局每月模型统计
const globalMonthlyPattern = 'usage:model:monthly:*' const modelMonthlyPattern = 'usage:model:monthly:*'
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern) const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
for (const key of globalMonthlyKeys) { for (const key of modelMonthlyKeys) {
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
if (match) { if (match) {
const model = match[1] const model = match[1]
@@ -1040,6 +1170,46 @@ async function importData() {
const pipeline = redis.client.pipeline() const pipeline = redis.client.pipeline()
let globalStatCount = 0 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) { if (globalStats.daily) {
for (const [date, models] of Object.entries(globalStats.daily)) { for (const [date, models] of Object.entries(globalStats.daily)) {
@@ -1061,6 +1231,8 @@ async function importData() {
} }
globalStatCount++ globalStatCount++
} }
// 同时更新月份索引(兼容旧格式导出文件)
pipeline.sadd('usage:model:monthly:months', month)
} }
} }

View File

@@ -141,7 +141,7 @@ async function cleanTestData() {
logger.info('🧹 Cleaning test data...') logger.info('🧹 Cleaning test data...')
// 获取所有API Keys // 获取所有API Keys
const allKeys = await apiKeyService.getAllApiKeys() const allKeys = await apiKeyService.getAllApiKeysFast()
// 找出所有测试 API Keys // 找出所有测试 API Keys
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key')) const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))

View File

@@ -12,6 +12,7 @@
*/ */
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const apiKeyService = require('../src/services/apiKeyService')
const logger = require('../src/utils/logger') const logger = require('../src/utils/logger')
const readline = require('readline') const readline = require('readline')
@@ -51,7 +52,7 @@ async function migrateApiKeys() {
logger.success('✅ Connected to Redis') logger.success('✅ Connected to Redis')
// 获取所有 API Keys // 获取所有 API Keys
const apiKeys = await redis.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
logger.info(`📊 Found ${apiKeys.length} API Keys in total`) logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
// 统计信息 // 统计信息

View 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)
})

View File

@@ -11,6 +11,7 @@ const logger = require('./utils/logger')
const redis = require('./models/redis') const redis = require('./models/redis')
const pricingService = require('./services/pricingService') const pricingService = require('./services/pricingService')
const cacheMonitor = require('./utils/cacheMonitor') const cacheMonitor = require('./utils/cacheMonitor')
const { getSafeMessage } = require('./utils/errorSanitizer')
// Import routes // Import routes
const apiRoutes = require('./routes/api') const apiRoutes = require('./routes/api')
@@ -50,7 +51,38 @@ class Application {
// 🔗 连接Redis // 🔗 连接Redis
logger.info('🔄 Connecting to Redis...') logger.info('🔄 Connecting to Redis...')
await redis.connect() 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 注册) // 💳 初始化账户余额查询服务Provider 注册)
try { 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账户会话窗口 // 🕐 初始化Claude账户会话窗口
logger.info('🕐 Initializing Claude account session windows...') logger.info('🕐 Initializing Claude account session windows...')
const claudeAccountService = require('./services/claudeAccountService') const claudeAccountService = require('./services/claudeAccountService')
@@ -104,6 +145,18 @@ class Application {
const costRankService = require('./services/costRankService') const costRankService = require('./services/costRankService')
await costRankService.initialize() 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/ 请求 - 在所有中间件之前 // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
if (req.path === '/admin-next/' && req.method === 'GET') { 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 }) logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
res.status(503).json({ res.status(503).json({
status: 'unhealthy', status: 'unhealthy',
error: error.message, error: getSafeMessage(error),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}) })
} }
@@ -413,7 +466,7 @@ class Application {
// 🚨 错误处理 // 🚨 错误处理
this.app.use(errorHandler) this.app.use(errorHandler)
logger.success('Application initialized successfully') logger.success('Application initialized successfully')
} catch (error) { } catch (error) {
logger.error('💥 Application initialization failed:', error) logger.error('💥 Application initialization failed:', error)
throw error throw error
@@ -448,7 +501,7 @@ class Application {
await redis.setSession('admin_credentials', adminCredentials) 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}`) logger.info(`📋 Admin username: ${adminCredentials.username}`)
} catch (error) { } catch (error) {
logger.error('❌ Failed to initialize admin credentials:', { logger.error('❌ Failed to initialize admin credentials:', {
@@ -465,22 +518,24 @@ class Application {
const client = redis.getClient() const client = redis.getClient()
// 获取所有 session:* 键 // 获取所有 session:* 键
const sessionKeys = await client.keys('session:*') const sessionKeys = await redis.scanKeys('session:*')
const dataList = await redis.batchHgetallChunked(sessionKeys)
let validCount = 0 let validCount = 0
let invalidCount = 0 let invalidCount = 0
for (const key of sessionKeys) { for (let i = 0; i < sessionKeys.length; i++) {
const key = sessionKeys[i]
// 跳过 admin_credentials系统凭据 // 跳过 admin_credentials系统凭据
if (key === 'session:admin_credentials') { if (key === 'session:admin_credentials') {
continue continue
} }
const sessionData = await client.hgetall(key) const sessionData = dataList[i]
// 检查会话完整性:必须有 username 和 loginTime // 检查会话完整性:必须有 username 和 loginTime
const hasUsername = !!sessionData.username const hasUsername = !!sessionData?.username
const hasLoginTime = !!sessionData.loginTime const hasLoginTime = !!sessionData?.loginTime
if (!hasUsername || !hasLoginTime) { if (!hasUsername || !hasLoginTime) {
// 无效会话 - 可能是漏洞利用创建的伪造会话 // 无效会话 - 可能是漏洞利用创建的伪造会话
@@ -495,11 +550,11 @@ class Application {
} }
if (invalidCount > 0) { if (invalidCount > 0) {
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`) logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`)
} }
logger.success( logger.success(
`Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed` `Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
) )
} catch (error) { } catch (error) {
// 清理失败不应阻止服务启动 // 清理失败不应阻止服务启动
@@ -549,9 +604,7 @@ class Application {
await this.initialize() await this.initialize()
this.server = this.app.listen(config.server.port, config.server.host, () => { this.server = this.app.listen(config.server.port, config.server.host, () => {
logger.start( logger.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`)
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
)
logger.info( logger.info(
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats` `🌐 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`) logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
}, 5000) }, 5000)
logger.success('Cache monitoring initialized') logger.success('Cache monitoring initialized')
} catch (error) { } catch (error) {
logger.error('❌ Failed to initialize cache monitoring:', error) logger.error('❌ Failed to initialize cache monitoring:', error)
// 不阻止应用启动 // 不阻止应用启动
@@ -655,7 +708,7 @@ class Application {
// 每分钟主动清理所有过期的并发项,不依赖请求触发 // 每分钟主动清理所有过期的并发项,不依赖请求触发
setInterval(async () => { setInterval(async () => {
try { try {
const keys = await redis.keys('concurrency:*') const keys = await redis.scanKeys('concurrency:*')
if (keys.length === 0) { if (keys.length === 0) {
return return
} }
@@ -837,9 +890,9 @@ class Application {
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏) // 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try { try {
logger.info('🔢 Cleaning up all concurrency counters...') logger.info('🔢 Cleaning up all concurrency counters...')
const keys = await redis.keys('concurrency:*') const keys = await redis.scanKeys('concurrency:*')
if (keys.length > 0) { if (keys.length > 0) {
await redis.client.del(...keys) await redis.batchDelChunked(keys)
logger.info(`✅ Cleaned ${keys.length} concurrency keys`) logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
} else { } else {
logger.info('✅ No concurrency keys to clean') logger.info('✅ No concurrency keys to clean')
@@ -856,7 +909,7 @@ class Application {
logger.error('❌ Error disconnecting Redis:', error) logger.error('❌ Error disconnecting Redis:', error)
} }
logger.success('Graceful shutdown completed') logger.success('Graceful shutdown completed')
process.exit(0) process.exit(0)
}) })

View File

@@ -14,9 +14,11 @@ const crypto = require('crypto')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const { parseSSELine } = require('../utils/sseParser') const { parseSSELine } = require('../utils/sseParser')
const axios = require('axios') const axios = require('axios')
const { getSafeMessage } = require('../utils/errorSanitizer')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
// ============================================================================ // ============================================================================
@@ -136,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
const { totalTokens, totalCost } = await updateRateLimitCounters( const { totalTokens, totalCost } = await updateRateLimitCounters(
req.rateLimitInfo, req.rateLimitInfo,
usageSummary, usageSummary,
model model,
req.apiKey?.id,
'gemini'
) )
if (totalTokens > 0) { if (totalTokens > 0) {
@@ -353,7 +357,7 @@ async function handleMessages(req, res) {
logger.error('Failed to select Gemini account:', error) logger.error('Failed to select Gemini account:', error)
return res.status(503).json({ return res.status(503).json({
error: { error: {
message: error.message || 'No available Gemini accounts', message: getSafeMessage(error) || 'No available Gemini accounts',
type: 'service_unavailable' type: 'service_unavailable'
} }
}) })
@@ -492,7 +496,8 @@ async function handleMessages(req, res) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
} }
} }
@@ -596,7 +601,8 @@ async function handleMessages(req, res) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
.then(() => { .then(() => {
logger.info( logger.info(
@@ -614,7 +620,7 @@ async function handleMessages(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Stream error', message: getSafeMessage(error) || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -662,7 +668,7 @@ async function handleMessages(req, res) {
const status = errorStatus || 500 const status = errorStatus || 500
const errorResponse = { const errorResponse = {
error: error.error || { error: error.error || {
message: error.message || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
} }
@@ -830,16 +836,18 @@ function handleModelDetails(req, res) {
*/ */
async function handleUsage(req, res) { async function handleUsage(req, res) {
try { try {
const { usage } = req.apiKey const keyData = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({ res.json({
object: 'usage', object: 'usage',
total_tokens: usage.total.tokens, total_tokens: usage?.total?.tokens || 0,
total_requests: usage.total.requests, total_requests: usage?.total?.requests || 0,
daily_tokens: usage.daily.tokens, daily_tokens: usage?.daily?.tokens || 0,
daily_requests: usage.daily.requests, daily_requests: usage?.daily?.requests || 0,
monthly_tokens: usage.monthly.tokens, monthly_tokens: usage?.monthly?.tokens || 0,
monthly_requests: usage.monthly.requests monthly_requests: usage?.monthly?.requests || 0
}) })
} catch (error) { } catch (error) {
logger.error('Failed to get usage stats:', error) logger.error('Failed to get usage stats:', error)
@@ -858,17 +866,18 @@ async function handleUsage(req, res) {
async function handleKeyInfo(req, res) { async function handleKeyInfo(req, res) {
try { try {
const keyData = req.apiKey const keyData = req.apiKey
// 按需查询 usage 数据(仅 key-info 端点需要)
const usage = await redis.getUsageStats(keyData.id)
const tokensUsed = usage?.total?.tokens || 0
res.json({ res.json({
id: keyData.id, id: keyData.id,
name: keyData.name, name: keyData.name,
permissions: keyData.permissions, permissions: keyData.permissions,
token_limit: keyData.tokenLimit, token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens, tokens_used: tokensUsed,
tokens_remaining: tokens_remaining:
keyData.tokenLimit > 0 keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
rate_limit: { rate_limit: {
window: keyData.rateLimitWindow, window: keyData.rateLimitWindow,
requests: keyData.rateLimitRequests requests: keyData.rateLimitRequests
@@ -1188,6 +1197,110 @@ async function handleOnboardUser(req, res) {
} }
} }
/**
* 处理 retrieveUserQuota 请求
* POST /v1internal:retrieveUserQuota
*
* 功能查询用户在各个Gemini模型上的配额使用情况
* 请求体:{ "project": "项目ID" }
* 响应:{ "buckets": [...] }
*/
async function handleRetrieveUserQuota(req, res) {
try {
// 1. 权限检查
if (!ensureGeminiPermission(req, res)) {
return undefined
}
// 2. 会话哈希
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 3. 账户选择
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
const { accountId, accountType } = schedulerResult
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
if (accountType === 'gemini-api') {
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
return res.status(400).json({
error: {
message:
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
type: 'invalid_account_type'
}
})
}
// 5. 获取账户
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: {
message: 'Gemini account not found',
type: 'account_not_found'
}
})
}
const { accessToken, refreshToken, projectId } = account
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject"
const requestProject = req.body.project
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`RetrieveUserQuota request (${version})`, {
requestedProject: requestProject || null,
accountProject: projectId || null,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 7. 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
// 8. 获取OAuth客户端
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 9. 智能处理项目ID与其他 v1internal 接口保持一致)
const effectiveProject = projectId || requestProject || null
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
accountProjectId: projectId,
requestProject,
effectiveProject,
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
})
// 10. 构建请求体(注入 effectiveProject
const requestBody = { ...req.body }
if (effectiveProject) {
requestBody.project = effectiveProject
}
// 11. 调用底层服务转发请求
const response = await geminiAccountService.forwardToCodeAssist(
client,
'retrieveUserQuota',
requestBody,
proxyConfig
)
res.json(response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
error: error.message
})
res.status(500).json({
error: 'Internal server error',
message: error.message
})
}
}
/** /**
* 处理 countTokens 请求 * 处理 countTokens 请求
*/ */
@@ -1304,7 +1417,7 @@ async function handleCountTokens(req, res) {
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }) logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1490,7 +1603,8 @@ async function handleGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
account.id account.id,
'gemini'
) )
logger.info( logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
@@ -1526,7 +1640,7 @@ async function handleGenerateContent(req, res) {
}) })
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1810,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
account.id account.id,
'gemini'
), ),
applyRateLimitTracking( applyRateLimitTracking(
req, req,
@@ -1847,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Stream error', message: getSafeMessage(error) || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1857,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) {
res.write( res.write(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
error: { error: {
message: error.message || 'Stream error', message: getSafeMessage(error) || 'Stream error',
type: 'stream_error', type: 'stream_error',
code: error.code code: error.code
} }
@@ -1886,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2147,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
logger.info( logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
@@ -2169,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2576,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
.then(() => { .then(() => {
logger.info( logger.info(
@@ -2604,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Stream error', message: getSafeMessage(error) || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2614,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) {
res.write( res.write(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
error: { error: {
message: error.message || 'Stream error', message: getSafeMessage(error) || 'Stream error',
type: 'stream_error', type: 'stream_error',
code: error.code code: error.code
} }
@@ -2698,6 +2815,7 @@ module.exports = {
handleSimpleEndpoint, handleSimpleEndpoint,
handleLoadCodeAssist, handleLoadCodeAssist,
handleOnboardUser, handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens, handleCountTokens,
handleGenerateContent, handleGenerateContent,
handleStreamGenerateContent, handleStreamGenerateContent,

View File

@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper') const { calculateWaitTimeStats } = require('../utils/statsHelper')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
// 工具函数 // 工具函数
function sleep(ms) { function sleep(ms) {
@@ -451,7 +452,7 @@ const authenticateApiKey = async (req, res, next) => {
} }
if (!apiKey) { 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({ return res.status(401).json({
error: 'Missing API key', error: 'Missing API key',
message: message:
@@ -461,7 +462,7 @@ const authenticateApiKey = async (req, res, next) => {
// 基本API Key格式验证 // 基本API Key格式验证
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { 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({ return res.status(401).json({
error: 'Invalid API key format', error: 'Invalid API key format',
message: 'API key format is invalid' message: 'API key format is invalid'
@@ -473,7 +474,7 @@ const authenticateApiKey = async (req, res, next) => {
if (!validation.valid) { if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' 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({ return res.status(401).json({
error: 'Invalid API key', error: 'Invalid API key',
message: validation.error message: validation.error
@@ -1195,12 +1196,16 @@ const authenticateApiKey = async (req, res, next) => {
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
) )
return res.status(429).json({ // 使用 402 Payment Required 而非 429避免客户端自动重试
error: 'Daily cost limit exceeded', return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到每日费用限制 ($${dailyCostLimit})`, message: `已达到每日费用限制 ($${dailyCostLimit})`,
code: 'daily_cost_limit_exceeded'
},
currentCost: dailyCost, currentCost: dailyCost,
costLimit: dailyCostLimit, 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}` }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
) )
return res.status(429).json({ // 使用 402 Payment Required 而非 429避免客户端自动重试
error: 'Total cost limit exceeded', return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到总费用限制 ($${totalCostLimit})`, message: `已达到总费用限制 ($${totalCostLimit})`,
code: 'total_cost_limit_exceeded'
},
currentCost: totalCost, currentCost: totalCost,
costLimit: totalCostLimit costLimit: totalCostLimit
}) })
@@ -1239,20 +1248,20 @@ const authenticateApiKey = async (req, res, next) => {
) )
} }
// 检查 Opus 周费用限制(仅对 Opus 模型生效) // 检查 Claude 周费用限制
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) { if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息 // 从请求中获取模型信息
const requestBody = req.body || {} const requestBody = req.body || {}
const model = requestBody.model || '' const model = requestBody.model || ''
// 判断是否为 Opus 模型 // 判断是否为 Claude 模型
if (model && model.toLowerCase().includes('claude-opus')) { if (isClaudeFamilyModel(model)) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) { if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security( 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 validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
) )
@@ -1265,18 +1274,22 @@ const authenticateApiKey = async (req, res, next) => {
resetDate.setDate(now.getDate() + daysUntilMonday) resetDate.setDate(now.getDate() + daysUntilMonday)
resetDate.setHours(0, 0, 0, 0) resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({ // 使用 402 Payment Required 而非 429避免客户端自动重试
error: 'Weekly Opus cost limit exceeded', return res.status(402).json({
error: {
type: 'insufficient_quota',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
code: 'weekly_opus_cost_limit_exceeded'
},
currentCost: weeklyOpusCost, currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit, costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置 resetAt: resetDate.toISOString()
}) })
} }
// 记录当前 Opus 费用使用情况 // 记录当前 Claude 费用使用情况
logger.api( logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${ `💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
) )
@@ -1306,10 +1319,8 @@ const authenticateApiKey = async (req, res, next) => {
dailyCostLimit: validation.keyData.dailyCostLimit, dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost, dailyCost: validation.keyData.dailyCost,
totalCostLimit: validation.keyData.totalCostLimit, totalCostLimit: validation.keyData.totalCostLimit,
totalCost: validation.keyData.totalCost, totalCost: validation.keyData.totalCost
usage: validation.keyData.usage
} }
req.usage = validation.keyData.usage
const authDuration = Date.now() - startTime const authDuration = Date.now() - startTime
const userAgent = req.headers['user-agent'] || 'No User-Agent' const userAgent = req.headers['user-agent'] || 'No User-Agent'
@@ -1357,7 +1368,7 @@ const authenticateAdmin = async (req, res, next) => {
req.headers['x-admin-token'] req.headers['x-admin-token']
if (!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({ return res.status(401).json({
error: 'Missing admin token', error: 'Missing admin token',
message: 'Please provide an admin token' message: 'Please provide an admin token'
@@ -1366,7 +1377,7 @@ const authenticateAdmin = async (req, res, next) => {
// 基本token格式验证 // 基本token格式验证
if (typeof token !== 'string' || token.length < 32 || token.length > 512) { 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({ return res.status(401).json({
error: 'Invalid admin token format', error: 'Invalid admin token format',
message: 'Admin token format is invalid' message: 'Admin token format is invalid'
@@ -1382,7 +1393,7 @@ const authenticateAdmin = async (req, res, next) => {
]) ])
if (!adminSession || Object.keys(adminSession).length === 0) { 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({ return res.status(401).json({
error: 'Invalid admin token', error: 'Invalid admin token',
message: 'Invalid or expired admin session' message: 'Invalid or expired admin session'
@@ -1440,7 +1451,7 @@ const authenticateAdmin = async (req, res, next) => {
} }
const authDuration = Date.now() - startTime 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() return next()
} catch (error) { } catch (error) {
@@ -1471,7 +1482,7 @@ const authenticateUser = async (req, res, next) => {
req.headers['x-user-token'] req.headers['x-user-token']
if (!sessionToken) { 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({ return res.status(401).json({
error: 'Missing user session token', error: 'Missing user session token',
message: 'Please login to access this resource' message: 'Please login to access this resource'
@@ -1480,7 +1491,7 @@ const authenticateUser = async (req, res, next) => {
// 基本token格式验证 // 基本token格式验证
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { 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({ return res.status(401).json({
error: 'Invalid session token format', error: 'Invalid session token format',
message: 'Session token format is invalid' message: 'Session token format is invalid'
@@ -1491,7 +1502,7 @@ const authenticateUser = async (req, res, next) => {
const sessionValidation = await userService.validateUserSession(sessionToken) const sessionValidation = await userService.validateUserSession(sessionToken)
if (!sessionValidation) { 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({ return res.status(401).json({
error: 'Invalid session token', error: 'Invalid session token',
message: 'Invalid or expired user session' message: 'Invalid or expired user session'
@@ -1582,7 +1593,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
req.userType = 'admin' req.userType = 'admin'
const authDuration = Date.now() - startTime 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() 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({ return res.status(401).json({
error: 'Authentication required', error: 'Authentication required',
message: 'Please login as user or admin to access this resource' message: 'Please login as user or admin to access this resource'

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,27 @@ function validatePermissions(permissions) {
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}` 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分配)
// 获取所有用户列表用于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 costStats = await redis.getCostStats(keyId)
const dailyCost = await redis.getDailyCost(keyId) const dailyCost = await redis.getDailyCost(keyId)
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const client = redis.getClientSafe()
// 获取所有相关的Redis键 // 获取所有相关的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 = {} const keyValues = {}
for (const key of costKeys) { for (let i = 0; i < costKeys.length; i++) {
keyValues[key] = await client.get(key) keyValues[costKeys[i]] = costValues[i]
} }
return res.json({ 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) { for (const apiKey of result.items) {
if (apiKey.userId) { if (apiKey.userId && userMap.has(apiKey.userId)) {
try { const user = userMap.get(apiKey.userId)
const user = await userService.getUserById(apiKey.userId, false)
if (user) {
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
} else { } else if (apiKey.userId) {
apiKey.ownerDisplayName = 'Unknown User' apiKey.ownerDisplayName = 'Unknown User'
}
} catch (error) {
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
apiKey.ownerDisplayName = 'Unknown User'
}
} else { } else {
apiKey.ownerDisplayName = apiKey.ownerDisplayName =
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' 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) => { router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
try { try {
@@ -673,22 +752,7 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
// 获取已存在的标签列表 // 获取已存在的标签列表
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => { router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
try { try {
const apiKeys = await apiKeyService.getAllApiKeys() const tags = await apiKeyService.getAllTags()
const tagSet = new Set()
// 收集所有API Keys的标签
for (const apiKey of apiKeys) {
if (apiKey.tags && Array.isArray(apiKey.tags)) {
apiKey.tags.forEach((tag) => {
if (tag && tag.trim()) {
tagSet.add(tag.trim())
}
})
}
}
// 转换为数组并排序
const tags = Array.from(tagSet).sort()
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`) logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
return res.json({ success: true, data: tags }) 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 数量统计 * 获取账户绑定的 API Key 数量统计
* GET /admin/accounts/binding-counts * GET /admin/accounts/binding-counts
@@ -1298,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
activationDays, // 新增:激活后有效天数 activationDays, // 新增:激活后有效天数
activationUnit, // 新增:激活时间单位 (hours/days) activationUnit, // 新增:激活时间单位 (hours/days)
expirationMode, // 新增:过期模式 expirationMode, // 新增:过期模式
icon // 新增:图标 icon, // 新增:图标
serviceRates // API Key 级别服务倍率
} = req.body } = req.body
// 输入验证 // 输入验证
@@ -1425,6 +1577,12 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: permissionsError }) 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({ const newKey = await apiKeyService.generateApiKey({
name, name,
description, description,
@@ -1452,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
activationDays, activationDays,
activationUnit, activationUnit,
expirationMode, expirationMode,
icon icon,
serviceRates
}) })
logger.success(`🔑 Admin created new API key: ${name}`) logger.success(`🔑 Admin created new API key: ${name}`)
@@ -1494,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays, activationDays,
activationUnit, activationUnit,
expirationMode, expirationMode,
icon icon,
serviceRates
} = req.body } = req.body
// 输入验证 // 输入验证
@@ -1518,6 +1678,12 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: batchPermissionsError }) return res.status(400).json({ error: batchPermissionsError })
} }
// 验证服务倍率
const batchServiceRatesError = validateServiceRates(serviceRates)
if (batchServiceRatesError) {
return res.status(400).json({ error: batchServiceRatesError })
}
// 生成批量API Keys // 生成批量API Keys
const createdKeys = [] const createdKeys = []
const errors = [] const errors = []
@@ -1552,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays, activationDays,
activationUnit, activationUnit,
expirationMode, expirationMode,
icon icon,
serviceRates
}) })
// 保留原始 API Key 供返回 // 保留原始 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( logger.info(
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` `🔄 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) { if (updates.enabled !== undefined) {
finalUpdates.enabled = updates.enabled finalUpdates.enabled = updates.enabled
} }
if (updates.serviceRates !== undefined) {
finalUpdates.serviceRates = updates.serviceRates
}
// 处理账户绑定 // 处理账户绑定
if (updates.claudeAccountId !== undefined) { if (updates.claudeAccountId !== undefined) {
@@ -1750,7 +1928,7 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
// 执行更新 // 执行更新
await apiKeyService.updateApiKey(keyId, finalUpdates) await apiKeyService.updateApiKey(keyId, finalUpdates)
results.successCount++ results.successCount++
logger.success(`Batch edit: API key ${keyId} updated successfully`) logger.success(`Batch edit: API key ${keyId} updated successfully`)
} catch (error) { } catch (error) {
results.failedCount++ results.failedCount++
results.errors.push(`Failed to update key ${keyId}: ${error.message}`) results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
@@ -1811,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
totalCostLimit, totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
ownerId // 新增所有者ID字段 ownerId, // 新增所有者ID字段
serviceRates // API Key 级别服务倍率
} = req.body } = req.body
// 只允许更新指定字段 // 只允许更新指定字段
@@ -1997,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.tags = tags updates.tags = tags
} }
// 处理服务倍率
if (serviceRates !== undefined) {
const singleServiceRatesError = validateServiceRates(serviceRates)
if (singleServiceRatesError) {
return res.status(400).json({ error: singleServiceRatesError })
}
updates.serviceRates = serviceRates
}
// 处理活跃/禁用状态状态, 放在过期处理后以确保后续增加禁用key功能 // 处理活跃/禁用状态状态, 放在过期处理后以确保后续增加禁用key功能
if (isActive !== undefined) { if (isActive !== undefined) {
if (typeof isActive !== 'boolean') { if (typeof isActive !== 'boolean') {
@@ -2200,7 +2388,7 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
await apiKeyService.deleteApiKey(keyId) await apiKeyService.deleteApiKey(keyId)
results.successCount++ results.successCount++
logger.success(`Batch delete: API key ${keyId} deleted successfully`) logger.success(`Batch delete: API key ${keyId} deleted successfully`)
} catch (error) { } catch (error) {
results.failedCount++ results.failedCount++
results.errors.push({ results.errors.push({
@@ -2255,13 +2443,13 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
// 📋 获取已删除的API Keys // 📋 获取已删除的API Keys
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
try { try {
const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted const deletedApiKeys = await apiKeyService.getAllApiKeysFast(true) // Include deleted
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true') const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === true)
// Add additional metadata for deleted keys // Add additional metadata for deleted keys
const enrichedKeys = onlyDeleted.map((key) => ({ const enrichedKeys = onlyDeleted.map((key) => ({
...key, ...key,
isDeleted: key.isDeleted === 'true', isDeleted: key.isDeleted === true,
deletedAt: key.deletedAt, deletedAt: key.deletedAt,
deletedBy: key.deletedBy, deletedBy: key.deletedBy,
deletedByType: key.deletedByType, 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') const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
if (result.success) { if (result.success) {
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`) logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
return res.json({ return res.json({
success: true, success: true,
message: 'API Key 已成功恢复', message: 'API Key 已成功恢复',

View File

@@ -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 module.exports = router

View File

@@ -377,7 +377,7 @@ router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
const { accountId } = req.params const { accountId } = req.params
await ccrAccountService.resetDailyUsage(accountId) 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' }) return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset CCR account daily usage:', 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 { try {
const { accountId } = req.params const { accountId } = req.params
const result = await ccrAccountService.resetAccountStatus(accountId) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset CCR account status:', error) logger.error('❌ Failed to reset CCR account status:', error)
@@ -403,7 +403,7 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
try { try {
await ccrAccountService.resetAllDailyUsage() 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' }) return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset all CCR accounts daily usage:', 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 module.exports = router

View File

@@ -36,7 +36,7 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req,
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 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({ return res.json({
success: true, success: true,
data: { 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分钟过期 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({ return res.json({
success: true, success: true,
data: { data: {
@@ -786,7 +786,7 @@ router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, asy
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) 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({ return res.json({
success: true, success: true,
message: 'Account profile updated successfully', message: 'Account profile updated successfully',
@@ -805,7 +805,7 @@ router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (re
try { try {
const result = await claudeAccountService.updateAllAccountProfiles() const result = await claudeAccountService.updateAllAccountProfiles()
logger.success('Batch profile update completed') logger.success('Batch profile update completed')
return res.json({ return res.json({
success: true, success: true,
message: 'Batch profile update completed', message: 'Batch profile update completed',
@@ -841,7 +841,7 @@ router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async
const result = await claudeAccountService.resetAccountStatus(accountId) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset Claude account status:', error) logger.error('❌ Failed to reset Claude account status:', error)

View File

@@ -441,7 +441,7 @@ router.post(
const { accountId } = req.params const { accountId } = req.params
await claudeConsoleAccountService.resetDailyUsage(accountId) 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' }) return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset Claude Console account daily usage:', error) logger.error('❌ Failed to reset Claude Console account daily usage:', error)
@@ -458,7 +458,7 @@ router.post(
try { try {
const { accountId } = req.params const { accountId } = req.params
const result = await claudeConsoleAccountService.resetAccountStatus(accountId) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset Claude Console account status:', 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 { try {
await claudeConsoleAccountService.resetAllDailyUsage() 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' }) return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)

View File

@@ -20,9 +20,14 @@ const router = express.Router()
// 获取系统概览 // 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => { router.get('/dashboard', authenticateAdmin, async (req, res) => {
try { try {
// 先检查是否有全局预聚合数据
const globalStats = await redis.getGlobalStats()
// 根据是否有全局统计决定查询策略
let apiKeys = null
let apiKeyCount = null
const [ const [
,
apiKeys,
claudeAccounts, claudeAccounts,
claudeConsoleAccounts, claudeConsoleAccounts,
geminiAccounts, geminiAccounts,
@@ -35,8 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
systemAverages, systemAverages,
realtimeMetrics realtimeMetrics
] = await Promise.all([ ] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(), claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(), claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(), geminiAccountService.getAllAccounts(),
@@ -50,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
redis.getRealtimeSystemMetrics() redis.getRealtimeSystemMetrics()
]) ])
// 有全局统计时只获取计数,否则拉全量
if (globalStats) {
apiKeyCount = await redis.getApiKeyCount()
} else {
apiKeys = await apiKeyService.getAllApiKeysFast()
}
// 处理Bedrock账户数据 // 处理Bedrock账户数据
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
const normalizeBoolean = (value) => value === true || value === 'true' const normalizeBoolean = (value) => value === true || value === 'true'
@@ -66,250 +76,118 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
return false return false
} }
const normalDroidAccounts = droidAccounts.filter( // 通用账户统计函数 - 单次遍历完成所有统计
(acc) => const countAccountStats = (accounts, opts = {}) => {
normalizeBoolean(acc.isActive) && const { isStringType = false, checkGeminiRateLimit = false } = opts
acc.status !== 'blocked' && let normal = 0,
acc.status !== 'unauthorized' && abnormal = 0,
normalizeBoolean(acc.schedulable) && paused = 0,
!isRateLimitedFlag(acc.rateLimitStatus) rateLimited = 0
).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
// 计算使用统计统一使用allTokens for (const acc of accounts) {
const totalTokensUsed = apiKeys.reduce( const isActive = isStringType
(sum, key) => sum + (key.usage?.total?.allTokens || 0), ? acc.isActive === 'true' ||
0 acc.isActive === true ||
) (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
const totalRequestsUsed = apiKeys.reduce( : acc.isActive
(sum, key) => sum + (key.usage?.total?.requests || 0), const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
0 const isSchedulable = isStringType
) ? acc.schedulable !== 'false' && acc.schedulable !== false
const totalInputTokensUsed = apiKeys.reduce( : acc.schedulable !== false
(sum, key) => sum + (key.usage?.total?.inputTokens || 0), const isRateLimited = checkGeminiRateLimit
0 ? acc.rateLimitStatus === 'limited' ||
)
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' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
) : 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
// Bedrock账户统计 if (!isActive || isBlocked) {
const normalBedrockAccounts = bedrockAccounts.filter( abnormal++
(acc) => } else if (!isSchedulable) {
acc.isActive && paused++
acc.status !== 'blocked' && } else if (isRateLimited) {
acc.status !== 'unauthorized' && rateLimited++
acc.schedulable !== false && } else {
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) normal++
).length }
const abnormalBedrockAccounts = bedrockAccounts.filter( }
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' return { normal, abnormal, paused, rateLimited }
).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
// OpenAI账户统计 // Droid 账户统计(特殊逻辑)
// 注意OpenAI账户的isActive和schedulable是字符串类型默认值为'true' let normalDroidAccounts = 0,
const normalOpenAIAccounts = openaiAccounts.filter( abnormalDroidAccounts = 0,
(acc) => pausedDroidAccounts = 0,
(acc.isActive === 'true' || rateLimitedDroidAccounts = 0
acc.isActive === true || for (const acc of droidAccounts) {
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && const isActive = normalizeBoolean(acc.isActive)
acc.status !== 'blocked' && const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
acc.status !== 'unauthorized' && const isSchedulable = normalizeBoolean(acc.schedulable)
acc.schedulable !== 'false' && const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
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
// CCR账户统计 if (!isActive || isBlocked) {
const normalCcrAccounts = ccrAccounts.filter( abnormalDroidAccounts++
(acc) => } else if (!isSchedulable) {
acc.isActive && pausedDroidAccounts++
acc.status !== 'blocked' && } else if (isRateLimited) {
acc.status !== 'unauthorized' && rateLimitedDroidAccounts++
acc.schedulable !== false && } else {
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) normalDroidAccounts++
).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
// OpenAI-Responses账户统计 // 计算使用统计
// 注意OpenAI-Responses账户的isActive和schedulable也是字符串类型 let totalTokensUsed = 0,
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( totalRequestsUsed = 0,
(acc) => totalInputTokensUsed = 0,
(acc.isActive === 'true' || totalOutputTokensUsed = 0,
acc.isActive === true || totalCacheCreateTokensUsed = 0,
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && totalCacheReadTokensUsed = 0,
acc.status !== 'blocked' && totalAllTokensUsed = 0,
acc.status !== 'unauthorized' && activeApiKeys = 0,
acc.schedulable !== 'false' && totalApiKeys = 0
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) if (globalStats) {
).length // 使用预聚合数据(快速路径)
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( totalRequestsUsed = globalStats.requests
(acc) => totalInputTokensUsed = globalStats.inputTokens
acc.isActive === 'false' || totalOutputTokensUsed = globalStats.outputTokens
acc.isActive === false || totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
acc.status === 'blocked' || totalCacheReadTokensUsed = globalStats.cacheReadTokens
acc.status === 'unauthorized' totalAllTokensUsed = globalStats.allTokens
).length totalTokensUsed = totalAllTokensUsed
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( totalApiKeys = apiKeyCount.total
(acc) => activeApiKeys = apiKeyCount.active
(acc.schedulable === 'false' || acc.schedulable === false) && } else {
(acc.isActive === 'true' || // 回退到遍历(兼容旧数据)
acc.isActive === true || totalApiKeys = apiKeys.length
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && for (const key of apiKeys) {
acc.status !== 'blocked' && const usage = key.usage?.total
acc.status !== 'unauthorized' if (usage) {
).length totalTokensUsed += usage.allTokens || 0
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( totalRequestsUsed += usage.requests || 0
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited totalInputTokensUsed += usage.inputTokens || 0
).length 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 = { const dashboard = {
overview: { overview: {
totalApiKeys: apiKeys.length, totalApiKeys,
activeApiKeys, activeApiKeys,
// 总账户统计(所有平台) // 总账户统计(所有平台)
totalAccounts: totalAccounts:
@@ -321,90 +199,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
openaiResponsesAccounts.length + openaiResponsesAccounts.length +
ccrAccounts.length, ccrAccounts.length,
normalAccounts: normalAccounts:
normalClaudeAccounts + claudeStats.normal +
normalClaudeConsoleAccounts + claudeConsoleStats.normal +
normalGeminiAccounts + geminiStats.normal +
normalBedrockAccounts + bedrockStats.normal +
normalOpenAIAccounts + openaiStats.normal +
normalOpenAIResponsesAccounts + openaiResponsesStats.normal +
normalCcrAccounts, ccrStats.normal,
abnormalAccounts: abnormalAccounts:
abnormalClaudeAccounts + claudeStats.abnormal +
abnormalClaudeConsoleAccounts + claudeConsoleStats.abnormal +
abnormalGeminiAccounts + geminiStats.abnormal +
abnormalBedrockAccounts + bedrockStats.abnormal +
abnormalOpenAIAccounts + openaiStats.abnormal +
abnormalOpenAIResponsesAccounts + openaiResponsesStats.abnormal +
abnormalCcrAccounts + ccrStats.abnormal +
abnormalDroidAccounts, abnormalDroidAccounts,
pausedAccounts: pausedAccounts:
pausedClaudeAccounts + claudeStats.paused +
pausedClaudeConsoleAccounts + claudeConsoleStats.paused +
pausedGeminiAccounts + geminiStats.paused +
pausedBedrockAccounts + bedrockStats.paused +
pausedOpenAIAccounts + openaiStats.paused +
pausedOpenAIResponsesAccounts + openaiResponsesStats.paused +
pausedCcrAccounts + ccrStats.paused +
pausedDroidAccounts, pausedDroidAccounts,
rateLimitedAccounts: rateLimitedAccounts:
rateLimitedClaudeAccounts + claudeStats.rateLimited +
rateLimitedClaudeConsoleAccounts + claudeConsoleStats.rateLimited +
rateLimitedGeminiAccounts + geminiStats.rateLimited +
rateLimitedBedrockAccounts + bedrockStats.rateLimited +
rateLimitedOpenAIAccounts + openaiStats.rateLimited +
rateLimitedOpenAIResponsesAccounts + openaiResponsesStats.rateLimited +
rateLimitedCcrAccounts + ccrStats.rateLimited +
rateLimitedDroidAccounts, rateLimitedDroidAccounts,
// 各平台详细统计 // 各平台详细统计
accountsByPlatform: { accountsByPlatform: {
claude: { claude: {
total: claudeAccounts.length, total: claudeAccounts.length,
normal: normalClaudeAccounts, normal: claudeStats.normal,
abnormal: abnormalClaudeAccounts, abnormal: claudeStats.abnormal,
paused: pausedClaudeAccounts, paused: claudeStats.paused,
rateLimited: rateLimitedClaudeAccounts rateLimited: claudeStats.rateLimited
}, },
'claude-console': { 'claude-console': {
total: claudeConsoleAccounts.length, total: claudeConsoleAccounts.length,
normal: normalClaudeConsoleAccounts, normal: claudeConsoleStats.normal,
abnormal: abnormalClaudeConsoleAccounts, abnormal: claudeConsoleStats.abnormal,
paused: pausedClaudeConsoleAccounts, paused: claudeConsoleStats.paused,
rateLimited: rateLimitedClaudeConsoleAccounts rateLimited: claudeConsoleStats.rateLimited
}, },
gemini: { gemini: {
total: geminiAccounts.length, total: geminiAccounts.length,
normal: normalGeminiAccounts, normal: geminiStats.normal,
abnormal: abnormalGeminiAccounts, abnormal: geminiStats.abnormal,
paused: pausedGeminiAccounts, paused: geminiStats.paused,
rateLimited: rateLimitedGeminiAccounts rateLimited: geminiStats.rateLimited
}, },
bedrock: { bedrock: {
total: bedrockAccounts.length, total: bedrockAccounts.length,
normal: normalBedrockAccounts, normal: bedrockStats.normal,
abnormal: abnormalBedrockAccounts, abnormal: bedrockStats.abnormal,
paused: pausedBedrockAccounts, paused: bedrockStats.paused,
rateLimited: rateLimitedBedrockAccounts rateLimited: bedrockStats.rateLimited
}, },
openai: { openai: {
total: openaiAccounts.length, total: openaiAccounts.length,
normal: normalOpenAIAccounts, normal: openaiStats.normal,
abnormal: abnormalOpenAIAccounts, abnormal: openaiStats.abnormal,
paused: pausedOpenAIAccounts, paused: openaiStats.paused,
rateLimited: rateLimitedOpenAIAccounts rateLimited: openaiStats.rateLimited
}, },
ccr: { ccr: {
total: ccrAccounts.length, total: ccrAccounts.length,
normal: normalCcrAccounts, normal: ccrStats.normal,
abnormal: abnormalCcrAccounts, abnormal: ccrStats.abnormal,
paused: pausedCcrAccounts, paused: ccrStats.paused,
rateLimited: rateLimitedCcrAccounts rateLimited: ccrStats.rateLimited
}, },
'openai-responses': { 'openai-responses': {
total: openaiResponsesAccounts.length, total: openaiResponsesAccounts.length,
normal: normalOpenAIResponsesAccounts, normal: openaiResponsesStats.normal,
abnormal: abnormalOpenAIResponsesAccounts, abnormal: openaiResponsesStats.abnormal,
paused: pausedOpenAIResponsesAccounts, paused: openaiResponsesStats.paused,
rateLimited: rateLimitedOpenAIResponsesAccounts rateLimited: openaiResponsesStats.rateLimited
}, },
droid: { droid: {
total: droidAccounts.length, total: droidAccounts.length,
@@ -416,20 +294,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
}, },
// 保留旧字段以兼容 // 保留旧字段以兼容
activeAccounts: activeAccounts:
normalClaudeAccounts + claudeStats.normal +
normalClaudeConsoleAccounts + claudeConsoleStats.normal +
normalGeminiAccounts + geminiStats.normal +
normalBedrockAccounts + bedrockStats.normal +
normalOpenAIAccounts + openaiStats.normal +
normalOpenAIResponsesAccounts + openaiResponsesStats.normal +
normalCcrAccounts + ccrStats.normal +
normalDroidAccounts, normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts, activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts, rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
totalGeminiAccounts: geminiAccounts.length, totalGeminiAccounts: geminiAccounts.length,
activeGeminiAccounts: normalGeminiAccounts, activeGeminiAccounts: geminiStats.normal,
rateLimitedGeminiAccounts, rateLimitedGeminiAccounts: geminiStats.rateLimited,
totalTokensUsed, totalTokensUsed,
totalRequestsUsed, totalRequestsUsed,
totalInputTokensUsed, totalInputTokensUsed,
@@ -459,8 +337,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
}, },
systemHealth: { systemHealth: {
redisConnected: redis.isConnected, redisConnected: redis.isConnected,
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0, claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
geminiAccountsHealthy: normalGeminiAccounts > 0, geminiAccountsHealthy: geminiStats.normal > 0,
droidAccountsHealthy: normalDroidAccounts > 0, droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime() uptime: process.uptime()
}, },
@@ -480,7 +358,7 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
const { period = 'daily' } = req.query // daily, monthly const { period = 'daily' } = req.query // daily, monthly
// 获取基础API Key统计 // 获取基础API Key统计
const apiKeys = await apiKeyService.getAllApiKeys() const apiKeys = await apiKeyService.getAllApiKeysFast()
const stats = apiKeys.map((key) => ({ const stats = apiKeys.map((key) => ({
keyId: key.id, 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}` `📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
) )
const client = redis.getClientSafe() // 收集所有需要扫描的日期
const datePatterns = []
// 获取所有模型的统计数据
let searchPatterns = []
if (startDate && endDate) { if (startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式 // 自定义日期范围
const start = new Date(startDate) const start = new Date(startDate)
const end = new Date(endDate) const end = new Date(endDate)
// 确保日期范围有效
if (start > end) { if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' }) 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 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) { if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
} }
// 生成日期范围内所有日期的搜索模式
const currentDate = new Date(start) const currentDate = new Date(start)
while (currentDate <= end) { while (currentDate <= end) {
const dateStr = redis.getDateStringInTimezone(currentDate) const dateStr = redis.getDateStringInTimezone(currentDate)
searchPatterns.push(`usage:model:daily:*:${dateStr}`) datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
currentDate.setDate(currentDate.getDate() + 1) 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 { } else {
// 使用默认的period // 使用默认的period
const pattern = const pattern =
period === 'daily' period === 'daily'
? `usage:model:daily:*:${today}` ? `usage:model:daily:*:${today}`
: `usage:model:monthly:*:${currentMonth}` : `usage:model:monthly:*:${currentMonth}`
searchPatterns = [pattern] datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
} }
logger.info('📊 Searching patterns:', searchPatterns) // 按日期集合扫描,串行避免并行触发多次全库 SCAN
const allResults = []
// 获取所有匹配的keys for (const { pattern } of datePatterns) {
const allKeys = [] const results = await redis.scanAndGetAllChunked(pattern)
for (const pattern of searchPatterns) { allResults.push(...results)
const keys = await client.keys(pattern)
allKeys.push(...keys)
} }
logger.info(`📊 Found ${allKeys.length} matching keys in total`) logger.info(`📊 Found ${allResults.length} matching keys in total`)
// 模型名标准化函数与redis.js保持一致 // 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => { const normalizeModelName = (model) => {
@@ -568,23 +439,23 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
// 对于Bedrock模型去掉区域前缀进行统一 // 对于Bedrock模型去掉区域前缀进行统一
if (model.includes('.anthropic.') || model.includes('.claude')) { if (model.includes('.anthropic.') || model.includes('.claude')) {
// 匹配所有AWS区域格式region.anthropic.model-name-v1:0 -> claude-model-name let normalized = model.replace(/^[a-z0-9-]+\./, '')
// 支持所有AWS区域格式us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 normalized = normalized.replace('anthropic.', '')
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) normalized = normalized.replace(/-v\d+:\d+$/, '')
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等
return normalized return normalized
} }
// 对于其他模型,去掉常见的版本后缀
return model.replace(/-v\d+:\d+$|:latest$/, '') return model.replace(/-v\d+:\d+$|:latest$/, '')
} }
// 聚合相同模型的数据 // 聚合相同模型的数据
const modelStatsMap = new Map() const modelStatsMap = new Map()
for (const key of allKeys) { for (const { key, data } of allResults) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) // 支持 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) { if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`) logger.warn(`📊 Pattern mismatch for key: ${key}`)
@@ -593,7 +464,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
const rawModel = match[1] const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel) const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
const stats = modelStatsMap.get(normalizedModel) || { const stats = modelStatsMap.get(normalizedModel) || {

View File

@@ -2,6 +2,7 @@ const express = require('express')
const crypto = require('crypto') const crypto = require('crypto')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/droidAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') 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) => { router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
try { try {
const accounts = await droidAccountService.getAllAccounts() const accounts = await droidAccountService.getAllAccounts()
const allApiKeys = await redis.getAllApiKeys() const accountIds = accounts.map((a) => a.id)
// 添加使用统计 // 并行获取:轻量 API Keys + 分组信息 + daily cost
const accountsWithStats = await Promise.all( const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
accounts.map(async (account) => { apiKeyService.getAllApiKeysLite(),
try { accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'),
const usageStats = await redis.getAccountUsageStats(account.id, 'droid') redis.batchGetAccountDailyCost(accountIds)
let groupInfos = [] ])
try {
groupInfos = await accountGroupService.getAccountGroups(account.id) // 构建绑定数映射droid 需要展开 group 绑定)
} catch (groupError) { // 1. 先构建 groupId -> accountIds 映射
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError) const groupToAccountIds = new Map()
groupInfos = [] 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) // 2. 单次遍历构建绑定数
const boundApiKeysCount = allApiKeys.reduce((count, key) => { const directBindingCount = new Map()
const groupBindingCount = new Map()
for (const key of allApiKeys) {
const binding = key.droidAccountId const binding = key.droidAccountId
if (!binding) { if (!binding) {
return count continue
}
if (binding === account.id) {
return count + 1
} }
if (binding.startsWith('group:')) { if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length) const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) { groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)
return count + 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) const formattedAccount = formatAccountExpiry(account)
return { return {
@@ -182,27 +273,13 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
boundApiKeysCount, boundApiKeysCount,
groupInfos, groupInfos,
usage: { usage: {
daily: usageStats.daily, daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total, total: usageStats.total,
monthly: usageStats.monthly,
averages: usageStats.averages 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 }) return res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -434,7 +511,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
} }
// 获取绑定的 API Key 数量 // 获取绑定的 API Key 数量
const allApiKeys = await redis.getAllApiKeys() const allApiKeys = await apiKeyService.getAllApiKeysFast()
const groupIds = groupInfos.map((group) => group.id) const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => { const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId 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 module.exports = router

View File

@@ -74,7 +74,7 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
const result = await geminiAccountService.pollAuthorizationStatus(sessionId) const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
if (result.success) { 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 } }) return res.json({ success: true, data: { tokens: result.tokens } })
} else { } else {
return res.json({ success: false, error: result.error }) return res.json({ success: false, error: result.error })
@@ -143,7 +143,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
await redis.deleteOAuthSession(sessionId) 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 } }) return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
} catch (error) { } catch (error) {
logger.error('❌ Failed to exchange Gemini authorization code:', 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) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset Gemini account status:', 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 module.exports = router

View File

@@ -31,53 +31,108 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
} }
} }
// 处理使用统计和绑定的 API Key 数量 const accountIds = accounts.map((a) => a.id)
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
// 检查并清除过期的限流状态
await geminiApiAccountService.checkAndClearRateLimit(account.id)
// 获取使用统计信息 // 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态
let usageStats const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
try { apiKeyService.getAllApiKeysLite(),
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api') accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'),
} catch (error) { redis.batchGetAccountDailyCost(accountIds),
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error) // 批量清除限流状态
usageStats = { 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 }, daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 }, total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { 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: 前缀 // 计算 averagesrpm/tpm
const allKeys = await redis.getAllApiKeys() const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
let boundCount = 0 const daysSinceCreated = Math.max(
1,
for (const key of allKeys) { Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
if (key.geminiAccountId) { )
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀) const totalMinutes = daysSinceCreated * 24 * 60
if (key.geminiAccountId === `api:${account.id}`) { const totalRequests = usageStats.total.requests || 0
boundCount++ const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
}
}
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return { return {
...account, ...account,
groupInfos, groupInfos,
usage: { usage: {
daily: usageStats.daily, daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total, 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 boundApiKeys: boundCount
} }
}) })
)
res.json({ success: true, data: accountsWithStats }) res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -275,7 +330,7 @@ router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) =>
message += `${unboundCount} 个 API Key 已切换为共享池模式` message += `${unboundCount} 个 API Key 已切换为共享池模式`
} }
logger.success(`${message}`) logger.success(`${message}`)
res.json({ res.json({
success: true, success: true,
@@ -389,7 +444,7 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
const result = await geminiApiAccountService.resetAccountStatus(id) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset Gemini-API account status:', error) logger.error('❌ Failed to reset Gemini-API account status:', error)

View File

@@ -26,6 +26,8 @@ const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency') const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig') const claudeRelayConfigRoutes = require('./claudeRelayConfig')
const syncRoutes = require('./sync') const syncRoutes = require('./sync')
const serviceRatesRoutes = require('./serviceRates')
const quotaCardsRoutes = require('./quotaCards')
// 挂载所有子路由 // 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径) // 使用完整路径的模块(直接挂载到根路径)
@@ -43,6 +45,8 @@ router.use('/', systemRoutes)
router.use('/', concurrencyRoutes) router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes) router.use('/', claudeRelayConfigRoutes)
router.use('/', syncRoutes) router.use('/', syncRoutes)
router.use('/', serviceRatesRoutes)
router.use('/', quotaCardsRoutes)
// 使用相对路径的模块(需要指定基础路径前缀) // 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes) router.use('/account-groups', accountGroupsRoutes)

View File

@@ -80,7 +80,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}` 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({ return res.json({
success: true, success: true,
@@ -191,7 +191,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
// 清理 Redis 会话 // 清理 Redis 会话
await redis.deleteOAuthSession(sessionId) await redis.deleteOAuthSession(sessionId)
logger.success('OpenAI OAuth token exchange successful') logger.success('OpenAI OAuth token exchange successful')
return res.json({ return res.json({
success: true, success: true,
@@ -386,7 +386,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
delete refreshedAccount.accessToken delete refreshedAccount.accessToken
delete refreshedAccount.refreshToken delete refreshedAccount.refreshToken
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
return res.json({ return res.json({
success: true, 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({ return res.json({
success: true, success: true,
@@ -541,7 +541,7 @@ router.put('/:id', authenticateAdmin, async (req, res) => {
}) })
} }
logger.success(`Token 验证成功,继续更新账户信息`) logger.success(`Token 验证成功,继续更新账户信息`)
} catch (refreshError) { } catch (refreshError) {
// 刷新失败,恢复原始 token // 刷新失败,恢复原始 token
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`) logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
@@ -755,7 +755,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
const result = await openaiAccountService.resetAccountStatus(accountId) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset OpenAI account status:', error) logger.error('❌ Failed to reset OpenAI account status:', error)

View File

@@ -39,64 +39,84 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
} }
} }
// 处理额度信息、使用统计和绑定的 API Key 数量 const accountIds = accounts.map((a) => a.id)
const accountsWithStats = await Promise.all(
accounts.map(async (account) => { // 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
try { const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
// 检查是否需要重置额度 apiKeyService.getAllApiKeysLite(),
const today = redis.getDateStringInTimezone() accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
if (account.lastResetDate !== today) { redis.batchGetAccountDailyCost(accountIds),
// 今天还没重置过,需要重置 // 批量清理限流状态
await openaiResponsesAccountService.updateAccount(account.id, { Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
dailyUsage: '0', ])
lastResetDate: today,
quotaStoppedAt: '' // 单次遍历构建绑定数映射(只算直连,不算 group
}) const bindingCountMap = new Map()
account.dailyUsage = '0' for (const key of allApiKeys) {
account.lastResetDate = today const binding = key.openaiAccountId
account.quotaStoppedAt = '' if (!binding) {
continue
}
// 处理 responses: 前缀
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
} }
// 检查并清除过期的限流状态 // 批量获取使用统计(不含 daily cost已单独获取
await openaiResponsesAccountService.checkAndClearRateLimit(account.id) 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()
let usageStats for (const accountId of accountIds) {
try { statsPipeline.hgetall(`account_usage:${accountId}`)
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses') statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
} catch (error) { statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
logger.debug( }
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`, const statsResults = await statsPipeline.exec()
error
) // 处理统计数据
usageStats = { 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 }, daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 }, total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 } monthly: { requests: 0, tokens: 0, allTokens: 0 }
} }
}
// 计算绑定的API Key数量支持 responses: 前缀) const groupInfos = allGroupInfosMap.get(account.id) || []
const allKeys = await redis.getAllApiKeys() const boundCount = bindingCountMap.get(account.id) || 0
let boundCount = 0 const dailyCost = dailyCostMap.get(account.id) || 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 formattedAccount = formatAccountExpiry(account) const formattedAccount = formatAccountExpiry(account)
return { return {
@@ -104,27 +124,12 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
groupInfos, groupInfos,
boundApiKeysCount: boundCount, boundApiKeysCount: boundCount,
usage: { usage: {
daily: usageStats.daily, daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total, total: usageStats.total,
monthly: usageStats.monthly 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 }) res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -413,7 +418,7 @@ router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, as
const result = await openaiResponsesAccountService.resetAccountStatus(id) 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 }) return res.json({ success: true, data: result })
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset OpenAI-Responses account status:', 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: '' 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({ res.json({
success: true, 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 module.exports = router

View 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

View 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

View File

@@ -288,10 +288,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
// ===== OpenAI OAuth accounts ===== // ===== OpenAI OAuth accounts =====
const openaiOAuthAccounts = [] const openaiOAuthAccounts = []
{ {
const client = redis.getClientSafe() const openaiIds = await redis.getAllIdsByIndex(
const openaiKeys = await client.keys('openai:account:*') 'openai:account:index',
for (const key of openaiKeys) { 'openai:account:*',
const id = key.split(':').slice(2).join(':') /^openai:account:(.+)$/
)
for (const id of openaiIds) {
const account = await openaiAccountService.getAccount(id) const account = await openaiAccountService.getAccount(id)
if (!account) { if (!account) {
continue continue
@@ -390,10 +392,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
// ===== OpenAI Responses API Key accounts ===== // ===== OpenAI Responses API Key accounts =====
const openaiResponsesAccounts = [] const openaiResponsesAccounts = []
const client = redis.getClientSafe() const openaiResponseIds = await redis.getAllIdsByIndex(
const openaiResponseKeys = await client.keys('openai_responses_account:*') 'openai_responses_account:index',
for (const key of openaiResponseKeys) { 'openai_responses_account:*',
const id = key.split(':').slice(1).join(':') /^openai_responses_account:(.+)$/
)
for (const id of openaiResponseIds) {
const full = await openaiResponsesAccountService.getAccount(id) const full = await openaiResponsesAccountService.getAccount(id)
if (!full) { if (!full) {
continue continue

View File

@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
siteIcon: '', siteIcon: '',
siteIconData: '', // Base64编码的图标数据 siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮 showAdminButton: true, // 是否显示管理后台按钮
apiStatsNotice: {
enabled: false,
title: '',
content: ''
},
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
@@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => {
// 更新OEM设置 // 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => { router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try { 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) { if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
siteIcon: (siteIcon || '').trim(), siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据 siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true 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() updatedAt: new Date().toISOString()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -27,14 +27,21 @@ const {
} = require('../services/anthropicGeminiBridgeService') } = require('../services/anthropicGeminiBridgeService')
const router = express.Router() const router = express.Router()
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { function queueRateLimitUpdate(
rateLimitInfo,
usageSummary,
model,
context = '',
keyId = null,
accountType = null
) {
if (!rateLimitInfo) { if (!rateLimitInfo) {
return Promise.resolve({ totalTokens: 0, totalCost: 0 }) return Promise.resolve({ totalTokens: 0, totalCost: 0 })
} }
const label = context ? ` (${context})` : '' const label = context ? ` (${context})` : ''
return updateRateLimitCounters(rateLimitInfo, usageSummary, model) return updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
.then(({ totalTokens, totalCost }) => { .then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) { if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
@@ -477,7 +484,7 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude') .recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType)
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record stream usage:', error) logger.error('❌ Failed to record stream usage:', error)
}) })
@@ -491,7 +498,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-stream' 'claude-stream',
_apiKeyId,
accountType
) )
usageDataCaptured = true usageDataCaptured = true
@@ -589,7 +598,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-console-stream' 'claude-console-stream',
_apiKeyIdConsole,
accountType
) )
usageDataCaptured = true usageDataCaptured = true
@@ -637,7 +648,8 @@ async function handleMessagesRequest(req, res) {
0, 0,
0, 0,
result.model, result.model,
accountId accountId,
'bedrock'
) )
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record Bedrock stream usage:', error) logger.error('❌ Failed to record Bedrock stream usage:', error)
@@ -652,7 +664,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens: 0 cacheReadTokens: 0
}, },
result.model, result.model,
'bedrock-stream' 'bedrock-stream',
_apiKeyIdBedrock,
'bedrock'
) )
usageDataCaptured = true usageDataCaptured = true
@@ -744,7 +758,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'ccr-stream' 'ccr-stream',
_apiKeyIdCcr,
'ccr'
) )
usageDataCaptured = true usageDataCaptured = true
@@ -1104,7 +1120,8 @@ async function handleMessagesRequest(req, res) {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model, model,
responseAccountId responseAccountId,
accountType
) )
await queueRateLimitUpdate( await queueRateLimitUpdate(
@@ -1116,7 +1133,9 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-non-stream' 'claude-non-stream',
_apiKeyIdNonStream,
accountType
) )
usageRecorded = true usageRecorded = true

View File

@@ -5,10 +5,39 @@ const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService') const claudeAccountService = require('../services/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/openaiAccountService')
const serviceRatesService = require('../services/serviceRatesService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper') const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const modelsConfig = require('../../config/models')
const { getSafeMessage } = require('../utils/errorSanitizer')
const router = express.Router() 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 // 🏠 重定向页面请求到新版 admin-spa
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.redirect(301, '/admin-next/api-stats') res.redirect(301, '/admin-next/api-stats')
@@ -39,7 +68,7 @@ router.post('/api/get-key-id', async (req, res) => {
if (!validation.valid) { if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' 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({ return res.status(401).json({
error: 'Invalid API key', error: 'Invalid API key',
message: validation.error message: validation.error
@@ -87,7 +116,7 @@ router.post('/api/user-stats', async (req, res) => {
keyData = await redis.getApiKey(apiId) keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) { 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({ return res.status(404).json({
error: 'API key not found', error: 'API key not found',
message: 'The specified API key does not exist' message: 'The specified API key does not exist'
@@ -166,7 +195,7 @@ router.post('/api/user-stats', async (req, res) => {
} else if (apiKey) { } else if (apiKey) {
// 通过 apiKey 查询(保持向后兼容) // 通过 apiKey 查询(保持向后兼容)
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { 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({ return res.status(400).json({
error: 'Invalid API key format', error: 'Invalid API key format',
message: 'API key format is invalid' message: 'API key format is invalid'
@@ -191,7 +220,7 @@ router.post('/api/user-stats', async (req, res) => {
keyData = validatedKeyData keyData = validatedKeyData
keyId = keyData.id keyId = keyData.id
} else { } 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({ return res.status(400).json({
error: 'API Key or ID is required', error: 'API Key or ID is required',
message: 'Please provide your API Key or API ID' 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}`) logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else { } else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键 // Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`) const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map() 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})$/) const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) { if (!modelMatch) {
continue continue
} }
const model = modelMatch[1] const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) { if (!modelUsageMap.has(model)) {
@@ -475,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => {
restrictedModels: fullKeyData.restrictedModels || [], restrictedModels: fullKeyData.restrictedModels || [],
enableClientRestriction: fullKeyData.enableClientRestriction || false, enableClientRestriction: fullKeyData.enableClientRestriction || false,
allowedClients: fullKeyData.allowedClients || [] 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({ return res.json({
@@ -598,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => {
...usage.monthly, ...usage.monthly,
cost: costStats.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 tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` 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:daily:*:${today}`
: `usage:${apiId}:model:monthly:*:${currentMonth}` : `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( const match = key.match(
period === 'daily' period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ ? /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 model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) { if (!modelUsageMap.has(model)) {
@@ -741,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
outputTokens: 0, outputTokens: 0,
cacheCreateTokens: 0, cacheCreateTokens: 0,
cacheReadTokens: 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.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
modelUsage.allTokens += parseInt(data.allTokens) || 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 = [] const modelStats = []
for (const [model, usage] of modelUsageMap) { for (const [model, usage] of modelUsageMap) {
const usageData = { const usageData = {
@@ -767,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
cache_read_input_tokens: usage.cacheReadTokens cache_read_input_tokens: usage.cacheReadTokens
} }
// 优先使用存储的费用,否则回退到重新计算
const { hasStoredCost } = usage
const costData = CostCalculator.calculateCost(usageData, model) 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({ modelStats.push({
model, model,
requests: usage.requests, requests: usage.requests,
@@ -779,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
allTokens: usage.allTokens, allTokens: usage.allTokens,
costs: costData.costs, costs: costData.costs,
formatted: costData.formatted, 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是否能正常访问服务 // 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
router.post('/api-key/test', async (req, res) => { router.post('/api-key/test', async (req, res) => {
const config = require('../../config/config') const config = require('../../config/config')
const { sendStreamTestRequest } = require('../utils/testPayloadHelper') const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try { 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) { if (!apiKey) {
return res.status(400).json({ return res.status(400).json({
@@ -841,7 +918,7 @@ router.post('/api-key/test', async (req, res) => {
apiUrl, apiUrl,
authorization: apiKey, authorization: apiKey,
responseStream: res, responseStream: res,
payload: createClaudeTestPayload(model, { stream: true }), payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
timeout: 60000, timeout: 60000,
extraHeaders: { 'x-api-key': apiKey } extraHeaders: { 'x-api-key': apiKey }
}) })
@@ -851,13 +928,317 @@ router.post('/api-key/test', async (req, res) => {
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({ return res.status(500).json({
error: 'Test failed', error: 'Test failed',
message: error.message || 'Internal server error' message: getSafeMessage(error)
}) })
} }
res.write( res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\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() res.end()
} }
}) })
@@ -886,7 +1267,7 @@ router.post('/api/user-model-stats', async (req, res) => {
keyData = await redis.getApiKey(apiId) keyData = await redis.getApiKey(apiId)
if (!keyData || Object.keys(keyData).length === 0) { 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({ return res.status(404).json({
error: 'API key not found', error: 'API key not found',
message: 'The specified API key does not exist' message: 'The specified API key does not exist'
@@ -942,33 +1323,37 @@ router.post('/api/user-model-stats', async (req, res) => {
) )
// 重用管理后台的模型统计逻辑但只返回该API Key的数据 // 重用管理后台的模型统计逻辑但只返回该API Key的数据
const client = redis.getClientSafe() const _client = redis.getClientSafe()
// 使用与管理页面相同的时区处理逻辑 // 使用与管理页面相同的时区处理逻辑
const tzDate = redis.getDateInTimezone() const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
const pattern = let pattern
period === 'daily' let matchRegex
? `usage:${keyId}:model:daily:*:${today}` if (period === 'daily') {
: `usage:${keyId}:model:monthly:*:${currentMonth}` 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 = [] const modelStats = []
for (const key of keys) { for (const { key, data } of results) {
const match = key.match( const match = key.match(matchRegex)
period === 'daily'
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
)
if (!match) { if (!match) {
continue continue
} }
const model = match[1] const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
const usage = { const usage = {
@@ -978,8 +1363,30 @@ router.post('/api/user-model-stats', async (req, res) => {
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 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) 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({ modelStats.push({
model, model,
requests: parseInt(data.requests) || 0, requests: parseInt(data.requests) || 0,
@@ -987,10 +1394,11 @@ router.post('/api/user-model-stats', async (req, res) => {
outputTokens: usage.output_tokens, outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens, cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens, cacheReadTokens: usage.cache_read_input_tokens,
allTokens: parseInt(data.allTokens) || 0, allTokens,
costs: costData.costs, costs: costData.costs,
formatted: costData.formatted, 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 module.exports = router

View File

@@ -86,7 +86,8 @@ class AtomicUsageReporter {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
modelToRecord, modelToRecord,
accountId accountId,
'azure-openai'
) )
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量 // 同步更新 Azure 账户的 lastUsedAt 和累计使用量

View File

@@ -29,6 +29,7 @@ const {
handleStreamGenerateContent, handleStreamGenerateContent,
handleLoadCodeAssist, handleLoadCodeAssist,
handleOnboardUser, handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens, handleCountTokens,
handleStandardGenerateContent, handleStandardGenerateContent,
handleStandardStreamGenerateContent, handleStandardStreamGenerateContent,
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
router.get('/key-info', authenticateApiKey, handleKeyInfo) router.get('/key-info', authenticateApiKey, handleKeyInfo)
// ============================================================================ // ============================================================================
// v1internal 独有路由listExperiments // v1internal 独有路由
// ============================================================================ // ============================================================================
/** /**
@@ -81,6 +82,12 @@ router.post(
handleSimpleEndpoint('listExperiments') handleSimpleEndpoint('listExperiments')
) )
/**
* POST /v1internal:retrieveUserQuota
* 获取用户配额信息Gemini CLI 0.22.2+ 需要)
*/
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
/** /**
* POST /v1beta/models/:modelName:listExperiments * POST /v1beta/models/:modelName:listExperiments
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由) * 带模型参数的实验列表(只有 geminiRoutes 定义此路由)

View File

@@ -13,6 +13,7 @@ const openaiToClaude = require('../services/openaiToClaude')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const { getSafeMessage } = require('../utils/errorSanitizer')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const pricingService = require('../services/pricingService') const pricingService = require('../services/pricingService')
@@ -23,14 +24,21 @@ function checkPermissions(apiKeyData, requiredPermission = 'claude') {
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
} }
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { function queueRateLimitUpdate(
rateLimitInfo,
usageSummary,
model,
context = '',
keyId = null,
accountType = null
) {
if (!rateLimitInfo) { if (!rateLimitInfo) {
return return
} }
const label = context ? ` (${context})` : '' const label = context ? ` (${context})` : ''
updateRateLimitCounters(rateLimitInfo, usageSummary, model) updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
.then(({ totalTokens, totalCost }) => { .then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) { if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
@@ -300,7 +308,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens cacheReadTokens
}, },
model, model,
`openai-${accountType}-stream` `openai-${accountType}-stream`,
req.apiKey?.id,
accountType
) )
} }
} }
@@ -425,7 +435,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens cacheReadTokens
}, },
claudeRequest.model, 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 const status = error.status || 500
res.status(status).json({ res.status(status).json({
error: { error: {
message: error.message || 'Internal server error', message: getSafeMessage(error),
type: 'server_error', type: 'server_error',
code: 'internal_error' code: 'internal_error'
} }

View File

@@ -539,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
0, // cacheCreateTokens 0, // cacheCreateTokens
0, // cacheReadTokens 0, // cacheReadTokens
model, model,
account.id account.id,
'gemini'
) )
logger.info( logger.info(
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` `📊 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, // cacheCreateTokens
0, // cacheReadTokens 0, // cacheReadTokens
model, model,
account.id account.id,
'gemini'
) )
logger.info( logger.info(
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}` `📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`

View File

@@ -9,9 +9,12 @@ const openaiAccountService = require('../services/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService') const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis')
const crypto = require('crypto') const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const { IncrementalSSEParser } = require('../utils/sseParser')
const { getSafeMessage } = require('../utils/errorSanitizer')
// 创建代理 Agent使用统一的代理工具 // 创建代理 Agent使用统一的代理工具
function createProxyAgent(proxy) { function createProxyAgent(proxy) {
@@ -67,7 +70,7 @@ function extractCodexUsageHeaders(headers) {
return hasData ? snapshot : null return hasData ? snapshot : null
} }
async function applyRateLimitTracking(req, usageSummary, model, context = '') { async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) {
if (!req.rateLimitInfo) { if (!req.rateLimitInfo) {
return return
} }
@@ -78,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
const { totalTokens, totalCost } = await updateRateLimitCounters( const { totalTokens, totalCost } = await updateRateLimitCounters(
req.rateLimitInfo, req.rateLimitInfo,
usageSummary, usageSummary,
model model,
req.apiKey?.id,
accountType
) )
if (totalTokens > 0) { if (totalTokens > 0) {
@@ -274,7 +279,9 @@ const handleResponses = async (req, res) => {
'text_formatting', 'text_formatting',
'truncation', 'truncation',
'text', 'text',
'service_tier' 'service_tier',
'prompt_cache_retention',
'safety_identifier'
] ]
fieldsToRemove.forEach((field) => { fieldsToRemove.forEach((field) => {
delete req.body[field] delete req.body[field]
@@ -575,7 +582,6 @@ const handleResponses = async (req, res) => {
} }
// 处理响应并捕获 usage 数据和真实的 model // 处理响应并捕获 usage 数据和真实的 model
let buffer = ''
let usageData = null let usageData = null
let actualModel = null let actualModel = null
let usageReported = false let usageReported = false
@@ -611,7 +617,8 @@ const handleResponses = async (req, res) => {
0, // OpenAI没有cache_creation_tokens 0, // OpenAI没有cache_creation_tokens
cacheReadTokens, cacheReadTokens,
actualModel, actualModel,
accountId accountId,
'openai'
) )
logger.info( logger.info(
@@ -627,7 +634,8 @@ const handleResponses = async (req, res) => {
cacheReadTokens cacheReadTokens
}, },
actualModel, actualModel,
'openai-non-stream' 'openai-non-stream',
'openai'
) )
} }
@@ -643,21 +651,11 @@ const handleResponses = async (req, res) => {
} }
} }
// 解析 SSE 事件以捕获 usage 数据和 model // 使用增量 SSE 解析器
const parseSSEForUsage = (data) => { const sseParser = new IncrementalSSEParser()
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)
// 处理解析出的事件
const processSSEEvent = (eventData) => {
// 检查是否是 response.completed 事件 // 检查是否是 response.completed 事件
if (eventData.type === 'response.completed' && eventData.response) { if (eventData.type === 'response.completed' && eventData.response) {
// 从响应中获取真实的 model // 从响应中获取真实的 model
@@ -683,34 +681,20 @@ const handleResponses = async (req, res) => {
) )
} }
} }
} catch (e) {
// 忽略解析错误
}
}
}
} }
upstream.data.on('data', (chunk) => { upstream.data.on('data', (chunk) => {
try { try {
const chunkStr = chunk.toString()
// 转发数据给客户端 // 转发数据给客户端
if (!res.destroyed) { if (!res.destroyed) {
res.write(chunk) res.write(chunk)
} }
// 同时解析数据以捕获 usage 信息 // 使用增量解析器处理数据
buffer += chunkStr const events = sseParser.feed(chunk.toString())
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
for (const event of events) { for (const event of events) {
if (event.trim()) { if (event.type === 'data' && event.data) {
parseSSEForUsage(event) processSSEEvent(event.data)
}
} }
} }
} catch (error) { } catch (error) {
@@ -720,8 +704,14 @@ const handleResponses = async (req, res) => {
upstream.data.on('end', async () => { upstream.data.on('end', async () => {
// 处理剩余的 buffer // 处理剩余的 buffer
if (buffer.trim()) { const remaining = sseParser.getRemaining()
parseSSEForUsage(buffer) if (remaining.trim()) {
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
for (const event of events) {
if (event.type === 'data' && event.data) {
processSSEEvent(event.data)
}
}
} }
// 记录使用统计 // 记录使用统计
@@ -743,7 +733,8 @@ const handleResponses = async (req, res) => {
0, // OpenAI没有cache_creation_tokens 0, // OpenAI没有cache_creation_tokens
cacheReadTokens, cacheReadTokens,
modelToRecord, modelToRecord,
accountId accountId,
'openai'
) )
logger.info( logger.info(
@@ -760,7 +751,8 @@ const handleResponses = async (req, res) => {
cacheReadTokens cacheReadTokens
}, },
modelToRecord, modelToRecord,
'openai-stream' 'openai-stream',
'openai'
) )
} catch (error) { } catch (error) {
logger.error('Failed to record OpenAI usage:', error) logger.error('Failed to record OpenAI usage:', error)
@@ -850,13 +842,15 @@ const handleResponses = async (req, res) => {
let responsePayload = error.response?.data let responsePayload = error.response?.data
if (!responsePayload) { if (!responsePayload) {
responsePayload = { error: { message: error.message || 'Internal server error' } } responsePayload = { error: { message: getSafeMessage(error) } }
} else if (typeof responsePayload === 'string') { } else if (typeof responsePayload === 'string') {
responsePayload = { error: { message: responsePayload } } responsePayload = { error: { message: getSafeMessage(responsePayload) } }
} else if (typeof responsePayload === 'object' && !responsePayload.error) { } else if (typeof responsePayload === 'object' && !responsePayload.error) {
responsePayload = { 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) { if (!res.headersSent) {
@@ -874,16 +868,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
// 使用情况统计端点 // 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => { router.get('/usage', authenticateApiKey, async (req, res) => {
try { try {
const { usage } = req.apiKey const keyData = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({ res.json({
object: 'usage', object: 'usage',
total_tokens: usage.total.tokens, total_tokens: usage?.total?.tokens || 0,
total_requests: usage.total.requests, total_requests: usage?.total?.requests || 0,
daily_tokens: usage.daily.tokens, daily_tokens: usage?.daily?.tokens || 0,
daily_requests: usage.daily.requests, daily_requests: usage?.daily?.requests || 0,
monthly_tokens: usage.monthly.tokens, monthly_tokens: usage?.monthly?.tokens || 0,
monthly_requests: usage.monthly.requests monthly_requests: usage?.monthly?.requests || 0
}) })
} catch (error) { } catch (error) {
logger.error('Failed to get usage stats:', error) logger.error('Failed to get usage stats:', error)
@@ -900,25 +896,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
router.get('/key-info', authenticateApiKey, async (req, res) => { router.get('/key-info', authenticateApiKey, async (req, res) => {
try { try {
const keyData = req.apiKey const keyData = req.apiKey
// 按需查询 usage 数据(仅 key-info 端点需要)
const usage = await redis.getUsageStats(keyData.id)
const tokensUsed = usage?.total?.tokens || 0
res.json({ res.json({
id: keyData.id, id: keyData.id,
name: keyData.name, name: keyData.name,
description: keyData.description, description: keyData.description,
permissions: keyData.permissions, permissions: keyData.permissions,
token_limit: keyData.tokenLimit, token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens, tokens_used: tokensUsed,
tokens_remaining: tokens_remaining:
keyData.tokenLimit > 0 keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
rate_limit: { rate_limit: {
window: keyData.rateLimitWindow, window: keyData.rateLimitWindow,
requests: keyData.rateLimitRequests requests: keyData.rateLimitRequests
}, },
usage: { usage: {
total: keyData.usage.total, total: usage?.total || {},
daily: keyData.usage.daily, daily: usage?.daily || {},
monthly: keyData.usage.monthly monthly: usage?.monthly || {}
} }
}) })
} catch (error) { } catch (error) {

View File

@@ -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 module.exports = router

View File

@@ -74,7 +74,7 @@ router.post('/auth/login', async (req, res) => {
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash) const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
if (!isValidUsername || !isValidPassword) { if (!isValidUsername || !isValidPassword) {
logger.security(`🔒 Failed login attempt for username: ${username}`) logger.security(`Failed login attempt for username: ${username}`)
return res.status(401).json({ return res.status(401).json({
error: 'Invalid credentials', error: 'Invalid credentials',
message: 'Invalid username or password' message: 'Invalid username or password'
@@ -96,7 +96,7 @@ router.post('/auth/login', async (req, res) => {
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存 // 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
// init.json 是唯一真实数据源 // init.json 是唯一真实数据源
logger.success(`🔐 Admin login successful: ${username}`) logger.success(`Admin login successful: ${username}`)
return res.json({ return res.json({
success: true, success: true,
@@ -197,7 +197,7 @@ router.post('/auth/change-password', async (req, res) => {
// 验证当前密码 // 验证当前密码
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash) const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
if (!isValidPassword) { 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({ return res.status(401).json({
error: 'Invalid current password', error: 'Invalid current password',
message: 'Current password is incorrect' message: 'Current password is incorrect'
@@ -253,7 +253,7 @@ router.post('/auth/change-password', async (req, res) => {
// 清除当前会话(强制用户重新登录) // 清除当前会话(强制用户重新登录)
await redis.deleteSession(token) 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({ return res.json({
success: true, success: true,
@@ -294,7 +294,7 @@ router.get('/auth/user', async (req, res) => {
// 🔒 安全修复:验证会话完整性 // 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) { 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) await redis.deleteSession(token)
return res.status(401).json({ return res.status(401).json({
error: 'Invalid session', error: 'Invalid session',
@@ -352,7 +352,7 @@ router.post('/auth/refresh', async (req, res) => {
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime // 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime
if (!sessionData.username || !sessionData.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) // 清理无效/伪造的会话 await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({ return res.status(401).json({
error: 'Invalid session', error: 'Invalid session',

View File

@@ -7,6 +7,62 @@ class AccountGroupService {
this.GROUPS_KEY = 'account_groups' this.GROUPS_KEY = 'account_groups'
this.GROUP_PREFIX = 'account_group:' this.GROUP_PREFIX = 'account_group:'
this.GROUP_MEMBERS_PREFIX = 'account_group_members:' 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) await client.sadd(this.GROUPS_KEY, groupId)
logger.success(`创建账户分组成功: ${name} (${platform})`) logger.success(`创建账户分组成功: ${name} (${platform})`)
return group return group
} catch (error) { } catch (error) {
@@ -101,7 +157,7 @@ class AccountGroupService {
// 返回更新后的完整数据 // 返回更新后的完整数据
const updatedGroup = await client.hgetall(groupKey) const updatedGroup = await client.hgetall(groupKey)
logger.success(`更新账户分组成功: ${updatedGroup.name}`) logger.success(`更新账户分组成功: ${updatedGroup.name}`)
return updatedGroup return updatedGroup
} catch (error) { } catch (error) {
@@ -143,7 +199,7 @@ class AccountGroupService {
// 从分组集合中移除 // 从分组集合中移除
await client.srem(this.GROUPS_KEY, groupId) await client.srem(this.GROUPS_KEY, groupId)
logger.success(`删除账户分组成功: ${group.name}`) logger.success(`删除账户分组成功: ${group.name}`)
} catch (error) { } catch (error) {
logger.error('❌ 删除账户分组失败:', error) logger.error('❌ 删除账户分组失败:', error)
throw error throw error
@@ -234,7 +290,10 @@ class AccountGroupService {
// 添加到分组成员集合 // 添加到分组成员集合
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) 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) { } catch (error) {
logger.error('❌ 添加账户到分组失败:', error) logger.error('❌ 添加账户到分组失败:', error)
throw error throw error
@@ -245,15 +304,26 @@ class AccountGroupService {
* 从分组移除账户 * 从分组移除账户
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @param {string} groupId - 分组ID * @param {string} groupId - 分组ID
* @param {string} platform - 平台(可选,如果不传则从分组获取)
*/ */
async removeAccountFromGroup(accountId, groupId) { async removeAccountFromGroup(accountId, groupId, platform = null) {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
// 从分组成员集合中移除 // 从分组成员集合中移除
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) 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) { } catch (error) {
logger.error('❌ 从分组移除账户失败:', error) logger.error('❌ 从分组移除账户失败:', error)
throw error throw error
@@ -399,7 +469,7 @@ class AccountGroupService {
await this.addAccountToGroup(accountId, groupId, accountPlatform) await this.addAccountToGroup(accountId, groupId, accountPlatform)
} }
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`) logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
} catch (error) { } catch (error) {
logger.error('❌ 批量设置账户分组失败:', error) logger.error('❌ 批量设置账户分组失败:', error)
throw error throw error
@@ -409,8 +479,9 @@ class AccountGroupService {
/** /**
* 从所有分组中移除账户 * 从所有分组中移除账户
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @param {string} platform - 平台(可选,用于清理反向索引)
*/ */
async removeAccountFromAllGroups(accountId) { async removeAccountFromAllGroups(accountId, platform = null) {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const allGroupIds = await client.smembers(this.GROUPS_KEY) const allGroupIds = await client.smembers(this.GROUPS_KEY)
@@ -419,12 +490,155 @@ class AccountGroupService {
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) 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) { } catch (error) {
logger.error('❌ 从所有分组移除账户失败:', error) logger.error('❌ 从所有分组移除账户失败:', error)
throw 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() module.exports = new AccountGroupService()

View File

@@ -1800,7 +1800,13 @@ function dumpToolsPayload({ vendor, model, tools, toolChoice }) {
* 更新速率限制计数器 * 更新速率限制计数器
* 跟踪 token 使用量和成本 * 跟踪 token 使用量和成本
*/ */
async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { async function applyRateLimitTracking(
rateLimitInfo,
usageSummary,
model,
context = '',
keyId = null
) {
if (!rateLimitInfo) { if (!rateLimitInfo) {
return return
} }
@@ -1811,7 +1817,9 @@ async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, contex
const { totalTokens, totalCost } = await updateRateLimitCounters( const { totalTokens, totalCost } = await updateRateLimitCounters(
rateLimitInfo, rateLimitInfo,
usageSummary, usageSummary,
model model,
keyId,
'gemini'
) )
if (totalTokens > 0) { if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
@@ -2134,13 +2142,15 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
0, 0,
0, 0,
effectiveModel, effectiveModel,
accountId accountId,
'gemini'
) )
await applyRateLimitTracking( await applyRateLimitTracking(
req.rateLimitInfo, req.rateLimitInfo,
{ inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 },
effectiveModel, effectiveModel,
'anthropic-messages' 'anthropic-messages',
req.apiKey?.id
) )
} }
@@ -2672,7 +2682,8 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
0, 0,
0, 0,
effectiveModel, effectiveModel,
accountId accountId,
'gemini'
) )
await applyRateLimitTracking( await applyRateLimitTracking(
req.rateLimitInfo, req.rateLimitInfo,

View File

@@ -82,7 +82,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
usageRecorded = true usageRecorded = true
} }
@@ -103,7 +104,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
0, 0,
0, 0,
model, model,
accountId accountId,
'gemini'
) )
} }
} }
@@ -158,7 +160,8 @@ async function sendAntigravityRequest({
0, 0,
0, 0,
requestedModel, requestedModel,
accountId accountId,
'gemini'
) )
} }

View 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

View File

@@ -150,6 +150,7 @@ async function createAccount(accountData) {
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
await redisClient.addToIndex('azure_openai:account:index', accountId)
// 如果是共享账户,添加到共享账户集合 // 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
// 从Redis中删除账户数据 // 从Redis中删除账户数据
await client.del(accountKey) await client.del(accountKey)
// 从索引中移除
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
// 从共享账户集合中移除 // 从共享账户集合中移除
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const client = redisClient.getClientSafe() const accountIds = await redisClient.getAllIdsByIndex(
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`) 'azure_openai:account:index',
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
/^azure_openai:account:(.+)$/
)
if (!keys || keys.length === 0) { if (!accountIds || accountIds.length === 0) {
return [] return []
} }
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
const accounts = [] const accounts = []
for (const key of keys) { const dataList = await redisClient.batchHgetallChunked(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) { if (accountData && Object.keys(accountData).length > 0) {
// 不返回敏感数据给前端 // 不返回敏感数据给前端
delete accountData.apiKey delete accountData.apiKey

View File

@@ -79,6 +79,7 @@ class BedrockAccountService {
const client = redis.getClientSafe() const client = redis.getClientSafe()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
await redis.addToIndex('bedrock_account:index', accountId)
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`) logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
@@ -208,12 +209,18 @@ class BedrockAccountService {
// 📋 获取所有账户列表 // 📋 获取所有账户列表
async getAllAccounts() { async getAllAccounts() {
try { try {
const client = redis.getClientSafe() const _client = redis.getClientSafe()
const keys = await client.keys('bedrock_account:*') const accountIds = await redis.getAllIdsByIndex(
'bedrock_account:index',
'bedrock_account:*',
/^bedrock_account:(.+)$/
)
const keys = accountIds.map((id) => `bedrock_account:${id}`)
const accounts = [] const accounts = []
const dataList = await redis.batchGetChunked(keys)
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const accountData = await client.get(key) const accountData = dataList[i]
if (accountData) { if (accountData) {
const account = JSON.parse(accountData) const account = JSON.parse(accountData)
@@ -375,6 +382,7 @@ class BedrockAccountService {
const client = redis.getClientSafe() const client = redis.getClientSafe()
await client.del(`bedrock_account:${accountId}`) await client.del(`bedrock_account:${accountId}`)
await redis.removeFromIndex('bedrock_account:index', accountId)
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`) logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)

View File

@@ -343,8 +343,8 @@ class BedrockRelayService {
res.write(`event: ${claudeEvent.type}\n`) res.write(`event: ${claudeEvent.type}\n`)
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`) res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
// 提取使用统计 // 提取使用统计 (usage is reported in message_delta per Claude API spec)
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) { if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage totalUsage = claudeEvent.data.usage
} }
@@ -576,8 +576,10 @@ class BedrockRelayService {
return { return {
type: 'message_start', type: 'message_start',
data: { data: {
type: 'message', type: 'message_start',
message: {
id: `msg_${Date.now()}_bedrock`, id: `msg_${Date.now()}_bedrock`,
type: 'message',
role: 'assistant', role: 'assistant',
content: [], content: [],
model: this.defaultModel, model: this.defaultModel,
@@ -587,21 +589,45 @@ class BedrockRelayService {
} }
} }
} }
}
if (bedrockChunk.type === 'content_block_start') {
return {
type: 'content_block_start',
data: {
type: 'content_block_start',
index: bedrockChunk.index || 0,
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
}
}
}
if (bedrockChunk.type === 'content_block_delta') { if (bedrockChunk.type === 'content_block_delta') {
return { return {
type: 'content_block_delta', type: 'content_block_delta',
data: { data: {
type: 'content_block_delta',
index: bedrockChunk.index || 0, index: bedrockChunk.index || 0,
delta: bedrockChunk.delta || {} delta: bedrockChunk.delta || {}
} }
} }
} }
if (bedrockChunk.type === 'content_block_stop') {
return {
type: 'content_block_stop',
data: {
type: 'content_block_stop',
index: bedrockChunk.index || 0
}
}
}
if (bedrockChunk.type === 'message_delta') { if (bedrockChunk.type === 'message_delta') {
return { return {
type: 'message_delta', type: 'message_delta',
data: { data: {
type: 'message_delta',
delta: bedrockChunk.delta || {}, delta: bedrockChunk.delta || {},
usage: bedrockChunk.usage || {} usage: bedrockChunk.usage || {}
} }
@@ -612,7 +638,7 @@ class BedrockRelayService {
return { return {
type: 'message_stop', type: 'message_stop',
data: { data: {
usage: bedrockChunk.usage || {} type: 'message_stop'
} }
} }
} }

View File

@@ -208,7 +208,7 @@ class BillingEventPublisher {
// MKSTREAM: 如果 stream 不存在则创建 // MKSTREAM: 如果 stream 不存在则创建
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM') await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
logger.success(`Created consumer group: ${groupName}`) logger.success(`Created consumer group: ${groupName}`)
return true return true
} catch (error) { } catch (error) {
if (error.message.includes('BUSYGROUP')) { if (error.message.includes('BUSYGROUP')) {

View File

@@ -1,33 +1,23 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const { createEncryptor } = require('../utils/commonHelper')
const LRUCache = require('../utils/lruCache')
class CcrAccountService { class CcrAccountService {
constructor() { constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'ccr-account-salt'
// Redis键前缀 // Redis键前缀
this.ACCOUNT_KEY_PREFIX = 'ccr_account:' this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts' this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 // 使用 commonHelper 的加密器
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作 this._encryptor = createEncryptor('ccr-account-salt')
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
this._decryptCache.cleanup() this._encryptor.clearCache()
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats()) logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -106,6 +96,7 @@ class CcrAccountService {
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`) logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
await redis.addToIndex('ccr_account:index', accountId)
// 如果是共享账户,添加到共享账户集合 // 如果是共享账户,添加到共享账户集合
if (accountType === 'shared') { if (accountType === 'shared') {
@@ -139,12 +130,17 @@ class CcrAccountService {
// 📋 获取所有CCR账户 // 📋 获取所有CCR账户
async getAllAccounts() { async getAllAccounts() {
try { try {
const client = redis.getClientSafe() const accountIds = await redis.getAllIdsByIndex(
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) 'ccr_account:index',
`${this.ACCOUNT_KEY_PREFIX}*`,
/^ccr_account:(.+)$/
)
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
const accounts = [] const accounts = []
const dataList = await redis.batchHgetallChunked(keys)
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const accountData = await client.hgetall(key) const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息 // 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData) const rateLimitInfo = this._getRateLimitInfo(accountData)
@@ -331,6 +327,9 @@ class CcrAccountService {
// 从共享账户集合中移除 // 从共享账户集合中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) 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}`) 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` ` CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
) )
} else { } else {
logger.success(`Removed rate limit for CCR account: ${accountId}`) logger.success(`Removed rate limit for CCR account: ${accountId}`)
} }
await client.hmset(accountKey, { await client.hmset(accountKey, {
@@ -488,7 +487,7 @@ class CcrAccountService {
errorMessage: '' errorMessage: ''
}) })
logger.success(`Removed overload status for CCR account: ${accountId}`) logger.success(`Removed overload status for CCR account: ${accountId}`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error) logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
@@ -606,70 +605,12 @@ class CcrAccountService {
// 🔐 加密敏感数据 // 🔐 加密敏感数据
_encryptSensitiveData(data) { _encryptSensitiveData(data) {
if (!data) { return this._encryptor.encrypt(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
}
} }
// 🔓 解密敏感数据 // 🔓 解密敏感数据
_decryptSensitiveData(encryptedData) { _decryptSensitiveData(encryptedData) {
if (!encryptedData) { return this._encryptor.decrypt(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
} }
// 🔍 获取限流状态信息 // 🔍 获取限流状态信息
@@ -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 } return { success: true, resetCount }
} catch (error) { } catch (error) {
logger.error('❌ Failed to reset all CCR daily usage:', error) logger.error('❌ Failed to reset all CCR daily usage:', error)
@@ -915,7 +856,7 @@ class CcrAccountService {
await client.hset(accountKey, updates) await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete) 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 通知(忽略错误) // 异步发送 Webhook 通知(忽略错误)
try { try {

View File

@@ -1570,7 +1570,7 @@ class ClaudeAccountService {
'rateLimitAutoStopped' 'rateLimitAutoStopped'
) )
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`) logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
@@ -2242,7 +2242,7 @@ class ClaudeAccountService {
await new Promise((resolve) => setTimeout(resolve, 1000)) 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 { return {
totalAccounts: accounts.length, totalAccounts: accounts.length,
@@ -2310,11 +2310,11 @@ class ClaudeAccountService {
} }
} }
logger.success('Session window initialization completed:') logger.success('Session window initialization completed:')
logger.success(` 📊 Total accounts: ${accounts.length}`) logger.success(` Total accounts: ${accounts.length}`)
logger.success(` Valid windows: ${validWindowCount}`) logger.success(` Valid windows: ${validWindowCount}`)
logger.success(` Expired windows: ${expiredWindowCount}`) logger.success(` Expired windows: ${expiredWindowCount}`)
logger.success(` 📭 No windows: ${noWindowCount}`) logger.success(` No windows: ${noWindowCount}`)
return { return {
total: accounts.length, total: accounts.length,

View File

@@ -5,6 +5,11 @@
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const {
getCachedConfig,
setCachedConfig,
deleteCachedConfig
} = require('../utils/performanceOptimizer')
class ClaudeCodeHeadersService { class ClaudeCodeHeadersService {
constructor() { constructor() {
@@ -41,6 +46,9 @@ class ClaudeCodeHeadersService {
'sec-fetch-mode', 'sec-fetch-mode',
'accept-encoding' 'accept-encoding'
] ]
// Headers 缓存 TTL60秒
this.headersCacheTtl = 60000
} }
/** /**
@@ -147,6 +155,9 @@ class ClaudeCodeHeadersService {
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期 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}`) logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
} catch (error) { } catch (error) {
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, 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) { async getAccountHeaders(accountId) {
const cacheKey = `claude_code_headers:${accountId}`
// 检查内存缓存
const cached = getCachedConfig(cacheKey)
if (cached) {
return cached
}
try { try {
const key = `claude_code_headers:${accountId}` const data = await redis.getClient().get(cacheKey)
const data = await redis.getClient().get(key)
if (data) { if (data) {
const parsed = JSON.parse(data) const parsed = JSON.parse(data)
logger.debug( logger.debug(
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}` `📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
) )
// 缓存到内存
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
return parsed.headers return parsed.headers
} }
@@ -183,8 +203,10 @@ class ClaudeCodeHeadersService {
*/ */
async clearAccountHeaders(accountId) { async clearAccountHeaders(accountId) {
try { try {
const key = `claude_code_headers:${accountId}` const cacheKey = `claude_code_headers:${accountId}`
await redis.getClient().del(key) await redis.getClient().del(cacheKey)
// 删除内存缓存
deleteCachedConfig(cacheKey)
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`) logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
} catch (error) { } catch (error) {
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, 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() { async getAllAccountHeaders() {
try { try {
const pattern = 'claude_code_headers:*' const pattern = 'claude_code_headers:*'
const keys = await redis.getClient().keys(pattern) const keys = await redis.scanKeys(pattern)
const results = {} const results = {}
for (const key of keys) { for (const key of keys) {

View File

@@ -129,6 +129,7 @@ class ClaudeConsoleAccountService {
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`) logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
await redis.addToIndex('claude_console_account:index', accountId)
// 如果是共享账户,添加到共享账户集合 // 如果是共享账户,添加到共享账户集合
if (accountType === 'shared') { if (accountType === 'shared') {
@@ -167,11 +168,18 @@ class ClaudeConsoleAccountService {
async getAllAccounts() { async getAllAccounts() {
try { try {
const client = redis.getClientSafe() 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 accounts = []
const dataList = await redis.batchHgetallChunked(keys)
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const accountData = await client.hgetall(key) const key = keys[i]
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
if (!accountData.id) { if (!accountData.id) {
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`) logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`)
@@ -449,6 +457,7 @@ class ClaudeConsoleAccountService {
// 从Redis删除 // 从Redis删除
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
await redis.removeFromIndex('claude_console_account:index', accountId)
// 从共享账户集合中移除 // 从共享账户集合中移除
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
@@ -577,7 +586,7 @@ class ClaudeConsoleAccountService {
} }
await client.hset(accountKey, updateData) 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 { } else {
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) { 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` ` 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 } return { success: true }
@@ -858,7 +867,7 @@ class ClaudeConsoleAccountService {
} }
await client.hset(accountKey, updateData) 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 { } else {
if (await client.hdel(accountKey, 'blockedAutoStopped')) { 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` ` 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 } return { success: true }
@@ -967,7 +976,7 @@ class ClaudeConsoleAccountService {
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') 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 } return { success: true }
} catch (error) { } catch (error) {
logger.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) { } catch (error) {
logger.error('Failed to reset all daily usage:', error) logger.error('Failed to reset all daily usage:', error)
} }
@@ -1489,7 +1498,7 @@ class ClaudeConsoleAccountService {
await client.hset(accountKey, updates) await client.hset(accountKey, updates)
await client.hdel(accountKey, ...fieldsToDelete) 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 通知 // 发送 Webhook 通知
try { try {

View File

@@ -18,8 +18,8 @@ const DEFAULT_CONFIG = {
// 用户消息队列配置 // 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭) userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒) userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待 userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
userMessageQueueLockTtlMs: 5000, // 锁TTL毫秒请求发送后立即释放无需长TTL userMessageQueueLockTtlMs: 120000, // 锁TTL毫秒
// 并发请求排队配置 // 并发请求排队配置
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭) concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3 concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3

View File

@@ -1,6 +1,5 @@
const https = require('https') const https = require('https')
const zlib = require('zlib') const zlib = require('zlib')
const fs = require('fs')
const path = require('path') const path = require('path')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const { filterForClaude } = require('../utils/headerFilter') const { filterForClaude } = require('../utils/headerFilter')
@@ -17,6 +16,15 @@ const requestIdentityService = require('./requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper') const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const userMessageQueueService = require('./userMessageQueueService') const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper') 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 { class ClaudeRelayService {
constructor() { constructor() {
@@ -946,8 +954,8 @@ class ClaudeRelayService {
return body return body
} }
// 深拷贝请求体 // 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能
const processedBody = JSON.parse(JSON.stringify(body)) const processedBody = safeClone(body)
// 验证并限制max_tokens参数 // 验证并限制max_tokens参数
this._validateAndLimitMaxTokens(processedBody) this._validateAndLimitMaxTokens(processedBody)
@@ -1077,15 +1085,15 @@ class ClaudeRelayService {
} }
try { try {
// 读取模型定价配置文件 // 使用缓存的定价数据
const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json') 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') logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation')
return return
} }
const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
const model = body.model || 'claude-sonnet-4-20250514' const model = body.model || 'claude-sonnet-4-20250514'
// 查找对应模型的配置 // 查找对应模型的配置
@@ -1251,20 +1259,20 @@ class ClaudeRelayService {
} }
// 🌐 获取代理Agent使用统一的代理工具 // 🌐 获取代理Agent使用统一的代理工具
async _getProxyAgent(accountId) { async _getProxyAgent(accountId, account = null) {
try { try {
const accountData = await claudeAccountService.getAllAccounts() // 优先使用传入的 account 对象,避免重复查询
const account = accountData.find((acc) => acc.id === accountId) const accountData = account || (await claudeAccountService.getAccount(accountId))
if (!account || !account.proxy) { if (!accountData || !accountData.proxy) {
logger.debug('🌐 No proxy configured for Claude account') logger.debug('🌐 No proxy configured for Claude account')
return null return null
} }
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy) const proxyAgent = ProxyHelper.createProxyAgent(accountData.proxy)
if (proxyAgent) { if (proxyAgent) {
logger.info( logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}` `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}`
) )
} }
return proxyAgent return proxyAgent
@@ -1361,7 +1369,7 @@ class ClaudeRelayService {
headers['User-Agent'] = userAgent headers['User-Agent'] = userAgent
headers['Accept'] = acceptHeader headers['Accept'] = acceptHeader
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`) logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header // 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model const modelId = requestPayload?.model || body?.model
@@ -1456,19 +1464,22 @@ class ClaudeRelayService {
path: requestPath + (url.search || ''), path: requestPath + (url.search || ''),
method: 'POST', method: 'POST',
headers, headers,
agent: proxyAgent, agent: proxyAgent || getHttpsAgentForNonStream(),
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
let responseData = Buffer.alloc(0) // 使用数组收集 chunks避免 O(n²) 的 Buffer.concat
const chunks = []
res.on('data', (chunk) => { res.on('data', (chunk) => {
responseData = Buffer.concat([responseData, chunk]) chunks.push(chunk)
}) })
res.on('end', () => { res.on('end', () => {
try { try {
// 一次性合并所有 chunks
const responseData = Buffer.concat(chunks)
let responseBody = '' let responseBody = ''
// 根据Content-Encoding处理响应数据 // 根据Content-Encoding处理响应数据
@@ -1871,7 +1882,7 @@ class ClaudeRelayService {
path: url.pathname + (url.search || ''), path: url.pathname + (url.search || ''),
method: 'POST', method: 'POST',
headers, headers,
agent: proxyAgent, agent: proxyAgent || getHttpsAgentForStream(),
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }

View File

@@ -1,9 +1,65 @@
const redis = require('../models/redis') const redis = require('../models/redis')
const apiKeyService = require('./apiKeyService')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../utils/costCalculator')
const logger = require('../utils/logger') const logger = require('../utils/logger')
// HMGET 需要的字段
const USAGE_FIELDS = [
'totalInputTokens',
'inputTokens',
'totalOutputTokens',
'outputTokens',
'totalCacheCreateTokens',
'cacheCreateTokens',
'totalCacheReadTokens',
'cacheReadTokens'
]
class CostInitService { 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的费用数据 * 初始化所有API Key的费用数据
* 扫描历史使用记录并计算费用 * 扫描历史使用记录并计算费用
@@ -12,25 +68,57 @@ class CostInitService {
try { try {
logger.info('💰 Starting cost initialization for all API Keys...') 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() 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 processedCount = 0
let errorCount = 0 let errorCount = 0
for (const apiKey of apiKeys) { // 优化6: 并行处理 + 并发限制
await this.parallelLimit(
apiKeyIds,
async (apiKeyId) => {
try { try {
await this.initializeApiKeyCosts(apiKey.id, client) await this.initializeApiKeyCosts(apiKeyId, client)
processedCount++ processedCount++
if (processedCount % 10 === 0) { if (processedCount % 100 === 0) {
logger.info(`💰 Processed ${processedCount} API Keys...`) logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
} }
} catch (error) { } catch (error) {
errorCount++ 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( logger.success(
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}` `💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
@@ -46,16 +134,55 @@ class CostInitService {
* 初始化单个API Key的费用数据 * 初始化单个API Key的费用数据
*/ */
async initializeApiKeyCosts(apiKeyId, client) { async initializeApiKeyCosts(apiKeyId, client) {
// 获取所有时间的模型使用统计 // 优化4: 使用 SCAN 获取 keys带去重
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`) 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 dailyCosts = new Map()
const monthlyCosts = new Map() // month -> cost const monthlyCosts = new Map()
const hourlyCosts = new Map() // hour -> cost const hourlyCosts = new Map()
for (const key of modelKeys) { for (const { key, data } of allData) {
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
const match = key.match( const match = key.match(
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/ /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 [, , period, model, dateStr] = match
// 获取使用数据
const data = await client.hgetall(key)
if (!data || Object.keys(data).length === 0) {
continue
}
// 计算费用
const usage = { const usage = {
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
@@ -84,47 +204,34 @@ class CostInitService {
const costResult = CostCalculator.calculateCost(usage, model) const costResult = CostCalculator.calculateCost(usage, model)
const cost = costResult.costs.total const cost = costResult.costs.total
// 根据period分组累加费用
if (period === 'daily') { if (period === 'daily') {
const currentCost = dailyCosts.get(dateStr) || 0 dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
dailyCosts.set(dateStr, currentCost + cost)
} else if (period === 'monthly') { } else if (period === 'monthly') {
const currentCost = monthlyCosts.get(dateStr) || 0 monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
monthlyCosts.set(dateStr, currentCost + cost)
} else if (period === 'hourly') { } else if (period === 'hourly') {
const currentCost = hourlyCosts.get(dateStr) || 0 hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
hourlyCosts.set(dateStr, currentCost + cost)
} }
} }
// 将计算出的费用写入Redis // 使用 SET NX EX 只补缺失的键,不覆盖已存在的
const promises = [] const pipeline = client.pipeline()
// 写入每日费用 // 写入每日费用(只补缺失)
for (const [date, cost] of dailyCosts) { for (const [date, cost] of dailyCosts) {
const key = `usage:cost:daily:${apiKeyId}:${date}` const key = `usage:cost:daily:${apiKeyId}:${date}`
promises.push( pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
client.set(key, cost.toString()),
client.expire(key, 86400 * 30) // 30天过期
)
} }
// 写入每月费用 // 写入每月费用(只补缺失)
for (const [month, cost] of monthlyCosts) { for (const [month, cost] of monthlyCosts) {
const key = `usage:cost:monthly:${apiKeyId}:${month}` const key = `usage:cost:monthly:${apiKeyId}:${month}`
promises.push( pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
client.set(key, cost.toString()),
client.expire(key, 86400 * 90) // 90天过期
)
} }
// 写入每小时费用 // 写入每小时费用(只补缺失)
for (const [hour, cost] of hourlyCosts) { for (const [hour, cost] of hourlyCosts) {
const key = `usage:cost:hourly:${apiKeyId}:${hour}` const key = `usage:cost:hourly:${apiKeyId}:${hour}`
promises.push( pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
client.set(key, cost.toString()),
client.expire(key, 86400 * 7) // 7天过期
)
} }
// 计算总费用 // 计算总费用
@@ -133,37 +240,25 @@ class CostInitService {
totalCost += cost totalCost += cost
} }
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值 // 写入总费用(只补缺失)
if (totalCost > 0) { if (totalCost > 0) {
const totalKey = `usage:cost:total:${apiKeyId}` const totalKey = `usage:cost:total:${apiKeyId}`
// 先检查总费用是否已存在
const existingTotal = await client.get(totalKey) const existingTotal = await client.get(totalKey)
if (!existingTotal || parseFloat(existingTotal) === 0) { if (!existingTotal || parseFloat(existingTotal) === 0) {
// 仅在总费用不存在或为0时才初始化 pipeline.set(totalKey, totalCost.toString())
promises.push(client.set(totalKey, totalCost.toString()))
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`) logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
} else { } else {
// 如果总费用已存在,保持不变,避免覆盖累计值
// 注意这个逻辑防止因每日费用键过期30天导致的错误覆盖
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
const existing = parseFloat(existingTotal) const existing = parseFloat(existingTotal)
const calculated = totalCost if (totalCost > existing * 1.1) {
if (calculated > existing * 1.1) {
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
logger.warn( 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.` `💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
)
} else {
logger.debug(
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
) )
} }
} }
} }
await Promise.all(promises) await pipeline.exec()
logger.debug( logger.debug(
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}` `💰 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() { async needsInitialization() {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
// 检查是否有任何费用数据 // 正确循环 SCAN 检查是否有任何费用数据
const costKeys = await client.keys('usage:cost:*') let cursor = '0'
let hasCostData = false
// 如果没有费用数据,需要初始化 do {
if (costKeys.length === 0) { 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') logger.info('💰 No cost data found, initialization needed')
return true return true
} }
// 检查是否有使用数据但没有对应的费用数据 // 抽样检查使用数据是否有对应的费用数据
const sampleKeys = await client.keys('usage:*:model:daily:*:*') cursor = '0'
if (sampleKeys.length > 10) { let samplesChecked = 0
// 抽样检查 const maxSamples = 10
const sampleSize = Math.min(10, sampleKeys.length)
for (let i = 0; i < sampleSize; i++) { do {
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)] 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})$/) const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) { if (match) {
const [, keyId, , date] = match const [, keyId, , date] = match
const costKey = `usage:cost:daily:${keyId}:${date}` const costKey = `usage:cost:daily:${keyId}:${date}`
const hasCost = await client.exists(costKey) const hasCost = await client.exists(costKey)
if (!hasCost) { if (!hasCost) {
logger.info( logger.info(
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed` `💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
) )
return true return true
} }
samplesChecked++
} }
} }
if (samplesChecked >= maxSamples) {
break
} }
} while (cursor !== '0')
logger.info('💰 Cost data appears to be up to date') logger.info('💰 Cost data appears to be up to date')
return false return false

View File

@@ -103,7 +103,7 @@ class CostRankService {
} }
this.isInitialized = true this.isInitialized = true
logger.success('CostRankService initialized') logger.success('CostRankService initialized')
} catch (error) { } catch (error) {
logger.error('❌ Failed to initialize CostRankService:', error) logger.error('❌ Failed to initialize CostRankService:', error)
throw error throw error
@@ -391,10 +391,24 @@ class CostRankService {
return {} return {}
} }
const status = {} // 使用 Pipeline 批量获取
const pipeline = client.pipeline()
for (const timeRange of VALID_TIME_RANGES) { 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] = { status[timeRange] = {
lastUpdate: meta.lastUpdate || null, lastUpdate: meta.lastUpdate || null,
keyCount: parseInt(meta.keyCount || 0), keyCount: parseInt(meta.keyCount || 0),
@@ -402,6 +416,7 @@ class CostRankService {
updateDuration: parseInt(meta.updateDuration || 0) updateDuration: parseInt(meta.updateDuration || 0)
} }
} }
})
return status return status
} }

View File

@@ -2,11 +2,10 @@ const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const LRUCache = require('../utils/lruCache') const { createEncryptor, isTruthy } = require('../utils/commonHelper')
/** /**
* Droid 账户管理服务 * Droid 账户管理服务
@@ -26,21 +25,14 @@ class DroidAccountService {
this.refreshIntervalHours = 6 // 每6小时刷新一次 this.refreshIntervalHours = 6 // 每6小时刷新一次
this.tokenValidHours = 8 // Token 有效期8小时 this.tokenValidHours = 8 // Token 有效期8小时
// 加密相关常量 // 使用 commonHelper 的加密器
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' this._encryptor = createEncryptor('droid-account-salt')
this.ENCRYPTION_SALT = 'droid-account-salt'
// 🚀 性能优化:缓存派生的加密密钥
this._encryptionKeyCache = null
// 🔄 解密结果缓存
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
this._decryptCache.cleanup() this._encryptor.clearCache()
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats()) logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -69,92 +61,19 @@ class DroidAccountService {
return 'anthropic' return 'anthropic'
} }
// 使用 commonHelper 的 isTruthy
_isTruthy(value) { _isTruthy(value) {
if (value === undefined || value === null) { return isTruthy(value)
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)
} }
/** // 加密敏感数据
* 生成加密密钥(缓存优化)
*/
_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) { _encryptSensitiveData(text) {
if (!text) { return this._encryptor.encrypt(text)
return ''
} }
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) { _decryptSensitiveData(encryptedText) {
if (!encryptedText) { return this._encryptor.decrypt(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 ''
}
} }
_parseApiKeyEntries(rawEntries) { _parseApiKeyEntries(rawEntries) {
@@ -683,7 +602,7 @@ class DroidAccountService {
lastRefreshAt = new Date().toISOString() lastRefreshAt = new Date().toISOString()
status = 'active' status = 'active'
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`) logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
} catch (error) { } catch (error) {
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
throw new Error(`Refresh Token 验证失败:${error.message}`) 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 { return {
accessToken: refreshed.accessToken, accessToken: refreshed.accessToken,

View File

@@ -90,7 +90,7 @@ class DroidRelayService {
return normalizedBody return normalizedBody
} }
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) {
if (!rateLimitInfo) { if (!rateLimitInfo) {
return return
} }
@@ -99,7 +99,9 @@ class DroidRelayService {
const { totalTokens, totalCost } = await updateRateLimitCounters( const { totalTokens, totalCost } = await updateRateLimitCounters(
rateLimitInfo, rateLimitInfo,
usageSummary, usageSummary,
model model,
keyId,
'droid'
) )
if (totalTokens > 0) { if (totalTokens > 0) {
@@ -403,6 +405,7 @@ class DroidRelayService {
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(apiUrl) const url = new URL(apiUrl)
const keyId = apiKeyData?.id
const bodyString = JSON.stringify(processedBody) const bodyString = JSON.stringify(processedBody)
const contentLength = Buffer.byteLength(bodyString) const contentLength = Buffer.byteLength(bodyString)
const requestHeaders = { const requestHeaders = {
@@ -606,10 +609,11 @@ class DroidRelayService {
clientRequest?.rateLimitInfo, clientRequest?.rateLimitInfo,
usageSummary, usageSummary,
model, model,
' [stream]' ' [stream]',
keyId
) )
logger.success(`Droid stream completed - Account: ${account.name}`) logger.success(`Droid stream completed - Account: ${account.name}`)
} else { } else {
logger.success( logger.success(
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped` `✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
@@ -1195,6 +1199,7 @@ class DroidRelayService {
skipUsageRecord = false skipUsageRecord = false
) { ) {
const { data } = response const { data } = response
const keyId = apiKeyData?.id
// 从响应中提取 usage 数据 // 从响应中提取 usage 数据
const usage = data.usage || {} const usage = data.usage || {}
@@ -1225,7 +1230,8 @@ class DroidRelayService {
clientRequest?.rateLimitInfo, clientRequest?.rateLimitInfo,
usageSummary, usageSummary,
model, model,
endpointLabel endpointLabel,
keyId
) )
logger.success( logger.success(

View File

@@ -2,103 +2,40 @@ const droidAccountService = require('./droidAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const {
isTruthy,
isAccountHealthy,
sortAccountsByPriority,
normalizeEndpointType
} = require('../utils/commonHelper')
class DroidScheduler { class DroidScheduler {
constructor() { constructor() {
this.STICKY_PREFIX = 'droid' 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) { _isAccountSchedulable(account) {
return this._isTruthy(account?.schedulable ?? true) return isTruthy(account?.schedulable ?? true)
} }
_matchesEndpoint(account, endpointType) { _matchesEndpoint(account, endpointType) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType) const normalizedEndpoint = normalizeEndpointType(endpointType)
const accountEndpoint = this._normalizeEndpointType(account?.endpointType) const accountEndpoint = normalizeEndpointType(account?.endpointType)
if (normalizedEndpoint === accountEndpoint) { if (normalizedEndpoint === accountEndpoint) {
return true return true
} }
// comm 端点可以使用任何类型的账户
if (normalizedEndpoint === 'comm') { if (normalizedEndpoint === 'comm') {
return true return true
} }
const sharedEndpoints = new Set(['anthropic', 'openai']) const sharedEndpoints = new Set(['anthropic', 'openai'])
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint) 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) { _composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
if (!sessionHash) { if (!sessionHash) {
return null return null
} }
const normalizedEndpoint = this._normalizeEndpointType(endpointType) const normalizedEndpoint = normalizeEndpointType(endpointType)
const apiKeyPart = apiKeyId || 'default' const apiKeyPart = apiKeyId || 'default'
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
} }
@@ -121,7 +58,7 @@ class DroidScheduler {
) )
return accounts.filter( 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) { async selectAccount(apiKeyData, endpointType, sessionHash) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType) const normalizedEndpoint = normalizeEndpointType(endpointType)
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id) const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
let candidates = [] let candidates = []
@@ -175,7 +112,7 @@ class DroidScheduler {
const filtered = candidates.filter( const filtered = candidates.filter(
(account) => (account) =>
account && account &&
this._isAccountActive(account) && isAccountHealthy(account) &&
this._isAccountSchedulable(account) && this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint) this._matchesEndpoint(account, normalizedEndpoint)
) )
@@ -203,7 +140,7 @@ class DroidScheduler {
} }
} }
const sorted = this._sortCandidates(filtered) const sorted = sortAccountsByPriority(filtered)
const selected = sorted[0] const selected = sorted[0]
if (!selected) { if (!selected) {

View File

@@ -1,8 +1,6 @@
const redisClient = require('../models/redis') const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const https = require('https') const https = require('https')
const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library') const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
@@ -15,9 +13,14 @@ const {
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache') const { createEncryptor } = require('../utils/commonHelper')
const antigravityClient = require('./antigravityClient') 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 应用 // Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' 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') 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( async function fetchAvailableModelsAntigravity(
accessToken, accessToken,
proxyConfig = null, proxyConfig = null,
@@ -196,91 +203,11 @@ async function countTokensAntigravity(client, contents, model, proxyConfig = nul
return response 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分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
decryptCache.cleanup() encryptor.clearCache()
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats()) logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -605,6 +532,7 @@ async function createAccount(accountData) {
// 保存到 Redis // 保存到 Redis
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account) await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
await redisClient.addToIndex('gemini_account:index', id)
// 如果是共享账户,添加到共享账户集合 // 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
@@ -806,19 +734,20 @@ async function deleteAccount(accountId) {
// 从 Redis 删除 // 从 Redis 删除
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
await redisClient.removeFromIndex('gemini_account:index', accountId)
// 从共享账户集合中移除 // 从共享账户集合中移除
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
} }
// 清理会话映射 // 清理会话映射(使用反向索引)
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
for (const key of sessionMappings) { if (sessionHashes.length > 0) {
const mappedAccountId = await client.get(key) const pipeline = client.pipeline()
if (mappedAccountId === accountId) { sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
await client.del(key) pipeline.del(`gemini_account_sessions:${accountId}`)
} await pipeline.exec()
} }
logger.info(`Deleted Gemini account: ${accountId}`) logger.info(`Deleted Gemini account: ${accountId}`)
@@ -827,12 +756,18 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const client = redisClient.getClientSafe() const _client = redisClient.getClientSafe()
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`) 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 accounts = []
const dataList = await redisClient.batchHgetallChunked(keys)
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const accountData = await client.hgetall(key) const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息 // 获取限流状态信息
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
@@ -935,6 +870,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期 3600, // 1小时过期
account.id account.id
) )
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
} }
return account return account
@@ -994,6 +931,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
// 创建粘性会话映射 // 创建粘性会话映射
if (sessionHash) { if (sessionHash) {
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id) 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 return selectedAccount
@@ -1950,8 +1889,7 @@ module.exports = {
setupUser, setupUser,
encrypt, encrypt,
decrypt, decrypt,
generateEncryptionKey, encryptor, // 暴露加密器以便测试和监控
decryptCache, // 暴露缓存对象以便测试和监控
countTokens, countTokens,
countTokensAntigravity, countTokensAntigravity,
generateContent, generateContent,

View File

@@ -85,7 +85,7 @@ class GeminiApiAccountService {
// 保存到 Redis // 保存到 Redis
await this._saveAccount(accountId, accountData) await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`) logger.success(`Created Gemini-API account: ${name} (${accountId})`)
return { return {
...accountData, ...accountData,
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
// 从共享账户列表中移除 // 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 从索引中移除
await redis.removeFromIndex('gemini_api_account:index', accountId)
// 删除账户数据 // 删除账户数据
await client.del(key) await client.del(key)
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
} }
// 直接从 Redis 获取所有账户(包括非共享账户) // 直接从 Redis 获取所有账户(包括非共享账户)
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) const allAccountIds = await redis.getAllIdsByIndex(
for (const key of keys) { 'gemini_api_account:index',
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '') `${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)) { if (!accountIds.includes(accountId)) {
const accountData = await client.hgetall(key) const accountData = dataList[i]
if (accountData && accountData.id) { if (accountData && accountData.id) {
// 过滤非活跃账户 // 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') { if (includeInactive || accountData.isActive === 'true') {
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
// 保存账户数据 // 保存账户数据
await client.hset(key, accountData) await client.hset(key, accountData)
// 添加到索引
await redis.addToIndex('gemini_api_account:index', accountId)
// 添加到共享账户列表 // 添加到共享账户列表
if (accountData.accountType === 'shared') { if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)

View File

@@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null
0, // cacheCreateTokens (Gemini 没有这个概念) 0, // cacheCreateTokens (Gemini 没有这个概念)
0, // cacheReadTokens (Gemini 没有这个概念) 0, // cacheReadTokens (Gemini 没有这个概念)
model, model,
accountId accountId,
'gemini'
) )
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record Gemini usage:', error) logger.error('❌ Failed to record Gemini usage:', error)
@@ -317,7 +318,8 @@ async function sendGeminiRequest({
0, // cacheCreateTokens 0, // cacheCreateTokens
0, // cacheReadTokens 0, // cacheReadTokens
model, model,
accountId accountId,
'gemini'
) )
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record Gemini usage:', error) logger.error('❌ Failed to record Gemini usage:', error)

View File

@@ -18,7 +18,7 @@ class ModelService {
(sum, config) => sum + config.models.length, (sum, config) => sum + config.models.length,
0 0
) )
logger.success(`Model service initialized with ${totalModels} models`) logger.success(`Model service initialized with ${totalModels} models`)
} }
/** /**

View File

@@ -1,6 +1,5 @@
const redisClient = require('../models/redis') const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const config = require('../../config/config') const config = require('../../config/config')
@@ -13,104 +12,23 @@ const {
logTokenUsage, logTokenUsage,
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('./tokenRefreshService')
const { createEncryptor } = require('../utils/commonHelper')
// 加密相关常量 // 使用 commonHelper 的加密器
const ALGORITHM = 'aes-256-cbc' const encryptor = createEncryptor('openai-account-salt')
const ENCRYPTION_SALT = 'openai-account-salt' const { encrypt, decrypt } = encryptor
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
}
// OpenAI 账户键前缀 // OpenAI 账户键前缀
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:' const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts' const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:' 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分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
decryptCache.cleanup() encryptor.clearCache()
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats()) logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
await redisClient.addToIndex('openai:account:index', accountId)
// 如果是共享账户,添加到共享账户集合 // 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
// 从 Redis 删除 // 从 Redis 删除
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
await redisClient.removeFromIndex('openai:account:index', accountId)
// 从共享账户集合中移除 // 从共享账户集合中移除
if (account.accountType === 'shared') { if (account.accountType === 'shared') {
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
} }
// 清理会话映射 // 清理会话映射(使用反向索引)
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
for (const key of sessionMappings) { if (sessionHashes.length > 0) {
const mappedAccountId = await client.get(key) const pipeline = client.pipeline()
if (mappedAccountId === accountId) { sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
await client.del(key) pipeline.del(`openai_account_sessions:${accountId}`)
} await pipeline.exec()
} }
logger.info(`Deleted OpenAI account: ${accountId}`) logger.info(`Deleted OpenAI account: ${accountId}`)
@@ -746,12 +666,18 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const client = redisClient.getClientSafe() const _client = redisClient.getClientSafe()
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`) 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 accounts = []
const dataList = await redisClient.batchHgetallChunked(keys)
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const accountData = await client.hgetall(key) const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
const codexUsage = buildCodexUsageSnapshot(accountData) const codexUsage = buildCodexUsageSnapshot(accountData)
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期 3600, // 1小时过期
account.id account.id
) )
// 反向索引accountId -> sessionHash用于删除账户时快速清理
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
await client.expire(`openai_account_sessions:${account.id}`, 3600)
} }
return account return account
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
3600, // 1小时过期 3600, // 1小时过期
selectedAccount.id selectedAccount.id
) )
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
} }
return selectedAccount return selectedAccount
@@ -1278,6 +1209,5 @@ module.exports = {
updateCodexUsageSnapshot, updateCodexUsageSnapshot,
encrypt, encrypt,
decrypt, decrypt,
generateEncryptionKey, encryptor // 暴露加密器以便测试和监控
decryptCache // 暴露缓存对象以便测试和监控
} }

View File

@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
// 保存到 Redis // 保存到 Redis
await this._saveAccount(accountId, accountData) await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`) logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
return { return {
...accountData, ...accountData,
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
// 从共享账户列表中移除 // 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 从索引中移除
await redis.removeFromIndex('openai_responses_account:index', accountId)
// 删除账户数据 // 删除账户数据
await client.del(key) await client.del(key)
@@ -191,71 +194,48 @@ class OpenAIResponsesAccountService {
// 获取所有账户 // 获取所有账户
async getAllAccounts(includeInactive = false) { async getAllAccounts(includeInactive = false) {
const client = redis.getClientSafe() 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 = [] 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') { if (!includeInactive && accountData.isActive !== 'true') {
// 隐藏敏感信息 return
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
} }
// 转换 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 = '***' accountData.apiKey = '***'
// 解析 JSON 字段 // 解析 JSON 字段
if (accountData.proxy) { if (accountData.proxy) {
try { try {
accountData.proxy = JSON.parse(accountData.proxy) accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) { } catch {
accountData.proxy = null accountData.proxy = null
} }
} }
// 获取限流状态信息与普通OpenAI账号保持一致的格式 // 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData) const rateLimitInfo = this._getRateLimitInfo(accountData)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
accountData.rateLimitStatus = rateLimitInfo.isRateLimited accountData.rateLimitStatus = rateLimitInfo.isRateLimited
? { ? {
isRateLimited: true, isRateLimited: true,
@@ -268,20 +248,14 @@ class OpenAIResponsesAccountService {
minutesRemaining: 0 minutesRemaining: 0
} }
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断) // 转换字段类型
accountData.schedulable = accountData.schedulable !== 'false' accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true' accountData.isActive = accountData.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
accountData.expiresAt = accountData.subscriptionExpiresAt || null accountData.expiresAt = accountData.subscriptionExpiresAt || null
accountData.platform = accountData.platform || 'openai-responses' accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData) accounts.push(accountData)
} })
}
}
}
return accounts return accounts
} }
@@ -644,6 +618,9 @@ class OpenAIResponsesAccountService {
// 保存账户数据 // 保存账户数据
await client.hset(key, accountData) await client.hset(key, accountData)
// 添加到索引
await redis.addToIndex('openai_responses_account:index', accountId)
// 添加到共享账户列表 // 添加到共享账户列表
if (accountData.accountType === 'shared') { if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)

View File

@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const config = require('../../config/config') const config = require('../../config/config')
const crypto = require('crypto') 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兼容多种字段命名 // 抽取缓存写入 token兼容多种字段命名
function extractCacheCreationTokens(usageData) { function extractCacheCreationTokens(usageData) {
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
this.defaultTimeout = config.requestTimeout || 600000 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) { async handleRequest(req, res, account, apiKeyData) {
let abortController = null let abortController = null
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
return res.status(response.status).json(errorData) return res.status(response.status).json(errorData)
} }
// 更新最后使用时间 // 更新最后使用时间(节流)
await openaiResponsesAccountService.updateAccount(account.id, { await this._throttledUpdateLastUsedAt(account.id)
lastUsedAt: new Date().toISOString()
})
// 处理流式响应 // 处理流式响应
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') { if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
@@ -539,7 +557,8 @@ class OpenAIResponsesRelayService {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
modelToRecord, modelToRecord,
account.id account.id,
'openai-responses'
) )
logger.info( logger.info(
@@ -667,7 +686,8 @@ class OpenAIResponsesRelayService {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
actualModel, actualModel,
account.id account.id,
'openai-responses'
) )
logger.info( logger.info(

View File

@@ -105,7 +105,7 @@ class PricingService {
// 设置文件监听器 // 设置文件监听器
this.setupFileWatcher() this.setupFileWatcher()
logger.success('💰 Pricing service initialized successfully') logger.success('Pricing service initialized successfully')
} catch (error) { } catch (error) {
logger.error('❌ Failed to initialize pricing service:', error) logger.error('❌ Failed to initialize pricing service:', error)
} }
@@ -298,7 +298,7 @@ class PricingService {
this.pricingData = jsonData this.pricingData = jsonData
this.lastUpdated = new Date() 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() this.setupFileWatcher()
@@ -762,7 +762,7 @@ class PricingService {
this.lastUpdated = new Date() this.lastUpdated = new Date()
const modelCount = Object.keys(jsonData).length 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 const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length

View 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()

View 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()

View File

@@ -6,6 +6,7 @@ const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper') const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
/** /**
* Check if account is Pro (not Max) * Check if account is Pro (not Max)
@@ -38,16 +39,6 @@ class UnifiedClaudeScheduler {
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:' 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 = '') { _isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
if (!requestedModel) { if (!requestedModel) {
@@ -286,7 +277,7 @@ class UnifiedClaudeScheduler {
throw error throw error
} }
if (!this._isSchedulable(boundAccount.schedulable)) { if (!isSchedulable(boundAccount.schedulable)) {
logger.warn( logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
) )
@@ -319,7 +310,7 @@ class UnifiedClaudeScheduler {
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable) isSchedulable(boundConsoleAccount.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -354,7 +345,7 @@ class UnifiedClaudeScheduler {
if ( if (
boundBedrockAccountResult.success && boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true && boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable) isSchedulable(boundBedrockAccountResult.data.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -436,7 +427,7 @@ class UnifiedClaudeScheduler {
} }
// 按优先级和最后使用时间排序 // 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableAccounts) const sortedAccounts = sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -496,7 +487,7 @@ class UnifiedClaudeScheduler {
throw error throw error
} }
if (!this._isSchedulable(boundAccount.schedulable)) { if (!isSchedulable(boundAccount.schedulable)) {
logger.warn( logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})` `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
) )
@@ -530,7 +521,7 @@ class UnifiedClaudeScheduler {
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable) isSchedulable(boundConsoleAccount.schedulable)
) { ) {
// 主动触发一次额度检查 // 主动触发一次额度检查
try { try {
@@ -579,7 +570,7 @@ class UnifiedClaudeScheduler {
if ( if (
boundBedrockAccountResult.success && boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true && boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable) isSchedulable(boundBedrockAccountResult.data.schedulable)
) { ) {
logger.info( logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
@@ -609,7 +600,7 @@ class UnifiedClaudeScheduler {
account.status !== 'blocked' && account.status !== 'blocked' &&
account.status !== 'temp_error' && account.status !== 'temp_error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 检查是否可调度 // 检查是否可调度
@@ -691,7 +682,7 @@ class UnifiedClaudeScheduler {
currentAccount.isActive === true && currentAccount.isActive === true &&
currentAccount.status === 'active' && currentAccount.status === 'active' &&
currentAccount.accountType === 'shared' && currentAccount.accountType === 'shared' &&
this._isSchedulable(currentAccount.schedulable) isSchedulable(currentAccount.schedulable)
) { ) {
// 检查是否可调度 // 检查是否可调度
@@ -826,7 +817,7 @@ class UnifiedClaudeScheduler {
if ( if (
account.isActive === true && account.isActive === true &&
account.accountType === 'shared' && account.accountType === 'shared' &&
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -870,7 +861,7 @@ class UnifiedClaudeScheduler {
account.isActive === true && account.isActive === true &&
account.status === 'active' && account.status === 'active' &&
account.accountType === 'shared' && account.accountType === 'shared' &&
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
@@ -949,21 +940,6 @@ class UnifiedClaudeScheduler {
return availableAccounts 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) { async _isAccountAvailable(accountId, accountType, requestedModel = null) {
try { try {
@@ -978,7 +954,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 Account ${accountId} is not schedulable`) logger.info(`🚫 Account ${accountId} is not schedulable`)
return false return false
} }
@@ -1029,7 +1005,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false return false
} }
@@ -1093,7 +1069,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(accountResult.data.schedulable)) { if (!isSchedulable(accountResult.data.schedulable)) {
logger.info(`🚫 Bedrock account ${accountId} is not schedulable`) logger.info(`🚫 Bedrock account ${accountId} is not schedulable`)
return false return false
} }
@@ -1113,7 +1089,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 CCR account ${accountId} is not schedulable`) logger.info(`🚫 CCR account ${accountId} is not schedulable`)
return false return false
} }
@@ -1544,7 +1520,7 @@ class UnifiedClaudeScheduler {
? account.status === 'active' ? account.status === 'active'
: 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')) { if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
continue continue
@@ -1594,7 +1570,7 @@ class UnifiedClaudeScheduler {
} }
// 使用现有的优先级排序逻辑 // 使用现有的优先级排序逻辑
const sortedAccounts = this._sortAccountsByPriority(availableAccounts) const sortedAccounts = sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -1664,7 +1640,7 @@ class UnifiedClaudeScheduler {
} }
// 3. 按优先级和最后使用时间排序 // 3. 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts) const sortedAccounts = sortAccountsByPriority(availableCcrAccounts)
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
// 4. 建立会话映射 // 4. 建立会话映射
@@ -1710,7 +1686,7 @@ class UnifiedClaudeScheduler {
account.isActive === true && account.isActive === true &&
account.status === 'active' && account.status === 'active' &&
account.accountType === 'shared' && account.accountType === 'shared' &&
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {

View File

@@ -3,6 +3,7 @@ const geminiApiAccountService = require('./geminiApiAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper')
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
@@ -44,9 +45,9 @@ class UnifiedGeminiScheduler {
} }
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值) // 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
_isActive(isActive) { _isActive(activeValue) {
// 兼容布尔值 true 和字符串 'true' // 兼容布尔值 true 和字符串 'true'
return isActive === true || isActive === 'true' return activeValue === true || activeValue === 'true'
} }
// 🎯 统一调度Gemini账号 // 🎯 统一调度Gemini账号
@@ -66,11 +67,7 @@ class UnifiedGeminiScheduler {
if (apiKeyData.geminiAccountId.startsWith('api:')) { if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '') const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId) const boundAccount = await geminiApiAccountService.getAccount(accountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
logger.info( logger.info(
`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}` `🎯 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] const selectedAccount = sortedAccounts[0]
@@ -243,11 +240,7 @@ class UnifiedGeminiScheduler {
if (apiKeyData.geminiAccountId.startsWith('api:')) { if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '') const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId) const boundAccount = await geminiApiAccountService.getAccount(accountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
const isRateLimited = await this.isAccountRateLimited(accountId) const isRateLimited = await this.isAccountRateLimited(accountId)
if (!isRateLimited) { if (!isRateLimited) {
// 检查模型支持 // 检查模型支持
@@ -349,10 +342,10 @@ class UnifiedGeminiScheduler {
const geminiAccounts = await geminiAccountService.getAllAccounts() const geminiAccounts = await geminiAccountService.getAllAccounts()
for (const account of geminiAccounts) { for (const account of geminiAccounts) {
if ( if (
this._isActive(account.isActive) && isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
if ( if (
normalizedOauthProvider && normalizedOauthProvider &&
@@ -405,10 +398,10 @@ class UnifiedGeminiScheduler {
const geminiApiAccounts = await geminiApiAccountService.getAllAccounts() const geminiApiAccounts = await geminiApiAccountService.getAllAccounts()
for (const account of geminiApiAccounts) { for (const account of geminiApiAccounts) {
if ( if (
this._isActive(account.isActive) && isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && (account.accountType === 'shared' || !account.accountType) &&
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
@@ -445,42 +438,27 @@ class UnifiedGeminiScheduler {
return availableAccounts 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) { async _isAccountAvailable(accountId, accountType) {
try { try {
if (accountType === 'gemini') { if (accountType === 'gemini') {
const account = await geminiAccountService.getAccount(accountId) 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 return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 Gemini account ${accountId} is not schedulable`) logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
return false return false
} }
return !(await this.isAccountRateLimited(accountId)) return !(await this.isAccountRateLimited(accountId))
} else if (accountType === 'gemini-api') { } else if (accountType === 'gemini-api') {
const account = await geminiApiAccountService.getAccount(accountId) 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 return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`) logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
return false return false
} }
@@ -738,9 +716,9 @@ class UnifiedGeminiScheduler {
// 检查账户是否可用 // 检查账户是否可用
if ( if (
this._isActive(account.isActive) && isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
this._isSchedulable(account.schedulable) isSchedulable(account.schedulable)
) { ) {
// 对于 Gemini OAuth 账户,检查 token 是否过期 // 对于 Gemini OAuth 账户,检查 token 是否过期
if (accountType === 'gemini') { if (accountType === 'gemini') {
@@ -787,7 +765,7 @@ class UnifiedGeminiScheduler {
} }
// 使用现有的优先级排序逻辑 // 使用现有的优先级排序逻辑
const sortedAccounts = this._sortAccountsByPriority(availableAccounts) const sortedAccounts = sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]

View File

@@ -3,42 +3,13 @@ const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
class UnifiedOpenAIScheduler { class UnifiedOpenAIScheduler {
constructor() { constructor() {
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:' 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) { _isRateLimited(rateLimitStatus) {
if (!rateLimitStatus) { if (!rateLimitStatus) {
@@ -85,9 +56,9 @@ class UnifiedOpenAIScheduler {
let rateLimitChecked = false let rateLimitChecked = false
let stillLimited = false let stillLimited = false
let isSchedulable = this._isSchedulable(account.schedulable) const accountSchedulable = isSchedulable(account.schedulable)
if (!isSchedulable) { if (!accountSchedulable) {
if (!hasRateLimitFlag) { if (!hasRateLimitFlag) {
return { canUse: false, reason: 'not_schedulable' } return { canUse: false, reason: 'not_schedulable' }
} }
@@ -104,7 +75,6 @@ class UnifiedOpenAIScheduler {
} else { } else {
account.schedulable = 'true' account.schedulable = 'true'
} }
isSchedulable = true
logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`) 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` const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable`
logger.warn(`⚠️ ${errorMsg}`) logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg) const error = new Error(errorMsg)
@@ -336,7 +306,7 @@ class UnifiedOpenAIScheduler {
} }
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致) // 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts) const sortedAccounts = sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -451,11 +421,12 @@ class UnifiedOpenAIScheduler {
if ( if (
(account.isActive === true || account.isActive === 'true') && (account.isActive === true || account.isActive === 'true') &&
account.status !== 'error' && account.status !== 'error' &&
account.status !== 'rateLimited' &&
(account.accountType === 'shared' || !account.accountType) (account.accountType === 'shared' || !account.accountType)
) { ) {
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus) // 检查 rateLimitStatus 或 status === 'rateLimited'
const schedulable = this._isSchedulable(account.schedulable) const hasRateLimitFlag =
this._hasRateLimitFlag(account.rateLimitStatus) || account.status === 'rateLimited'
const schedulable = isSchedulable(account.schedulable)
if (!schedulable && !hasRateLimitFlag) { if (!schedulable && !hasRateLimitFlag) {
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`) logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`)
@@ -464,9 +435,23 @@ class UnifiedOpenAIScheduler {
let isRateLimitCleared = false let isRateLimitCleared = false
if (hasRateLimitFlag) { if (hasRateLimitFlag) {
// 区分正常限流和历史遗留数据
if (this._hasRateLimitFlag(account.rateLimitStatus)) {
// 有 rateLimitStatus走正常清理逻辑
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
account.id 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) { if (!isRateLimitCleared) {
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`) logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
@@ -544,7 +529,7 @@ class UnifiedOpenAIScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!this._isSchedulable(account.schedulable)) { if (!isSchedulable(account.schedulable)) {
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
return false return false
} }
@@ -905,7 +890,7 @@ class UnifiedOpenAIScheduler {
} }
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致) // 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts) const sortedAccounts = sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]

View File

@@ -10,6 +10,7 @@ const { v4: uuidv4 } = require('uuid')
const redis = require('../models/redis') const redis = require('../models/redis')
const config = require('../../config/config') const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { getCachedConfig, setCachedConfig } = require('../utils/performanceOptimizer')
// 清理任务间隔 // 清理任务间隔
const CLEANUP_INTERVAL_MS = 60000 // 1分钟 const CLEANUP_INTERVAL_MS = 60000 // 1分钟
@@ -19,6 +20,9 @@ const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔 const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子 const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
// 配置缓存 key
const CONFIG_CACHE_KEY = 'user_message_queue_config'
class UserMessageQueueService { class UserMessageQueueService {
constructor() { constructor() {
this.cleanupTimer = null this.cleanupTimer = null
@@ -64,18 +68,23 @@ class UserMessageQueueService {
} }
/** /**
* 获取当前配置(支持 Web 界面配置优先) * 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存
* @returns {Promise<Object>} 配置对象 * @returns {Promise<Object>} 配置对象
*/ */
async getConfig() { async getConfig() {
// 检查缓存
const cached = getCachedConfig(CONFIG_CACHE_KEY)
if (cached) {
return cached
}
// 默认配置(防止 config.userMessageQueue 未定义) // 默认配置(防止 config.userMessageQueue 未定义)
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
const queueConfig = config.userMessageQueue || {} const queueConfig = config.userMessageQueue || {}
const defaults = { const defaults = {
enabled: queueConfig.enabled ?? false, enabled: queueConfig.enabled ?? false,
delayMs: queueConfig.delayMs ?? 200, delayMs: queueConfig.delayMs ?? 200,
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000因为锁持有时间短 timeoutMs: queueConfig.timeoutMs ?? 60000,
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 50005秒足以覆盖请求发送 lockTtlMs: queueConfig.lockTtlMs ?? 120000
} }
// 尝试从 claudeRelayConfigService 获取 Web 界面配置 // 尝试从 claudeRelayConfigService 获取 Web 界面配置
@@ -83,7 +92,7 @@ class UserMessageQueueService {
const claudeRelayConfigService = require('./claudeRelayConfigService') const claudeRelayConfigService = require('./claudeRelayConfigService')
const webConfig = await claudeRelayConfigService.getConfig() const webConfig = await claudeRelayConfigService.getConfig()
return { const result = {
enabled: enabled:
webConfig.userMessageQueueEnabled !== undefined webConfig.userMessageQueueEnabled !== undefined
? webConfig.userMessageQueueEnabled ? webConfig.userMessageQueueEnabled
@@ -101,8 +110,13 @@ class UserMessageQueueService {
? webConfig.userMessageQueueLockTtlMs ? webConfig.userMessageQueueLockTtlMs
: defaults.lockTtlMs : defaults.lockTtlMs
} }
// 缓存配置 30 秒
setCachedConfig(CONFIG_CACHE_KEY, result, 30000)
return result
} catch { } catch {
// 回退到环境变量配置 // 回退到环境变量配置,也缓存
setCachedConfig(CONFIG_CACHE_KEY, defaults, 30000)
return defaults return defaults
} }
} }

View File

@@ -74,6 +74,7 @@ class UserService {
// 保存用户信息 // 保存用户信息
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
await redis.set(`${this.usernamePrefix}${username}`, user.id) await redis.set(`${this.usernamePrefix}${username}`, user.id)
await redis.addToIndex('user:index', user.id)
// 如果是新用户尝试转移匹配的API Keys // 如果是新用户尝试转移匹配的API Keys
if (isNewUser) { 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` `📊 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 // Count only non-deleted API keys for the user's active count(布尔值比较)
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length const activeApiKeyCount = userApiKeys.filter((key) => !key.isDeleted).length
return { return {
totalUsage, totalUsage,
@@ -191,14 +192,18 @@ class UserService {
// 📋 获取所有用户列表(管理员功能) // 📋 获取所有用户列表(管理员功能)
async getAllUsers(options = {}) { async getAllUsers(options = {}) {
try { try {
const client = redis.getClientSafe()
const { page = 1, limit = 20, role, isActive } = options const { page = 1, limit = 20, role, isActive } = options
const pattern = `${this.userPrefix}*` const userIds = await redis.getAllIdsByIndex(
const keys = await client.keys(pattern) 'user:index',
`${this.userPrefix}*`,
/^user:(.+)$/
)
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
const dataList = await redis.batchGetChunked(keys)
const users = [] const users = []
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const userData = await client.get(key) const userData = dataList[i]
if (userData) { if (userData) {
const user = JSON.parse(userData) const user = JSON.parse(userData)
@@ -398,14 +403,15 @@ class UserService {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const pattern = `${this.userSessionPrefix}*` 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) { for (let i = 0; i < keys.length; i++) {
const sessionData = await client.get(key) const sessionData = dataList[i]
if (sessionData) { if (sessionData) {
const session = JSON.parse(sessionData) const session = JSON.parse(sessionData)
if (session.userId === userId) { if (session.userId === userId) {
await client.del(key) await client.del(keys[i])
} }
} }
} }
@@ -454,9 +460,13 @@ class UserService {
// 📊 获取用户统计信息 // 📊 获取用户统计信息
async getUserStats() { async getUserStats() {
try { try {
const client = redis.getClientSafe() const userIds = await redis.getAllIdsByIndex(
const pattern = `${this.userPrefix}*` 'user:index',
const keys = await client.keys(pattern) `${this.userPrefix}*`,
/^user:(.+)$/
)
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
const dataList = await redis.batchGetChunked(keys)
const stats = { const stats = {
totalUsers: 0, totalUsers: 0,
@@ -472,8 +482,8 @@ class UserService {
} }
} }
for (const key of keys) { for (let i = 0; i < keys.length; i++) {
const userData = await client.get(key) const userData = dataList[i]
if (userData) { if (userData) {
const user = JSON.parse(userData) const user = JSON.parse(userData)
stats.totalUsers++ stats.totalUsers++
@@ -522,7 +532,7 @@ class UserService {
const { displayName, username, email } = user const { displayName, username, email } = user
// 获取所有API Keys // 获取所有API Keys
const allApiKeys = await apiKeyService.getAllApiKeys() const allApiKeys = await apiKeyService.getAllApiKeysFast()
// 找到没有用户ID的API Keys即由Admin创建的 // 找到没有用户ID的API Keys即由Admin创建的
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '') const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')

View 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
View 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
}

View File

@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
title: 'Claude Code Compact System Prompt Agent SDK2', 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." 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: { claudeOtherSystemPromptCompact: {
category: 'system', category: 'system',
title: 'Claude Code Compact System Prompt', title: 'Claude Code Compact System Prompt',

View File

@@ -1,217 +1,260 @@
/** /**
* 错误消息清理工具 * 错误消息清理工具 - 白名单错误码制
* 用于移除上游错误中的供应商特定信息(如 URL、引用等 * 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端
*/ */
/** const logger = require('./logger')
* 清理错误消息中的 URL 和供应商引用
* @param {string} message - 原始错误消息 // 标准错误码定义
* @returns {string} - 清理后的消息 const ERROR_CODES = {
*/ E001: { message: 'Service temporarily unavailable', status: 503 },
function sanitizeErrorMessage(message) { E002: { message: 'Network connection failed', status: 502 },
if (typeof message !== 'string') { E003: { message: 'Authentication failed', status: 401 },
return message 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 }
} }
// 移除 URLhttp:// 或 https:// // 错误特征匹配规则(按优先级排序
let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '') 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' },
// 移除常见的供应商引用模式 // 认证错误
cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '') { pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' },
cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx" { pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' },
cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx" { pattern: /authentication|auth.*fail/i, code: 'E003' },
cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息
// 移除供应商特定关键词(包括整个单词) // 权限错误
cleaned = cleaned.replace(/88code\S*/gi, '') { pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' },
cleaned = cleaned.replace(/duck\S*/gi, '') { pattern: /does not have.*permission/i, code: 'E009' },
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() // 限流错误
{ pattern: /rate.*limit|too many requests|429/i, code: 'E004' },
{ pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' },
// 如果消息被清理得太短或为空,返回通用消息 // 过载错误
if (cleaned.length < 5) { { pattern: /overloaded|529|capacity/i, code: 'E012' },
return 'The requested model is currently unavailable'
}
return cleaned // 账户错误
} { 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 - 原始错误数据对象 * @param {Error|string|object} error - 原始错误
* @returns {Object} - 清理后的错误数据 * @param {object} options - 选项
* @param {string} options.context - 错误上下文(用于日志)
* @param {boolean} options.logOriginal - 是否记录原始错误默认true
* @returns {{ code: string, message: string, status: number }}
*/ */
function sanitizeUpstreamError(errorData) { function mapToErrorCode(error, options = {}) {
if (!errorData || typeof errorData !== 'object') { const { context = 'unknown', logOriginal = true } = options
return errorData
// 提取原始错误信息
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 = let matchedCode = 'E015' // 默认:内部服务器错误
errorData.isAxiosError ||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
if (looksLikeAxiosError || looksLikeError) { // 先按 HTTP 状态码快速匹配
const statusCode = errorData.response?.status if (statusCode) {
const upstreamBody = errorData.response?.data if (statusCode === 401) {
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '') 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 { return {
name: errorData.name || 'Error', code: matchedCode,
code: errorData.code, message: result.message,
statusCode, status: result.status
message: sanitizeErrorMessage(errorData.message || ''),
upstreamMessage: upstreamMessage || undefined,
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
} }
} }
// 递归清理嵌套的错误对象
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) { function extractErrorMessage(body) {
if (!body) { return extractOriginalMessage(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 ''
}
/**
* 检测是否为账户被禁用或不可用的 400 错误
* @param {number} statusCode - HTTP 状态码
* @param {*} body - 响应体
* @returns {boolean} - 是否为账户禁用错误
*/
function isAccountDisabledError(statusCode, body) { function isAccountDisabledError(statusCode, body) {
if (statusCode !== 400) { if (statusCode !== 400) {
return false return false
} }
const message = extractOriginalMessage(body)
const message = extractErrorMessage(body)
if (!message) { if (!message) {
return false return false
} }
// 将消息全部转换为小写,进行模糊匹配(避免大小写问题) const lower = message.toLowerCase()
const lowerMessage = message.toLowerCase()
// 检测常见的账户禁用/不可用模式
return ( return (
lowerMessage.includes('organization has been disabled') || lower.includes('organization has been disabled') ||
lowerMessage.includes('account has been disabled') || lower.includes('account has been disabled') ||
lowerMessage.includes('account is disabled') || lower.includes('account is disabled') ||
lowerMessage.includes('no account supporting') || lower.includes('no account supporting') ||
lowerMessage.includes('account not found') || lower.includes('account not found') ||
lowerMessage.includes('invalid account') || lower.includes('invalid account') ||
lowerMessage.includes('too many active sessions') lower.includes('too many active sessions')
) )
} }
module.exports = { module.exports = {
ERROR_CODES,
mapToErrorCode,
createSafeErrorResponse,
createSafeSSEError,
getSafeMessage,
// 兼容旧接口
sanitizeErrorMessage, sanitizeErrorMessage,
sanitizeUpstreamError, sanitizeUpstreamError,
extractErrorMessage, extractErrorMessage,

View File

@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
return false 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 = { module.exports = {
parseVendorPrefixedModel, parseVendorPrefixedModel,
hasVendorPrefix, hasVendorPrefix,
getEffectiveModel, getEffectiveModel,
getVendorType, getVendorType,
isOpus45OrNewer isOpus45OrNewer,
isClaudeFamilyModel
} }

View File

@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference', SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
} }
@@ -35,6 +35,7 @@ function generateState() {
/** /**
* 生成随机的 code verifierPKCE * 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串 * @returns {string} base64url 编码的随机字符串
*/ */
function generateCodeVerifier() { function generateCodeVerifier() {
@@ -210,7 +211,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
dataKeys: response.data ? Object.keys(response.data) : [] dataKeys: response.data ? Object.keys(response.data) : []
}) })
logger.success('OAuth token exchange successful', { logger.success('OAuth token exchange successful', {
status: response.status, status: response.status,
hasAccessToken: !!response.data?.access_token, hasAccessToken: !!response.data?.access_token,
hasRefreshToken: !!response.data?.refresh_token, hasRefreshToken: !!response.data?.refresh_token,
@@ -430,7 +431,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
dataKeys: response.data ? Object.keys(response.data) : [] dataKeys: response.data ? Object.keys(response.data) : []
}) })
logger.success('Setup Token exchange successful', { logger.success('Setup Token exchange successful', {
status: response.status, status: response.status,
hasAccessToken: !!response.data?.access_token, hasAccessToken: !!response.data?.access_token,
scopes: response.data?.scope, scopes: response.data?.scope,
@@ -660,7 +661,7 @@ async function getOrganizationInfo(sessionKey, proxyConfig = null) {
throw new Error('未找到具有chat能力的组织') throw new Error('未找到具有chat能力的组织')
} }
logger.success('Found organization', { logger.success('Found organization', {
uuid: bestOrg.uuid, uuid: bestOrg.uuid,
capabilities: maxCapabilities capabilities: maxCapabilities
}) })
@@ -777,7 +778,7 @@ async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyCon
// 构建完整的授权码包含state如果有的话 // 构建完整的授权码包含state如果有的话
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
logger.success('Got authorization code via Cookie', { logger.success('Got authorization code via Cookie', {
codeLength: authorizationCode.length, codeLength: authorizationCode.length,
codePrefix: `${authorizationCode.substring(0, 10)}...` codePrefix: `${authorizationCode.substring(0, 10)}...`
}) })
@@ -853,7 +854,7 @@ async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = fa
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig) ? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
: await exchangeCodeForTokens(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, isSetupToken,
organizationUuid, organizationUuid,
hasAccessToken: !!tokenData.accessToken, hasAccessToken: !!tokenData.accessToken,

View 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高 maxSocketstimeout=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
}

View File

@@ -7,9 +7,16 @@ function toNumber(value) {
return Number.isFinite(num) ? num : 0 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) { if (!rateLimitInfo) {
return { totalTokens: 0, totalCost: 0 } return { totalTokens: 0, totalCost: 0, ratedCost: 0 }
} }
const client = redis.getClient() 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 = { module.exports = {

View File

@@ -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
} }

View File

@@ -24,9 +24,12 @@ function generateSessionString() {
* @param {string} model - 模型名称 * @param {string} model - 模型名称
* @param {object} options - 可选配置 * @param {object} options - 可选配置
* @param {boolean} options.stream - 是否流式默认false * @param {boolean} options.stream - 是否流式默认false
* @param {string} options.prompt - 自定义提示词(默认 'hi'
* @param {number} options.maxTokens - 最大输出 token默认 1000
* @returns {object} 测试请求体 * @returns {object} 测试请求体
*/ */
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) { function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
const { stream, prompt = 'hi', maxTokens = 1000 } = options
const payload = { const payload = {
model, model,
messages: [ messages: [
@@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
content: [ content: [
{ {
type: 'text', type: 'text',
text: 'hi', text: prompt,
cache_control: { cache_control: {
type: 'ephemeral' type: 'ephemeral'
} }
@@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
metadata: { metadata: {
user_id: generateSessionString() user_id: generateSessionString()
}, },
max_tokens: 21333, max_tokens: maxTokens,
temperature: 1 temperature: 1
} }
if (options.stream) { if (stream) {
payload.stream = true 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 = { module.exports = {
randomHex, randomHex,
generateSessionString, generateSessionString,
createClaudeTestPayload, createClaudeTestPayload,
createGeminiTestPayload,
createOpenAITestPayload,
sendStreamTestRequest sendStreamTestRequest
} }

View File

@@ -61,7 +61,7 @@ async function startDeviceAuthorization(proxyConfig = null) {
throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)') throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)')
} }
logger.success('成功获取 WorkOS 设备码授权信息', { logger.success('成功获取 WorkOS 设备码授权信息', {
verificationUri: data.verification_uri, verificationUri: data.verification_uri,
userCode: data.user_code userCode: data.user_code
}) })

View File

@@ -5,20 +5,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Relay Service - 管理后台</title> <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> </head>
<body> <body>

View File

@@ -1157,7 +1157,6 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
@@ -1352,7 +1351,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1589,7 +1587,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3063,15 +3060,13 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
@@ -3623,7 +3618,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3770,7 +3764,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4035,7 +4028,6 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -4533,7 +4525,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4924,7 +4915,6 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -5125,7 +5115,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.18", "@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18", "@vue/compiler-sfc": "3.5.18",

View File

@@ -4,7 +4,6 @@
<!-- 全局组件 --> <!-- 全局组件 -->
<ToastNotification ref="toastRef" /> <ToastNotification ref="toastRef" />
<ConfirmDialog ref="confirmRef" />
</div> </div>
</template> </template>
@@ -13,12 +12,10 @@ import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import ToastNotification from '@/components/common/ToastNotification.vue' import ToastNotification from '@/components/common/ToastNotification.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const toastRef = ref() const toastRef = ref()
const confirmRef = ref()
onMounted(() => { onMounted(() => {
// 初始化主题 // 初始化主题

Binary file not shown.

Binary file not shown.

View 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');
}

View File

@@ -49,8 +49,8 @@
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3), 0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05); 0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -87,18 +87,6 @@
transition: all 0.3s ease; 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 { .stat-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: box-shadow:
@@ -106,10 +94,6 @@
0 10px 10px -5px rgba(0, 0, 0, 0.04); 0 10px 10px -5px rgba(0, 0, 0, 0.04);
} }
.stat-card:hover::before {
opacity: 1;
}
.stat-icon { .stat-icon {
width: 56px; width: 56px;
height: 56px; height: 56px;
@@ -160,15 +144,15 @@
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3), 0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05); 0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
} }
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3), 0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1); 0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
} }
.btn-success { .btn-success {
@@ -202,7 +186,7 @@
} }
.btn-secondary { .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; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(107, 114, 128, 0.3), 0 10px 15px -3px rgba(107, 114, 128, 0.3),
@@ -231,7 +215,7 @@
outline: none; outline: none;
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 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); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
} }
@@ -251,7 +235,7 @@
} }
.table-row:hover { .table-row:hover {
background: rgba(102, 126, 234, 0.05); background: rgba(var(--primary-rgb), 0.05);
transform: scale(1.005); transform: scale(1.005);
} }
@@ -276,8 +260,8 @@
} }
.dark .modal-content { .dark .modal-content {
background: rgba(17, 24, 39, 0.95); background: var(--glass-strong-color);
border: 1px solid rgba(75, 85, 99, 0.3); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 25px -5px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05); 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -419,8 +403,8 @@
/* 玻璃态容器 */ /* 玻璃态容器 */
.glass, .glass,
.glass-strong { .glass-strong {
margin: 16px; margin: 0;
border-radius: 20px; border-radius: 16px;
} }
/* 统计卡片 */ /* 统计卡片 */

View File

@@ -47,6 +47,70 @@
--table-hover: rgba(129, 140, 248, 0.1); --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 - 避免布局跳动 */ /* 优化后的transition - 避免布局跳动 */
button, button,
input, input,
@@ -99,9 +163,9 @@ body::before {
right: 0; right: 0;
bottom: 0; bottom: 0;
background: background:
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 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(102, 126, 234, 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(118, 75, 162, 0.1) 0%, transparent 50%); radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
} }
@@ -174,8 +238,8 @@ body::before {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3), 0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05); 0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -215,15 +279,15 @@ body::before {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3), 0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05); 0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
} }
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3), 0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1); 0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
} }
.btn-success { .btn-success {
@@ -275,7 +339,7 @@ body::before {
outline: none; outline: none;
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 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); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: #1f2937; color: #1f2937;
@@ -283,9 +347,9 @@ body::before {
.dark .form-input:focus { .dark .form-input:focus {
box-shadow: 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); 0 10px 15px -3px rgba(0, 0, 0, 0.2);
background: rgba(17, 24, 39, 0.95); background: var(--glass-strong-color);
color: #f3f4f6; color: #f3f4f6;
} }
@@ -332,19 +396,7 @@ body::before {
} }
.dark .stat-card { .dark .stat-card {
background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%); background: linear-gradient(135deg, var(--glass-strong-color) 0%, var(--bg-gradient-start) 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;
} }
.stat-card:hover { .stat-card:hover {
@@ -354,10 +406,6 @@ body::before {
0 10px 10px -5px rgba(0, 0, 0, 0.04); 0 10px 10px -5px rgba(0, 0, 0, 0.04);
} }
.stat-card:hover::before {
opacity: 1;
}
.stat-icon { .stat-icon {
width: 56px; width: 56px;
height: 56px; height: 56px;
@@ -407,15 +455,15 @@ body::before {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white; color: white;
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3), 0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05); 0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
} }
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3), 0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1); 0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
} }
.btn-success { .btn-success {
@@ -466,7 +514,7 @@ body::before {
outline: none; outline: none;
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 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); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: #1f2937; color: #1f2937;
@@ -474,9 +522,9 @@ body::before {
.dark .form-input:focus { .dark .form-input:focus {
box-shadow: 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); 0 10px 15px -3px rgba(0, 0, 0, 0.2);
background: rgba(17, 24, 39, 0.95); background: var(--glass-strong-color);
color: #f3f4f6; color: #f3f4f6;
} }
@@ -527,8 +575,8 @@ body::before {
} }
.dark .modal-content { .dark .modal-content {
background: #1f2937; background: var(--bg-gradient-start);
border: 1px solid rgba(75, 85, 99, 0.5); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6), 0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.02); 0 0 0 1px rgba(255, 255, 255, 0.02);
@@ -633,11 +681,11 @@ body::before {
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
.custom-scrollbar { .custom-scrollbar {
scrollbar-width: thin; 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 { .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 { .custom-scrollbar::-webkit-scrollbar {
@@ -646,38 +694,62 @@ body::before {
} }
.custom-scrollbar::-webkit-scrollbar-track { .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(102, 126, 234, 0.05); background: rgba(var(--primary-rgb), 0.05);
border-radius: 10px; border-radius: 10px;
} }
.dark .custom-scrollbar::-webkit-scrollbar-track { .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 { .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; border-radius: 10px;
transition: background 0.3s ease; transition: background 0.3s ease;
} }
.dark .custom-scrollbar::-webkit-scrollbar-thumb { .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 { .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 { .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 { .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 { .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) { @media (max-width: 768px) {
.glass, .glass,
.glass-strong { .glass-strong {
margin: 16px; margin: 0;
border-radius: 20px; border-radius: 16px;
} }
.stat-card { .stat-card {

Some files were not shown because too many files have changed in this diff Show More