Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot]
b892ac30a0 chore: sync VERSION file with release v1.1.242 [skip ci] 2025-12-26 05:59:55 +00:00
Wesley Liddick
b8f34b4630 Merge pull request #844 from dadongwo/antigravity
feat: 实现 Antigravity OAuth 账户支持与路径分流
2025-12-26 00:59:42 -05:00
Wesley Liddick
c9621e9efb Merge pull request #846 from bgColorGray/feat/passthrough-system-prompt [skip ci]
feat: allow passing system prompt to Claude
2025-12-26 00:59:29 -05:00
pengyujie
e57a7bd614 feat: allow passing system prompt to Claude
Add CRS_PASSTHROUGH_SYSTEM_PROMPT to optionally forward OpenAI-format system messages to Claude, improving compatibility with clients that rely on strict system instructions (e.g. MineContext).
2025-12-25 20:02:26 +08:00
206 changed files with 9406 additions and 22138 deletions

View File

@@ -53,38 +53,20 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
# - /antigravity/api -> Antigravity OAuth # - /antigravity/api -> Antigravity OAuth
# - /gemini-cli/api -> Gemini CLI OAuth # - /gemini-cli/api -> Gemini CLI OAuth
# ============================================================================ # 可选Claude Code 调试 Dump会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
# 🐛 调试 Dump 配置(可选) # - anthropic-requests-dump.jsonl
# ============================================================================ # - anthropic-responses-dump.jsonl
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。 # - anthropic-tools-dump.jsonl
# ⚠️ 生产环境建议关闭,避免磁盘占用。
#
# 📄 输出文件列表:
# - anthropic-requests-dump.jsonl (客户端请求)
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
# - anthropic-tools-dump.jsonl (工具定义快照)
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
#
# 📌 开关配置:
# ANTHROPIC_DEBUG_REQUEST_DUMP=true # ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
#
# 📏 单条记录大小上限(字节),默认 2MB
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152 # ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152 # ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
#
# 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果)
# - antigravity-upstream-requests-dump.jsonl
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152 # ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
#
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB
# DUMP_MAX_FILE_SIZE_BYTES=10485760
#
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
# (仅 /antigravity/api 分流生效)
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
# 🚫 529错误处理配置 # 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟 # 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
@@ -114,16 +96,6 @@ 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
@@ -194,3 +166,7 @@ DEFAULT_USER_ROLE=user
USER_SESSION_TIMEOUT=86400000 USER_SESSION_TIMEOUT=86400000
MAX_API_KEYS_PER_USER=1 MAX_API_KEYS_PER_USER=1
ALLOW_USER_DELETE_API_KEYS=false ALLOW_USER_DELETE_API_KEYS=false
# Pass through incoming OpenAI-format system prompts to Claude.
# Enable this when using generic OpenAI-compatible clients (e.g. MineContext) that rely on system prompts.
# CRS_PASSTHROUGH_SYSTEM_PROMPT=true

1
.gitattributes vendored
View File

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

View File

@@ -1,9 +1,9 @@
# Claude Relay Service # Claude Relay Service
> [!CAUTION] > [!CAUTION]
> **安全更新通知**v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。 > **安全更新通知**v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
> >
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)** > **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center"> <div align="center">
@@ -394,32 +394,29 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:** **Claude Code 设置环境变量:**
默认使用标准 Claude 账号池Claude/Console/Bedrock/CCR
**使用标准 Claude 账号池**
默认使用标准 Claude 账号池:
```bash ```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
``` ```
**使用 Antigravity 账户池** 如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。 Antigravity OAuth支持 `claude-opus-4-5` 等 Antigravity 模型):
```bash ```bash
# 1. 设置 Base URL 为 Antigravity 专用路径
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/" export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
# 2. 设置 API Key在后台创建权限需包含 'all' 或 'gemini'
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
# 3. 指定模型名称(直接使用短名,无需前缀!)
export ANTHROPIC_MODEL="claude-opus-4-5" export ANTHROPIC_MODEL="claude-opus-4-5"
```
# 4. 启动 Gemini CLI OAuth使用 Gemini 模型):
claude
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="gemini-2.5-pro"
``` ```
**VSCode Claude 插件配置:** **VSCode Claude 插件配置:**
@@ -625,9 +622,8 @@ gpt-5 # Codex使用固定模型ID
- 所有账号类型都使用相同的API密钥在后台统一创建 - 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型 - 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池 - `/claude/` - 使用Claude账号池
- `/antigravity/api/` - 使用Antigravity账号池推荐用于Claude Code
- `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用 - `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用
- `/gemini/` - 使用Gemini账号池 - `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式 - `/openai/` - 使用Codex账号只支持Openai-Response格式
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池只建议api调用或Droid Cli中使用 - `/droid/openai/` - 使用Droid类型OpenAI兼容账号池只建议api调用或Droid Cli中使用
- 支持所有标准API端点messages、models等 - 支持所有标准API端点messages、models等

View File

@@ -1,9 +1,9 @@
# Claude Relay Service # Claude Relay Service
> [!CAUTION] > [!CAUTION]
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel. > **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
> >
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)** > **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center"> <div align="center">
@@ -243,13 +243,31 @@ Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:** **Claude Code Set Environment Variables:**
Default uses standard Claude account pool: Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
```bash ```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
export ANTHROPIC_AUTH_TOKEN="API key created in the backend" export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
``` ```
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth (Gemini models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude Plugin Configuration:** **VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`: If using VSCode Claude plugin, configure in `~/.claude/config.json`:
@@ -609,4 +627,4 @@ This project uses the [MIT License](LICENSE).
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions** **🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div> </div>

View File

@@ -1,21 +0,0 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

View File

@@ -1 +1 @@
1.1.263 1.1.242

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.getAllApiKeysFast(), apiKeyService.getAllApiKeys(),
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.getAllApiKeysFast() const apiKeys = await apiKeyService.getAllApiKeys()
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.maskedKey || '-', key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
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.getAllApiKeysFast() const apiKeys = await apiKeyService.getAllApiKeys()
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.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${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.getAllApiKeysFast() const apiKeys = await apiKeyService.getAllApiKeys()
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.getAllApiKeysFast() const apiKeys = await apiKeyService.getAllApiKeys()
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.maskedKey || key.id.substring(0, 8)})`, name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
value: key.id value: key.id
})) }))
} }

View File

@@ -123,8 +123,7 @@ 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界面配置
@@ -221,13 +220,6 @@ 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 // 最大总额度(美元)
} }
} }

View File

@@ -1,64 +0,0 @@
/**
* 模型列表配置
* 用于前端展示和测试功能
*/
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,8 +2,7 @@ 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 = const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
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(/\/$/, '')
@@ -12,6 +11,7 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
module.exports = { module.exports = {
pricingFileName, pricingFileName,
hashFileName, hashFileName,
pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`, pricingUrl:
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}` hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
} }

View File

@@ -152,110 +152,62 @@ async function exportUsageStats(keyId) {
daily: {}, daily: {},
monthly: {}, monthly: {},
hourly: {}, hourly: {},
models: {}, models: {}
// 费用统计String 类型)
costTotal: null,
costDaily: {},
costMonthly: {},
costHourly: {},
opusTotal: null,
opusWeekly: {}
} }
// 导出总统计Hash // 导出总统计
const totalData = await redis.client.hgetall(`usage:${keyId}`) const totalKey = `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
} }
// 导出费用总统计String // 导出每日统计最近30天
const costTotal = await redis.client.get(`usage:cost:total:${keyId}`) const today = new Date()
if (costTotal) { for (let i = 0; i < 30; i++) {
stats.costTotal = costTotal const date = new Date(today)
} date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
const dailyKey = `usage:daily:${keyId}:${dateStr}`
// 导出 Opus 费用总统计String const dailyData = await redis.client.hgetall(dailyKey)
const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`) if (dailyData && Object.keys(dailyData).length > 0) {
if (opusTotal) { stats.daily[dateStr] = dailyData
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
} }
} }
// 导出每日费用(扫描现有 key // 导出每月统计最近12个月
const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`) for (let i = 0; i < 12; i++) {
for (const key of costDailyKeys) { const date = new Date(today)
const date = key.split(':').pop() date.setMonth(date.getMonth() - i)
const value = await redis.client.get(key) const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
if (value) { const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
stats.costDaily[date] = value
const monthlyData = await redis.client.hgetall(monthlyKey)
if (monthlyData && Object.keys(monthlyData).length > 0) {
stats.monthly[monthStr] = monthlyData
} }
} }
// 导出每月统计(扫描现有 key // 导出小时统计(最近24小时
const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`) for (let i = 0; i < 24; i++) {
for (const key of monthlyKeys) { const date = new Date(today)
const month = key.split(':').pop() date.setHours(date.getHours() - i)
const data = await redis.client.hgetall(key) const dateStr = date.toISOString().split('T')[0]
if (data && Object.keys(data).length > 0) { const hour = String(date.getHours()).padStart(2, '0')
stats.monthly[month] = data const hourKey = `${dateStr}:${hour}`
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
const hourlyData = await redis.client.hgetall(hourlyKey)
if (hourlyData && Object.keys(hourlyData).length > 0) {
stats.hourly[hourKey] = hourlyData
} }
} }
// 导出每月费用(扫描现有 key // 导出模型统计
const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`) // 每日模型统计
for (const key of costMonthlyKeys) { const modelDailyPattern = `usage:${keyId}:model:daily:*`
const month = key.split(':').pop() const modelDailyKeys = await redis.client.keys(modelDailyPattern)
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) {
@@ -271,8 +223,9 @@ async function exportUsageStats(keyId) {
} }
} }
// 导出模型统计(每月) // 每月模型统计
const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`) const modelMonthlyPattern = `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) {
@@ -305,7 +258,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)
@@ -313,19 +266,7 @@ 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)) {
@@ -335,15 +276,7 @@ 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)) {
@@ -353,23 +286,7 @@ 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)) {
@@ -379,17 +296,10 @@ 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)) {
@@ -399,6 +309,7 @@ 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)) {
@@ -636,54 +547,13 @@ 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 monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
if (monthlyMonths && monthlyMonths.length > 0) {
globalStats.monthlyMonths = monthlyMonths
logger.info(`📤 Found ${monthlyMonths.length} months in index`)
}
// 导出全局统计
const globalTotal = await redis.client.hgetall('usage:global:total')
if (globalTotal && Object.keys(globalTotal).length > 0) {
globalStats.globalTotal = globalTotal
logger.info('📤 Found global total stats')
}
// 导出全局每日统计
const globalDailyKeys = await redis.client.keys('usage:global:daily:*')
for (const key of globalDailyKeys) {
const date = key.replace('usage:global:daily:', '')
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
globalStats.globalDaily[date] = data
}
}
logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`)
// 导出全局每月统计
const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*')
for (const key of globalMonthlyKeys) {
const month = key.replace('usage:global:monthly:', '')
const data = await redis.client.hgetall(key)
if (data && Object.keys(data).length > 0) {
globalStats.globalMonthly[month] = data
}
}
logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`)
// 导出全局每日模型统计 // 导出全局每日模型统计
const modelDailyPattern = 'usage:model:daily:*' const globalDailyPattern = 'usage:model:daily:*'
const modelDailyKeys = await redis.client.keys(modelDailyPattern) const globalDailyKeys = await redis.client.keys(globalDailyPattern)
for (const key of modelDailyKeys) { for (const key of globalDailyKeys) {
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]
@@ -699,9 +569,9 @@ async function exportData() {
} }
// 导出全局每月模型统计 // 导出全局每月模型统计
const modelMonthlyPattern = 'usage:model:monthly:*' const globalMonthlyPattern = 'usage:model:monthly:*'
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
for (const key of modelMonthlyKeys) { for (const key of globalMonthlyKeys) {
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]
@@ -1170,46 +1040,6 @@ 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)) {
@@ -1231,8 +1061,6 @@ 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.getAllApiKeysFast() const allKeys = await apiKeyService.getAllApiKeys()
// 找出所有测试 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,7 +12,6 @@
*/ */
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')
@@ -52,7 +51,7 @@ async function migrateApiKeys() {
logger.success('✅ Connected to Redis') logger.success('✅ Connected to Redis')
// 获取所有 API Keys // 获取所有 API Keys
const apiKeys = await apiKeyService.getAllApiKeysFast() const apiKeys = await redis.getAllApiKeys()
logger.info(`📊 Found ${apiKeys.length} API Keys in total`) logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
// 统计信息 // 统计信息

View File

@@ -1,138 +0,0 @@
/**
* 历史数据索引迁移脚本
* 为现有的 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,7 +11,6 @@ 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')
@@ -51,38 +50,7 @@ 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 {
@@ -126,15 +94,6 @@ 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')
@@ -145,18 +104,6 @@ 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') {
@@ -232,7 +179,7 @@ class Application {
// 🔧 基础中间件 // 🔧 基础中间件
this.app.use( this.app.use(
express.json({ express.json({
limit: '100mb', limit: '10mb',
verify: (req, res, buf, encoding) => { verify: (req, res, buf, encoding) => {
// 验证JSON格式 // 验证JSON格式
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
@@ -241,7 +188,7 @@ class Application {
} }
}) })
) )
this.app.use(express.urlencoded({ extended: true, limit: '100mb' })) this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
this.app.use(securityMiddleware) this.app.use(securityMiddleware)
// 🎯 信任代理 // 🎯 信任代理
@@ -430,7 +377,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: getSafeMessage(error), error: error.message,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}) })
} }
@@ -466,7 +413,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
@@ -501,7 +448,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:', {
@@ -518,24 +465,22 @@ class Application {
const client = redis.getClient() const client = redis.getClient()
// 获取所有 session:* 键 // 获取所有 session:* 键
const sessionKeys = await redis.scanKeys('session:*') const sessionKeys = await client.keys('session:*')
const dataList = await redis.batchHgetallChunked(sessionKeys)
let validCount = 0 let validCount = 0
let invalidCount = 0 let invalidCount = 0
for (let i = 0; i < sessionKeys.length; i++) { for (const key of sessionKeys) {
const key = sessionKeys[i]
// 跳过 admin_credentials系统凭据 // 跳过 admin_credentials系统凭据
if (key === 'session:admin_credentials') { if (key === 'session:admin_credentials') {
continue continue
} }
const sessionData = dataList[i] const sessionData = await client.hgetall(key)
// 检查会话完整性:必须有 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) {
// 无效会话 - 可能是漏洞利用创建的伪造会话 // 无效会话 - 可能是漏洞利用创建的伪造会话
@@ -550,11 +495,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) {
// 清理失败不应阻止服务启动 // 清理失败不应阻止服务启动
@@ -604,7 +549,9 @@ 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(`Claude Relay Service started on ${config.server.host}:${config.server.port}`) logger.start(
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
)
logger.info( 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`
) )
@@ -659,7 +606,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)
// 不阻止应用启动 // 不阻止应用启动
@@ -708,7 +655,7 @@ class Application {
// 每分钟主动清理所有过期的并发项,不依赖请求触发 // 每分钟主动清理所有过期的并发项,不依赖请求触发
setInterval(async () => { setInterval(async () => {
try { try {
const keys = await redis.scanKeys('concurrency:*') const keys = await redis.keys('concurrency:*')
if (keys.length === 0) { if (keys.length === 0) {
return return
} }
@@ -890,9 +837,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.scanKeys('concurrency:*') const keys = await redis.keys('concurrency:*')
if (keys.length > 0) { if (keys.length > 0) {
await redis.batchDelChunked(keys) await redis.client.del(...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')
@@ -909,7 +856,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,11 +14,9 @@ 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')
// ============================================================================ // ============================================================================
@@ -138,9 +136,7 @@ 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) {
@@ -357,7 +353,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: getSafeMessage(error) || 'No available Gemini accounts', message: error.message || 'No available Gemini accounts',
type: 'service_unavailable' type: 'service_unavailable'
} }
}) })
@@ -496,8 +492,7 @@ async function handleMessages(req, res) {
0, 0,
0, 0,
model, model,
accountId, accountId
'gemini'
) )
} }
} }
@@ -601,8 +596,7 @@ async function handleMessages(req, res) {
0, 0,
0, 0,
model, model,
accountId, accountId
'gemini'
) )
.then(() => { .then(() => {
logger.info( logger.info(
@@ -620,7 +614,7 @@ async function handleMessages(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Stream error', message: error.message || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -668,7 +662,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: getSafeMessage(error) || 'Internal server error', message: error.message || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
} }
@@ -836,18 +830,16 @@ function handleModelDetails(req, res) {
*/ */
async function handleUsage(req, res) { async function handleUsage(req, res) {
try { try {
const keyData = req.apiKey const { usage } = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({ res.json({
object: 'usage', object: 'usage',
total_tokens: usage?.total?.tokens || 0, total_tokens: usage.total.tokens,
total_requests: usage?.total?.requests || 0, total_requests: usage.total.requests,
daily_tokens: usage?.daily?.tokens || 0, daily_tokens: usage.daily.tokens,
daily_requests: usage?.daily?.requests || 0, daily_requests: usage.daily.requests,
monthly_tokens: usage?.monthly?.tokens || 0, monthly_tokens: usage.monthly.tokens,
monthly_requests: usage?.monthly?.requests || 0 monthly_requests: usage.monthly.requests
}) })
} catch (error) { } catch (error) {
logger.error('Failed to get usage stats:', error) logger.error('Failed to get usage stats:', error)
@@ -866,18 +858,17 @@ 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 || 'all',
token_limit: keyData.tokenLimit, token_limit: keyData.tokenLimit,
tokens_used: tokensUsed, tokens_used: keyData.usage.total.tokens,
tokens_remaining: tokens_remaining:
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, keyData.tokenLimit > 0
? 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
@@ -1197,110 +1188,6 @@ 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 请求
*/ */
@@ -1417,7 +1304,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: getSafeMessage(error) || 'Internal server error', message: error.message || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1603,8 +1490,7 @@ 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}`
@@ -1640,7 +1526,7 @@ async function handleGenerateContent(req, res) {
}) })
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Internal server error', message: error.message || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1924,8 +1810,7 @@ async function handleStreamGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
account.id, account.id
'gemini'
), ),
applyRateLimitTracking( applyRateLimitTracking(
req, req,
@@ -1962,7 +1847,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Stream error', message: error.message || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -1972,7 +1857,7 @@ async function handleStreamGenerateContent(req, res) {
res.write( res.write(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
error: { error: {
message: getSafeMessage(error) || 'Stream error', message: error.message || 'Stream error',
type: 'stream_error', type: 'stream_error',
code: error.code code: error.code
} }
@@ -2001,7 +1886,7 @@ async function handleStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Internal server error', message: error.message || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2262,8 +2147,7 @@ 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}`
@@ -2285,7 +2169,7 @@ async function handleStandardGenerateContent(req, res) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Internal server error', message: error.message || 'Internal server error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2692,8 +2576,7 @@ async function handleStandardStreamGenerateContent(req, res) {
0, 0,
0, 0,
model, model,
accountId, accountId
'gemini'
) )
.then(() => { .then(() => {
logger.info( logger.info(
@@ -2721,7 +2604,7 @@ async function handleStandardStreamGenerateContent(req, res) {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Stream error', message: error.message || 'Stream error',
type: 'api_error' type: 'api_error'
} }
}) })
@@ -2731,7 +2614,7 @@ async function handleStandardStreamGenerateContent(req, res) {
res.write( res.write(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
error: { error: {
message: getSafeMessage(error) || 'Stream error', message: error.message || 'Stream error',
type: 'stream_error', type: 'stream_error',
code: error.code code: error.code
} }
@@ -2815,7 +2698,6 @@ module.exports = {
handleSimpleEndpoint, handleSimpleEndpoint,
handleLoadCodeAssist, handleLoadCodeAssist,
handleOnboardUser, handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens, handleCountTokens,
handleGenerateContent, handleGenerateContent,
handleStreamGenerateContent, handleStreamGenerateContent,

View File

@@ -9,7 +9,6 @@ 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) {
@@ -452,7 +451,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:
@@ -462,7 +461,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'
@@ -474,7 +473,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
@@ -1196,16 +1195,12 @@ const authenticateApiKey = async (req, res, next) => {
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
) )
// 使用 402 Payment Required 而非 429避免客户端自动重试 return res.status(429).json({
return res.status(402).json({ error: 'Daily cost limit exceeded',
error: { message: `已达到每日费用限制 ($${dailyCostLimit})`,
type: 'insufficient_quota',
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() resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
}) })
} }
@@ -1229,13 +1224,9 @@ const authenticateApiKey = async (req, res, next) => {
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
) )
// 使用 402 Payment Required 而非 429避免客户端自动重试 return res.status(429).json({
return res.status(402).json({ error: 'Total cost limit exceeded',
error: { message: `已达到总费用限制 ($${totalCostLimit})`,
type: 'insufficient_quota',
message: `已达到总费用限制 ($${totalCostLimit})`,
code: 'total_cost_limit_exceeded'
},
currentCost: totalCost, currentCost: totalCost,
costLimit: totalCostLimit costLimit: totalCostLimit
}) })
@@ -1248,20 +1239,20 @@ const authenticateApiKey = async (req, res, next) => {
) )
} }
// 检查 Claude 周费用限制 // 检查 Opus 周费用限制(仅对 Opus 模型生效)
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 || ''
// 判断是否为 Claude 模型 // 判断是否为 Opus 模型
if (isClaudeFamilyModel(model)) { if (model && model.toLowerCase().includes('claude-opus')) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) { if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security( logger.security(
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${ `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
) )
@@ -1274,22 +1265,18 @@ 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)
// 使用 402 Payment Required 而非 429避免客户端自动重试 return res.status(429).json({
return res.status(402).json({ error: 'Weekly Opus cost limit exceeded',
error: { message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
type: 'insufficient_quota',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
code: 'weekly_opus_cost_limit_exceeded'
},
currentCost: weeklyOpusCost, currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit, costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() resetAt: resetDate.toISOString() // 下周一重置
}) })
} }
// 记录当前 Claude 费用使用情况 // 记录当前 Opus 费用使用情况
logger.api( logger.api(
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${ `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
) )
@@ -1319,8 +1306,10 @@ 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'
@@ -1368,7 +1357,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'
@@ -1377,7 +1366,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'
@@ -1393,7 +1382,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'
@@ -1445,13 +1434,14 @@ const authenticateAdmin = async (req, res, next) => {
// 设置管理员信息(只包含必要信息) // 设置管理员信息(只包含必要信息)
req.admin = { req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username, username: adminSession.username,
sessionId: token, sessionId: token,
loginTime: adminSession.loginTime loginTime: adminSession.loginTime
} }
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) {
@@ -1482,7 +1472,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'
@@ -1491,7 +1481,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'
@@ -1502,7 +1492,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'
@@ -1577,25 +1567,17 @@ const authenticateUserOrAdmin = async (req, res, next) => {
try { try {
const adminSession = await redis.getSession(adminToken) const adminSession = await redis.getSession(adminToken)
if (adminSession && Object.keys(adminSession).length > 0) { if (adminSession && Object.keys(adminSession).length > 0) {
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致) req.admin = {
if (!adminSession.username || !adminSession.loginTime) { id: adminSession.adminId || 'admin',
logger.security( username: adminSession.username,
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})` sessionId: adminToken,
) loginTime: adminSession.loginTime
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
// 不返回 401继续尝试用户认证
} else {
req.admin = {
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
} }
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
} }
} catch (error) { } catch (error) {
logger.debug('Admin authentication failed, trying user authentication:', error.message) logger.debug('Admin authentication failed, trying user authentication:', error.message)
@@ -1634,7 +1616,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'
@@ -2061,7 +2043,7 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件 // 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => { const requestSizeLimit = (req, res, next) => {
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10) const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024 const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0') const contentLength = parseInt(req.headers['content-length'] || '0')
@@ -2070,7 +2052,7 @@ const requestSizeLimit = (req, res, next) => {
return res.status(413).json({ return res.status(413).json({
error: 'Payload Too Large', error: 'Payload Too Large',
message: 'Request body size exceeds limit', message: 'Request body size exceeds limit',
limit: `${MAX_SIZE_MB}MB` limit: '10MB'
}) })
} }

File diff suppressed because it is too large Load Diff

View File

@@ -45,27 +45,6 @@ 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分配
@@ -137,14 +116,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 redis.scanKeys(`usage:cost:*:${keyId}:*`) const costKeys = await client.keys(`usage:cost:*:${keyId}:*`)
const costValues = await redis.batchGetChunked(costKeys)
const keyValues = {} const keyValues = {}
for (let i = 0; i < costKeys.length; i++) { for (const key of costKeys) {
keyValues[costKeys[i]] = costValues[i] keyValues[key] = await client.get(key)
} }
return res.json({ return res.json({
@@ -345,28 +324,20 @@ 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 && userMap.has(apiKey.userId)) { if (apiKey.userId) {
const user = userMap.get(apiKey.userId) try {
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' const user = await userService.getUserById(apiKey.userId, false)
} else if (apiKey.userId) { if (user) {
apiKey.ownerDisplayName = 'Unknown User' apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
} else {
apiKey.ownerDisplayName = 'Unknown User'
}
} catch (error) {
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
apiKey.ownerDisplayName = 'Unknown User'
}
} else { } else {
apiKey.ownerDisplayName = apiKey.ownerDisplayName =
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
@@ -637,56 +608,6 @@ 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 {
@@ -752,7 +673,22 @@ 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 tags = await apiKeyService.getAllTags() const apiKeys = await apiKeyService.getAllApiKeys()
const tagSet = new Set()
// 收集所有API Keys的标签
for (const apiKey of apiKeys) {
if (apiKey.tags && Array.isArray(apiKey.tags)) {
apiKey.tags.forEach((tag) => {
if (tag && tag.trim()) {
tagSet.add(tag.trim())
}
})
}
}
// 转换为数组并排序
const tags = Array.from(tagSet).sort()
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`) logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
return res.json({ success: true, data: tags }) return res.json({ success: true, data: tags })
@@ -762,93 +698,6 @@ 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
@@ -1449,8 +1298,7 @@ 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
// 输入验证 // 输入验证
@@ -1577,12 +1425,6 @@ 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,
@@ -1610,8 +1452,7 @@ 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}`)
@@ -1653,8 +1494,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays, activationDays,
activationUnit, activationUnit,
expirationMode, expirationMode,
icon, icon
serviceRates
} = req.body } = req.body
// 输入验证 // 输入验证
@@ -1678,12 +1518,6 @@ 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 = []
@@ -1718,8 +1552,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
activationDays, activationDays,
activationUnit, activationUnit,
expirationMode, expirationMode,
icon, icon
serviceRates
}) })
// 保留原始 API Key 供返回 // 保留原始 API Key 供返回
@@ -1793,14 +1626,6 @@ 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)}`
) )
@@ -1869,9 +1694,6 @@ 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) {
@@ -1928,7 +1750,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}`)
@@ -1989,8 +1811,7 @@ 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
// 只允许更新指定字段 // 只允许更新指定字段
@@ -2176,15 +1997,6 @@ 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') {
@@ -2388,7 +2200,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({
@@ -2443,13 +2255,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.getAllApiKeysFast(true) // Include deleted const deletedApiKeys = await apiKeyService.getAllApiKeys(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,
@@ -2476,7 +2288,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,84 +414,4 @@ 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

@@ -122,7 +122,6 @@ router.post('/', authenticateAdmin, async (req, res) => {
description, description,
region, region,
awsCredentials, awsCredentials,
bearerToken,
defaultModel, defaultModel,
priority, priority,
accountType, accountType,
@@ -146,9 +145,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
} }
// 验证credentialType的有效性 // 验证credentialType的有效性
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) { if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid credential type. Must be "access_key" or "bearer_token"' error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
}) })
} }
@@ -157,11 +156,10 @@ router.post('/', authenticateAdmin, async (req, res) => {
description: description || '', description: description || '',
region: region || 'us-east-1', region: region || 'us-east-1',
awsCredentials, awsCredentials,
bearerToken,
defaultModel, defaultModel,
priority: priority || 50, priority: priority || 50,
accountType: accountType || 'shared', accountType: accountType || 'shared',
credentialType: credentialType || 'access_key' credentialType: credentialType || 'default'
}) })
if (!result.success) { if (!result.success) {
@@ -208,10 +206,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
// 验证credentialType的有效性 // 验证credentialType的有效性
if ( if (
mappedUpdates.credentialType && mappedUpdates.credentialType &&
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType) !['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
) { ) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid credential type. Must be "access_key" or "bearer_token"' error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
}) })
} }
@@ -351,15 +349,22 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
} }
}) })
// 测试Bedrock账户连接SSE 流式) // 测试Bedrock账户连接
router.post('/:accountId/test', authenticateAdmin, async (req, res) => { router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
try { try {
const { accountId } = req.params const { accountId } = req.params
await bedrockAccountService.testAccountConnection(accountId, res) const result = await bedrockAccountService.testAccount(accountId)
if (!result.success) {
return res.status(500).json({ error: 'Account test failed', message: result.error })
}
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
return res.json({ success: true, data: result.data })
} catch (error) { } catch (error) {
logger.error('❌ Failed to test Bedrock account:', error) logger.error('❌ Failed to test Bedrock account:', error)
// 错误已在服务层处理,这里仅做日志记录 return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
} }
}) })

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,89 +413,4 @@ 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,14 +20,9 @@ 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,
@@ -40,6 +35,8 @@ 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(),
@@ -53,13 +50,6 @@ 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'
@@ -76,118 +66,250 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
return false return false
} }
// 通用账户统计函数 - 单次遍历完成所有统计 const normalDroidAccounts = droidAccounts.filter(
const countAccountStats = (accounts, opts = {}) => { (acc) =>
const { isStringType = false, checkGeminiRateLimit = false } = opts normalizeBoolean(acc.isActive) &&
let normal = 0, acc.status !== 'blocked' &&
abnormal = 0, acc.status !== 'unauthorized' &&
paused = 0, normalizeBoolean(acc.schedulable) &&
rateLimited = 0 !isRateLimitedFlag(acc.rateLimitStatus)
).length
const abnormalDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.schedulable) &&
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
isRateLimitedFlag(acc.rateLimitStatus)
).length
for (const acc of accounts) { // 计算使用统计统一使用allTokens
const isActive = isStringType const totalTokensUsed = apiKeys.reduce(
? acc.isActive === 'true' || (sum, key) => sum + (key.usage?.total?.allTokens || 0),
acc.isActive === true || 0
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false) )
: acc.isActive const totalRequestsUsed = apiKeys.reduce(
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' (sum, key) => sum + (key.usage?.total?.requests || 0),
const isSchedulable = isStringType 0
? acc.schedulable !== 'false' && acc.schedulable !== false )
: acc.schedulable !== false const totalInputTokensUsed = apiKeys.reduce(
const isRateLimited = checkGeminiRateLimit (sum, key) => sum + (key.usage?.total?.inputTokens || 0),
? acc.rateLimitStatus === 'limited' || 0
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) )
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited 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
)
if (!isActive || isBlocked) { const activeApiKeys = apiKeys.filter((key) => key.isActive).length
abnormal++
} else if (!isSchedulable) {
paused++
} else if (isRateLimited) {
rateLimited++
} else {
normal++
}
}
return { normal, abnormal, paused, rateLimited }
}
// Droid 账户统计(特殊逻辑 // Claude账户统计 - 根据账户管理页面的判断逻辑
let normalDroidAccounts = 0, const normalClaudeAccounts = claudeAccounts.filter(
abnormalDroidAccounts = 0, (acc) =>
pausedDroidAccounts = 0, acc.isActive &&
rateLimitedDroidAccounts = 0 acc.status !== 'blocked' &&
for (const acc of droidAccounts) { acc.status !== 'unauthorized' &&
const isActive = normalizeBoolean(acc.isActive) acc.schedulable !== false &&
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
const isSchedulable = normalizeBoolean(acc.schedulable) ).length
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus) 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
if (!isActive || isBlocked) { // Claude Console账户统计
abnormalDroidAccounts++ const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
} else if (!isSchedulable) { (acc) =>
pausedDroidAccounts++ acc.isActive &&
} else if (isRateLimited) { acc.status !== 'blocked' &&
rateLimitedDroidAccounts++ acc.status !== 'unauthorized' &&
} else { acc.schedulable !== false &&
normalDroidAccounts++ !(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账户统计
let totalTokensUsed = 0, const normalGeminiAccounts = geminiAccounts.filter(
totalRequestsUsed = 0, (acc) =>
totalInputTokensUsed = 0, acc.isActive &&
totalOutputTokensUsed = 0, acc.status !== 'blocked' &&
totalCacheCreateTokensUsed = 0, acc.status !== 'unauthorized' &&
totalCacheReadTokensUsed = 0, acc.schedulable !== false &&
totalAllTokensUsed = 0, !(
activeApiKeys = 0, acc.rateLimitStatus === 'limited' ||
totalApiKeys = 0 (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
if (globalStats) { // Bedrock账户统计
// 使用预聚合数据(快速路径) const normalBedrockAccounts = bedrockAccounts.filter(
totalRequestsUsed = globalStats.requests (acc) =>
totalInputTokensUsed = globalStats.inputTokens acc.isActive &&
totalOutputTokensUsed = globalStats.outputTokens acc.status !== 'blocked' &&
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens acc.status !== 'unauthorized' &&
totalCacheReadTokensUsed = globalStats.cacheReadTokens acc.schedulable !== false &&
totalAllTokensUsed = globalStats.allTokens !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
totalTokensUsed = totalAllTokensUsed ).length
totalApiKeys = apiKeyCount.total const abnormalBedrockAccounts = bedrockAccounts.filter(
activeApiKeys = apiKeyCount.active (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
} else { ).length
// 回退到遍历(兼容旧数据) const pausedBedrockAccounts = bedrockAccounts.filter(
totalApiKeys = apiKeys.length (acc) =>
for (const key of apiKeys) { acc.schedulable === false &&
const usage = key.usage?.total acc.isActive &&
if (usage) { acc.status !== 'blocked' &&
totalTokensUsed += usage.allTokens || 0 acc.status !== 'unauthorized'
totalRequestsUsed += usage.requests || 0 ).length
totalInputTokensUsed += usage.inputTokens || 0 const rateLimitedBedrockAccounts = bedrockAccounts.filter(
totalOutputTokensUsed += usage.outputTokens || 0 (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0 ).length
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
totalAllTokensUsed += usage.allTokens || 0
}
if (key.isActive) {
activeApiKeys++
}
}
}
// 各平台账户统计(单次遍历) // OpenAI账户统计
const claudeStats = countAccountStats(claudeAccounts) // 注意OpenAI账户的isActive和schedulable是字符串类型默认值为'true'
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts) const normalOpenAIAccounts = openaiAccounts.filter(
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true }) (acc) =>
const bedrockStats = countAccountStats(bedrockAccounts) (acc.isActive === 'true' ||
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true }) acc.isActive === true ||
const ccrStats = countAccountStats(ccrAccounts) (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true }) acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false && // 包括'true'、true和undefined
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// CCR账户统计
const normalCcrAccounts = ccrAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalCcrAccounts = ccrAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedCcrAccounts = ccrAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedCcrAccounts = ccrAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// OpenAI-Responses账户统计
// 注意OpenAI-Responses账户的isActive和schedulable也是字符串类型
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
const dashboard = { const dashboard = {
overview: { overview: {
totalApiKeys, totalApiKeys: apiKeys.length,
activeApiKeys, activeApiKeys,
// 总账户统计(所有平台) // 总账户统计(所有平台)
totalAccounts: totalAccounts:
@@ -199,90 +321,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
openaiResponsesAccounts.length + openaiResponsesAccounts.length +
ccrAccounts.length, ccrAccounts.length,
normalAccounts: normalAccounts:
claudeStats.normal + normalClaudeAccounts +
claudeConsoleStats.normal + normalClaudeConsoleAccounts +
geminiStats.normal + normalGeminiAccounts +
bedrockStats.normal + normalBedrockAccounts +
openaiStats.normal + normalOpenAIAccounts +
openaiResponsesStats.normal + normalOpenAIResponsesAccounts +
ccrStats.normal, normalCcrAccounts,
abnormalAccounts: abnormalAccounts:
claudeStats.abnormal + abnormalClaudeAccounts +
claudeConsoleStats.abnormal + abnormalClaudeConsoleAccounts +
geminiStats.abnormal + abnormalGeminiAccounts +
bedrockStats.abnormal + abnormalBedrockAccounts +
openaiStats.abnormal + abnormalOpenAIAccounts +
openaiResponsesStats.abnormal + abnormalOpenAIResponsesAccounts +
ccrStats.abnormal + abnormalCcrAccounts +
abnormalDroidAccounts, abnormalDroidAccounts,
pausedAccounts: pausedAccounts:
claudeStats.paused + pausedClaudeAccounts +
claudeConsoleStats.paused + pausedClaudeConsoleAccounts +
geminiStats.paused + pausedGeminiAccounts +
bedrockStats.paused + pausedBedrockAccounts +
openaiStats.paused + pausedOpenAIAccounts +
openaiResponsesStats.paused + pausedOpenAIResponsesAccounts +
ccrStats.paused + pausedCcrAccounts +
pausedDroidAccounts, pausedDroidAccounts,
rateLimitedAccounts: rateLimitedAccounts:
claudeStats.rateLimited + rateLimitedClaudeAccounts +
claudeConsoleStats.rateLimited + rateLimitedClaudeConsoleAccounts +
geminiStats.rateLimited + rateLimitedGeminiAccounts +
bedrockStats.rateLimited + rateLimitedBedrockAccounts +
openaiStats.rateLimited + rateLimitedOpenAIAccounts +
openaiResponsesStats.rateLimited + rateLimitedOpenAIResponsesAccounts +
ccrStats.rateLimited + rateLimitedCcrAccounts +
rateLimitedDroidAccounts, rateLimitedDroidAccounts,
// 各平台详细统计 // 各平台详细统计
accountsByPlatform: { accountsByPlatform: {
claude: { claude: {
total: claudeAccounts.length, total: claudeAccounts.length,
normal: claudeStats.normal, normal: normalClaudeAccounts,
abnormal: claudeStats.abnormal, abnormal: abnormalClaudeAccounts,
paused: claudeStats.paused, paused: pausedClaudeAccounts,
rateLimited: claudeStats.rateLimited rateLimited: rateLimitedClaudeAccounts
}, },
'claude-console': { 'claude-console': {
total: claudeConsoleAccounts.length, total: claudeConsoleAccounts.length,
normal: claudeConsoleStats.normal, normal: normalClaudeConsoleAccounts,
abnormal: claudeConsoleStats.abnormal, abnormal: abnormalClaudeConsoleAccounts,
paused: claudeConsoleStats.paused, paused: pausedClaudeConsoleAccounts,
rateLimited: claudeConsoleStats.rateLimited rateLimited: rateLimitedClaudeConsoleAccounts
}, },
gemini: { gemini: {
total: geminiAccounts.length, total: geminiAccounts.length,
normal: geminiStats.normal, normal: normalGeminiAccounts,
abnormal: geminiStats.abnormal, abnormal: abnormalGeminiAccounts,
paused: geminiStats.paused, paused: pausedGeminiAccounts,
rateLimited: geminiStats.rateLimited rateLimited: rateLimitedGeminiAccounts
}, },
bedrock: { bedrock: {
total: bedrockAccounts.length, total: bedrockAccounts.length,
normal: bedrockStats.normal, normal: normalBedrockAccounts,
abnormal: bedrockStats.abnormal, abnormal: abnormalBedrockAccounts,
paused: bedrockStats.paused, paused: pausedBedrockAccounts,
rateLimited: bedrockStats.rateLimited rateLimited: rateLimitedBedrockAccounts
}, },
openai: { openai: {
total: openaiAccounts.length, total: openaiAccounts.length,
normal: openaiStats.normal, normal: normalOpenAIAccounts,
abnormal: openaiStats.abnormal, abnormal: abnormalOpenAIAccounts,
paused: openaiStats.paused, paused: pausedOpenAIAccounts,
rateLimited: openaiStats.rateLimited rateLimited: rateLimitedOpenAIAccounts
}, },
ccr: { ccr: {
total: ccrAccounts.length, total: ccrAccounts.length,
normal: ccrStats.normal, normal: normalCcrAccounts,
abnormal: ccrStats.abnormal, abnormal: abnormalCcrAccounts,
paused: ccrStats.paused, paused: pausedCcrAccounts,
rateLimited: ccrStats.rateLimited rateLimited: rateLimitedCcrAccounts
}, },
'openai-responses': { 'openai-responses': {
total: openaiResponsesAccounts.length, total: openaiResponsesAccounts.length,
normal: openaiResponsesStats.normal, normal: normalOpenAIResponsesAccounts,
abnormal: openaiResponsesStats.abnormal, abnormal: abnormalOpenAIResponsesAccounts,
paused: openaiResponsesStats.paused, paused: pausedOpenAIResponsesAccounts,
rateLimited: openaiResponsesStats.rateLimited rateLimited: rateLimitedOpenAIResponsesAccounts
}, },
droid: { droid: {
total: droidAccounts.length, total: droidAccounts.length,
@@ -294,20 +416,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
}, },
// 保留旧字段以兼容 // 保留旧字段以兼容
activeAccounts: activeAccounts:
claudeStats.normal + normalClaudeAccounts +
claudeConsoleStats.normal + normalClaudeConsoleAccounts +
geminiStats.normal + normalGeminiAccounts +
bedrockStats.normal + normalBedrockAccounts +
openaiStats.normal + normalOpenAIAccounts +
openaiResponsesStats.normal + normalOpenAIResponsesAccounts +
ccrStats.normal + normalCcrAccounts +
normalDroidAccounts, normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal, activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited, rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
totalGeminiAccounts: geminiAccounts.length, totalGeminiAccounts: geminiAccounts.length,
activeGeminiAccounts: geminiStats.normal, activeGeminiAccounts: normalGeminiAccounts,
rateLimitedGeminiAccounts: geminiStats.rateLimited, rateLimitedGeminiAccounts,
totalTokensUsed, totalTokensUsed,
totalRequestsUsed, totalRequestsUsed,
totalInputTokensUsed, totalInputTokensUsed,
@@ -337,8 +459,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
}, },
systemHealth: { systemHealth: {
redisConnected: redis.isConnected, redisConnected: redis.isConnected,
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0, claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
geminiAccountsHealthy: geminiStats.normal > 0, geminiAccountsHealthy: normalGeminiAccounts > 0,
droidAccountsHealthy: normalDroidAccounts > 0, droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime() uptime: process.uptime()
}, },
@@ -358,7 +480,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.getAllApiKeysFast() const apiKeys = await apiKeyService.getAllApiKeys()
const stats = apiKeys.map((key) => ({ const stats = apiKeys.map((key) => ({
keyId: key.id, keyId: key.id,
@@ -388,48 +510,55 @@ 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)
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` }) searchPatterns.push(`usage:model:daily:*:${dateStr}`)
currentDate.setDate(currentDate.getDate() + 1) currentDate.setDate(currentDate.getDate() + 1)
} }
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`) logger.info(`📊 Generated ${searchPatterns.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}`
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern }) searchPatterns = [pattern]
} }
// 按日期集合扫描,串行避免并行触发多次全库 SCAN logger.info('📊 Searching patterns:', searchPatterns)
const allResults = []
for (const { pattern } of datePatterns) { // 获取所有匹配的keys
const results = await redis.scanAndGetAllChunked(pattern) const allKeys = []
allResults.push(...results) for (const pattern of searchPatterns) {
const keys = await client.keys(pattern)
allKeys.push(...keys)
} }
logger.info(`📊 Found ${allResults.length} matching keys in total`) logger.info(`📊 Found ${allKeys.length} matching keys in total`)
// 模型名标准化函数与redis.js保持一致 // 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => { const normalizeModelName = (model) => {
@@ -439,23 +568,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')) {
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 匹配所有AWS区域格式region.anthropic.model-name-v1:0 -> claude-model-name
normalized = normalized.replace('anthropic.', '') // 支持所有AWS区域格式us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
normalized = normalized.replace(/-v\d+:\d+$/, '') let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
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, data } of allResults) { for (const key of allKeys) {
// 支持 daily 和 monthly 两种格式 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}$/) ||
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}`)
@@ -464,6 +593,7 @@ 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,7 +2,6 @@ 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')
@@ -143,143 +142,67 @@ 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 accountIds = accounts.map((a) => a.id) const allApiKeys = await redis.getAllApiKeys()
// 并行获取:轻量 API Keys + 分组信息 + daily cost // 添加使用统计
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([ const accountsWithStats = await Promise.all(
apiKeyService.getAllApiKeysLite(), accounts.map(async (account) => {
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'), try {
redis.batchGetAccountDailyCost(accountIds) const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
]) let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (groupError) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
groupInfos = []
}
// 构建绑定数映射droid 需要展开 group 绑定) const groupIds = groupInfos.map((group) => group.id)
// 1. 先构建 groupId -> accountIds 映射 const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const groupToAccountIds = new Map() const binding = key.droidAccountId
for (const [accountId, groups] of allGroupInfosMap) { if (!binding) {
for (const group of groups) { return count
if (!groupToAccountIds.has(group.id)) { }
groupToAccountIds.set(group.id, []) if (binding === account.id) {
} return count + 1
groupToAccountIds.get(group.id).push(accountId) }
} if (binding.startsWith('group:')) {
} const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
// 2. 单次遍历构建绑定数 const formattedAccount = formatAccountExpiry(account)
const directBindingCount = new Map() return {
const groupBindingCount = new Map() ...formattedAccount,
for (const key of allApiKeys) { schedulable: account.schedulable === 'true',
const binding = key.droidAccountId boundApiKeysCount,
if (!binding) { groupInfos,
continue usage: {
} daily: usageStats.daily,
if (binding.startsWith('group:')) { total: usageStats.total,
const groupId = binding.substring('group:'.length) averages: usageStats.averages
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1) }
} else { }
directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1) } catch (error) {
} logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
} const formattedAccount = formatAccountExpiry(account)
return {
// 批量获取使用统计 ...formattedAccount,
const client = redis.getClientSafe() boundApiKeysCount: 0,
const today = redis.getDateStringInTimezone() groupInfos: [],
const tzDate = redis.getDateInTimezone() usage: {
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
const statsPipeline = client.pipeline() averages: { rpm: 0, tpm: 0 }
for (const accountId of accountIds) { }
statsPipeline.hgetall(`account_usage:${accountId}`) }
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
// 构建 accountId -> createdAt 映射用于计算 averages
const accountCreatedAtMap = new Map()
for (const account of accounts) {
accountCreatedAtMap.set(
account.id,
account.createdAt ? new Date(account.createdAt) : new Date()
)
}
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const totalData = errTotal ? {} : parseUsage(total)
const totalTokens = totalData.tokens || 0
const totalRequests = totalData.requests || 0
// 计算 averages
const createdAt = accountCreatedAtMap.get(accountId)
const now = new Date()
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
allUsageStatsMap.set(accountId, {
total: totalData,
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly),
averages: {
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
} }
}) })
} )
// 处理账户数据
const accountsWithStats = accounts.map((account) => {
const groupInfos = allGroupInfosMap.get(account.id) || []
const usageStats = allUsageStatsMap.get(account.id) || {
daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
monthly: { tokens: 0, requests: 0 },
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
}
const dailyCost = dailyCostMap.get(account.id) || 0
// 计算绑定数:直接绑定 + 通过 group 绑定
let boundApiKeysCount = directBindingCount.get(account.id) || 0
for (const group of groupInfos) {
boundApiKeysCount += groupBindingCount.get(group.id) || 0
}
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
usage: {
daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total,
monthly: usageStats.monthly,
averages: usageStats.averages
}
}
})
return res.json({ success: true, data: accountsWithStats }) return res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -511,7 +434,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
} }
// 获取绑定的 API Key 数量 // 获取绑定的 API Key 数量
const allApiKeys = await apiKeyService.getAllApiKeysFast() const allApiKeys = await redis.getAllApiKeys()
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
@@ -601,92 +524,4 @@ 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,89 +506,4 @@ 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,108 +31,53 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
} }
} }
const accountIds = accounts.map((a) => a.id) // 处理使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
// 检查并清除过期的限流状态
await geminiApiAccountService.checkAndClearRateLimit(account.id)
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态 // 获取使用统计信息
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([ let usageStats
apiKeyService.getAllApiKeysLite(), try {
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'), usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
redis.batchGetAccountDailyCost(accountIds), } catch (error) {
// 批量清除限流状态 logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id))) usageStats = {
]) daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
// 单次遍历构建绑定数映射(只算直连,不算 group monthly: { requests: 0, tokens: 0, allTokens: 0 }
const bindingCountMap = new Map()
for (const key of allApiKeys) {
const binding = key.geminiAccountId
if (!binding) {
continue
}
// 处理 api: 前缀
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
}
// 批量获取使用统计
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const statsPipeline = client.pipeline()
for (const accountId of accountIds) {
statsPipeline.hgetall(`account_usage:${accountId}`)
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
allUsageStatsMap.set(accountId, {
total: errTotal ? {} : parseUsage(total),
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly)
})
}
// 处理账户数据
const accountsWithStats = accounts.map((account) => {
const groupInfos = allGroupInfosMap.get(account.id) || []
const usageStats = allUsageStatsMap.get(account.id) || {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
const dailyCost = dailyCostMap.get(account.id) || 0
const boundCount = bindingCountMap.get(account.id) || 0
// 计算 averagesrpm/tpm
const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
const daysSinceCreated = Math.max(
1,
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
)
const totalMinutes = daysSinceCreated * 24 * 60
const totalRequests = usageStats.total.requests || 0
const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
return {
...account,
groupInfos,
usage: {
daily: { ...usageStats.daily, cost: dailyCost },
total: usageStats.total,
averages: {
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
} }
}, }
boundApiKeys: boundCount
} // 计算绑定的API Key数量支持 api: 前缀)
}) const allKeys = await redis.getAllApiKeys()
let boundCount = 0
for (const key of allKeys) {
if (key.geminiAccountId) {
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
if (key.geminiAccountId === `api:${account.id}`) {
boundCount++
}
}
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages || usageStats.monthly
},
boundApiKeys: boundCount
}
})
)
res.json({ success: true, data: accountsWithStats }) res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -330,7 +275,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,
@@ -444,7 +389,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,8 +26,6 @@ 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')
// 挂载所有子路由 // 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径) // 使用完整路径的模块(直接挂载到根路径)
@@ -45,8 +43,6 @@ 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,97 +39,92 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
} }
} }
const accountIds = accounts.map((a) => a.id)
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
apiKeyService.getAllApiKeysLite(),
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
redis.batchGetAccountDailyCost(accountIds),
// 批量清理限流状态
Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
])
// 单次遍历构建绑定数映射(只算直连,不算 group
const bindingCountMap = new Map()
for (const key of allApiKeys) {
const binding = key.openaiAccountId
if (!binding) {
continue
}
// 处理 responses: 前缀
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
}
// 批量获取使用统计(不含 daily cost已单独获取
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const statsPipeline = client.pipeline()
for (const accountId of accountIds) {
statsPipeline.hgetall(`account_usage:${accountId}`)
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
}
const statsResults = await statsPipeline.exec()
// 处理统计数据
const allUsageStatsMap = new Map()
for (let i = 0; i < accountIds.length; i++) {
const accountId = accountIds[i]
const [errTotal, total] = statsResults[i * 3]
const [errDaily, daily] = statsResults[i * 3 + 1]
const [errMonthly, monthly] = statsResults[i * 3 + 2]
const parseUsage = (data) => ({
requests: parseInt(data?.totalRequests || data?.requests) || 0,
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
allTokens:
parseInt(data?.totalAllTokens || data?.allTokens) ||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
})
allUsageStatsMap.set(accountId, {
total: errTotal ? {} : parseUsage(total),
daily: errDaily ? {} : parseUsage(daily),
monthly: errMonthly ? {} : parseUsage(monthly)
})
}
// 处理额度信息、使用统计和绑定的 API Key 数量 // 处理额度信息、使用统计和绑定的 API Key 数量
const accountsWithStats = accounts.map((account) => { const accountsWithStats = await Promise.all(
const usageStats = allUsageStatsMap.get(account.id) || { accounts.map(async (account) => {
daily: { requests: 0, tokens: 0, allTokens: 0 }, try {
total: { requests: 0, tokens: 0, allTokens: 0 }, // 检查是否需要重置额度
monthly: { requests: 0, tokens: 0, allTokens: 0 } const today = redis.getDateStringInTimezone()
} if (account.lastResetDate !== today) {
// 今天还没重置过,需要重置
await openaiResponsesAccountService.updateAccount(account.id, {
dailyUsage: '0',
lastResetDate: today,
quotaStoppedAt: ''
})
account.dailyUsage = '0'
account.lastResetDate = today
account.quotaStoppedAt = ''
}
const groupInfos = allGroupInfosMap.get(account.id) || [] // 检查并清除过期的限流状态
const boundCount = bindingCountMap.get(account.id) || 0 await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
const dailyCost = dailyCostMap.get(account.id) || 0
const formattedAccount = formatAccountExpiry(account) // 获取使用统计信息
return { let usageStats
...formattedAccount, try {
groupInfos, usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
boundApiKeysCount: boundCount, } catch (error) {
usage: { logger.debug(
daily: { ...usageStats.daily, cost: dailyCost }, `Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
total: usageStats.total, error
monthly: usageStats.monthly )
usageStats = {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
// 计算绑定的API Key数量支持 responses: 前缀)
const allKeys = await redis.getAllApiKeys()
let boundCount = 0
for (const key of allKeys) {
// 检查是否绑定了该账户(包括 responses: 前缀)
if (
key.openaiAccountId === account.id ||
key.openaiAccountId === `responses:${account.id}`
) {
boundCount++
}
}
// 调试日志:检查绑定计数
if (boundCount > 0) {
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
boundApiKeysCount: boundCount,
usage: {
daily: usageStats.daily,
total: usageStats.total,
monthly: usageStats.monthly
}
}
} catch (error) {
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos: [],
boundApiKeysCount: 0,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
} }
} })
}) )
res.json({ success: true, data: accountsWithStats }) res.json({ success: true, data: accountsWithStats })
} catch (error) { } catch (error) {
@@ -418,7 +413,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)
@@ -437,7 +432,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,
@@ -452,85 +447,4 @@ 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

@@ -1,242 +0,0 @@
/**
* 额度卡/时间卡管理路由
*/
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

@@ -1,72 +0,0 @@
/**
* 服务倍率配置管理路由
*/
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,12 +288,10 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
// ===== OpenAI OAuth accounts ===== // ===== OpenAI OAuth accounts =====
const openaiOAuthAccounts = [] const openaiOAuthAccounts = []
{ {
const openaiIds = await redis.getAllIdsByIndex( const client = redis.getClientSafe()
'openai:account:index', const openaiKeys = await client.keys('openai:account:*')
'openai:account:*', for (const key of openaiKeys) {
/^openai:account:(.+)$/ const id = key.split(':').slice(2).join(':')
)
for (const id of openaiIds) {
const account = await openaiAccountService.getAccount(id) const account = await openaiAccountService.getAccount(id)
if (!account) { if (!account) {
continue continue
@@ -392,12 +390,10 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
// ===== OpenAI Responses API Key accounts ===== // ===== OpenAI Responses API Key accounts =====
const openaiResponsesAccounts = [] const openaiResponsesAccounts = []
const openaiResponseIds = await redis.getAllIdsByIndex( const client = redis.getClientSafe()
'openai_responses_account:index', const openaiResponseKeys = await client.keys('openai_responses_account:*')
'openai_responses_account:*', for (const key of openaiResponseKeys) {
/^openai_responses_account:(.+)$/ const id = key.split(':').slice(1).join(':')
)
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,11 +267,6 @@ 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()
} }
@@ -301,7 +296,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, apiStatsNotice } = req.body const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
// 验证输入 // 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -333,11 +328,6 @@ 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,21 +27,14 @@ const {
} = require('../services/anthropicGeminiBridgeService') } = require('../services/anthropicGeminiBridgeService')
const router = express.Router() const router = express.Router()
function queueRateLimitUpdate( function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
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, keyId, accountType) return updateRateLimitCounters(rateLimitInfo, usageSummary, model)
.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`)
@@ -129,18 +122,12 @@ async function handleMessagesRequest(req, res) {
try { try {
const startTime = Date.now() const startTime = Date.now()
const forcedVendor = req._anthropicVendor || null // Claude 服务权限校验,阻止未授权的 Key
const requiredService = if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
message: message: '此 API Key 无权访问 Claude 服务'
requiredService === 'gemini'
? '此 API Key 无权访问 Gemini 服务'
: '此 API Key 无权访问 Claude 服务'
} }
}) })
} }
@@ -189,6 +176,7 @@ async function handleMessagesRequest(req, res) {
} }
} }
const forcedVendor = req._anthropicVendor || null
logger.api('📥 /v1/messages request received', { logger.api('📥 /v1/messages request received', {
model: req.body.model || null, model: req.body.model || null,
forcedVendor, forcedVendor,
@@ -204,10 +192,34 @@ async function handleMessagesRequest(req, res) {
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) // /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim() const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel }) return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
} }
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求 // 检查是否为流式请求
const isStream = req.body.stream === true const isStream = req.body.stream === true
@@ -423,18 +435,11 @@ async function handleMessagesRequest(req, res) {
// 根据账号类型选择对应的转发服务并调用 // 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号 // 官方Claude账号使用原有的转发服务会自己选择账号
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
const _apiKeyId = req.apiKey.id
const _rateLimitInfo = req.rateLimitInfo
const _requestBody = req.body // 传递后清除引用
const _apiKey = req.apiKey
const _headers = req.headers
await claudeRelayService.relayStreamRequestWithUsageCapture( await claudeRelayService.relayStreamRequestWithUsageCapture(
_requestBody, req.body,
_apiKey, req.apiKey,
res, res,
_headers, req.headers,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -484,13 +489,13 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType) .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record stream usage:', error) logger.error('❌ Failed to record stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
_rateLimitInfo, req.rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -498,9 +503,7 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-stream', 'claude-stream'
_apiKeyId,
accountType
) )
usageDataCaptured = true usageDataCaptured = true
@@ -517,18 +520,11 @@ async function handleMessagesRequest(req, res) {
) )
} else if (accountType === 'claude-console') { } else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务需要传递accountId // Claude Console账号使用Console转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdConsole = req.apiKey.id
const _rateLimitInfoConsole = req.rateLimitInfo
const _requestBodyConsole = req.body
const _apiKeyConsole = req.apiKey
const _headersConsole = req.headers
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
_requestBodyConsole, req.body,
_apiKeyConsole, req.apiKey,
res, res,
_headersConsole, req.headers,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -579,7 +575,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService apiKeyService
.recordUsageWithDetails( .recordUsageWithDetails(
_apiKeyIdConsole, req.apiKey.id,
usageObject, usageObject,
model, model,
usageAccountId, usageAccountId,
@@ -590,7 +586,7 @@ async function handleMessagesRequest(req, res) {
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
_rateLimitInfoConsole, req.rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -598,9 +594,7 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-console-stream', 'claude-console-stream'
_apiKeyIdConsole,
accountType
) )
usageDataCaptured = true usageDataCaptured = true
@@ -618,11 +612,6 @@ async function handleMessagesRequest(req, res) {
) )
} else if (accountType === 'bedrock') { } else if (accountType === 'bedrock') {
// Bedrock账号使用Bedrock转发服务 // Bedrock账号使用Bedrock转发服务
// 🧹 内存优化:提取需要的值
const _apiKeyIdBedrock = req.apiKey.id
const _rateLimitInfoBedrock = req.rateLimitInfo
const _requestBodyBedrock = req.body
try { try {
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
if (!bedrockAccountResult.success) { if (!bedrockAccountResult.success) {
@@ -630,7 +619,7 @@ async function handleMessagesRequest(req, res) {
} }
const result = await bedrockRelayService.handleStreamRequest( const result = await bedrockRelayService.handleStreamRequest(
_requestBodyBedrock, req.body,
bedrockAccountResult.data, bedrockAccountResult.data,
res res
) )
@@ -641,22 +630,13 @@ async function handleMessagesRequest(req, res) {
const outputTokens = result.usage.output_tokens || 0 const outputTokens = result.usage.output_tokens || 0
apiKeyService apiKeyService
.recordUsage( .recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
_apiKeyIdBedrock,
inputTokens,
outputTokens,
0,
0,
result.model,
accountId,
'bedrock'
)
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record Bedrock stream usage:', error) logger.error('❌ Failed to record Bedrock stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
_rateLimitInfoBedrock, req.rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -664,9 +644,7 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens: 0 cacheReadTokens: 0
}, },
result.model, result.model,
'bedrock-stream', 'bedrock-stream'
_apiKeyIdBedrock,
'bedrock'
) )
usageDataCaptured = true usageDataCaptured = true
@@ -683,18 +661,11 @@ async function handleMessagesRequest(req, res) {
} }
} else if (accountType === 'ccr') { } else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务需要传递accountId // CCR账号使用CCR转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdCcr = req.apiKey.id
const _rateLimitInfoCcr = req.rateLimitInfo
const _requestBodyCcr = req.body
const _apiKeyCcr = req.apiKey
const _headersCcr = req.headers
await ccrRelayService.relayStreamRequestWithUsageCapture( await ccrRelayService.relayStreamRequestWithUsageCapture(
_requestBodyCcr, req.body,
_apiKeyCcr, req.apiKey,
res, res,
_headersCcr, req.headers,
(usageData) => { (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量 // 回调函数当检测到完整usage数据时记录真实token使用量
logger.info( logger.info(
@@ -744,13 +715,13 @@ async function handleMessagesRequest(req, res) {
} }
apiKeyService apiKeyService
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr') .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record CCR stream usage:', error) logger.error('❌ Failed to record CCR stream usage:', error)
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
_rateLimitInfoCcr, req.rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -758,9 +729,7 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'ccr-stream', 'ccr-stream'
_apiKeyIdCcr,
'ccr'
) )
usageDataCaptured = true usageDataCaptured = true
@@ -787,26 +756,18 @@ async function handleMessagesRequest(req, res) {
} }
}, 1000) // 1秒后检查 }, 1000) // 1秒后检查
} else { } else {
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
const _apiKeyIdNonStream = req.apiKey.id
const _apiKeyNameNonStream = req.apiKey.name
const _rateLimitInfoNonStream = req.rateLimitInfo
const _requestBodyNonStream = req.body
const _apiKeyNonStream = req.apiKey
const _headersNonStream = req.headers
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开) // 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) { if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn( logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}` `⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
) )
return undefined return undefined
} }
// 非流式响应 - 只使用官方真实usage数据 // 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', { logger.info('📄 Starting non-streaming request', {
apiKeyId: _apiKeyIdNonStream, apiKeyId: req.apiKey.id,
apiKeyName: _apiKeyNameNonStream apiKeyName: req.apiKey.name
}) })
// 📊 监听 socket 事件以追踪连接状态变化 // 📊 监听 socket 事件以追踪连接状态变化
@@ -977,11 +938,11 @@ async function handleMessagesRequest(req, res) {
? await claudeAccountService.getAccount(accountId) ? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId) : await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) { if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api( logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})` `🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
) )
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model)) return res.json(buildMockWarmupResponse(req.body.model))
} }
} }
@@ -994,11 +955,11 @@ async function handleMessagesRequest(req, res) {
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务 // 官方Claude账号使用原有的转发服务
response = await claudeRelayService.relayRequest( response = await claudeRelayService.relayRequest(
_requestBodyNonStream, req.body,
_apiKeyNonStream, req.apiKey,
req, // clientRequest 用于断开检测,保留但服务层已优化 req,
res, res,
_headersNonStream req.headers
) )
} else if (accountType === 'claude-console') { } else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务 // Claude Console账号使用Console转发服务
@@ -1006,11 +967,11 @@ async function handleMessagesRequest(req, res) {
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
) )
response = await claudeConsoleRelayService.relayRequest( response = await claudeConsoleRelayService.relayRequest(
_requestBodyNonStream, req.body,
_apiKeyNonStream, req.apiKey,
req, // clientRequest 保留用于断开检测 req,
res, res,
_headersNonStream, req.headers,
accountId accountId
) )
} else if (accountType === 'bedrock') { } else if (accountType === 'bedrock') {
@@ -1022,9 +983,9 @@ async function handleMessagesRequest(req, res) {
} }
const result = await bedrockRelayService.handleNonStreamRequest( const result = await bedrockRelayService.handleNonStreamRequest(
_requestBodyNonStream, req.body,
bedrockAccountResult.data, bedrockAccountResult.data,
_headersNonStream req.headers
) )
// 构建标准响应格式 // 构建标准响应格式
@@ -1054,11 +1015,11 @@ async function handleMessagesRequest(req, res) {
// CCR账号使用CCR转发服务 // CCR账号使用CCR转发服务
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
response = await ccrRelayService.relayRequest( response = await ccrRelayService.relayRequest(
_requestBodyNonStream, req.body,
_apiKeyNonStream, req.apiKey,
req, // clientRequest 保留用于断开检测 req,
res, res,
_headersNonStream, req.headers,
accountId accountId
) )
} }
@@ -1107,25 +1068,24 @@ async function handleMessagesRequest(req, res) {
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown' const rawModel = jsonData.model || req.body.model || 'unknown'
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID // 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response const { accountId: responseAccountId } = response
await apiKeyService.recordUsage( await apiKeyService.recordUsage(
_apiKeyIdNonStream, req.apiKey.id,
inputTokens, inputTokens,
outputTokens, outputTokens,
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
model, model,
responseAccountId, responseAccountId
accountType
) )
await queueRateLimitUpdate( await queueRateLimitUpdate(
_rateLimitInfoNonStream, req.rateLimitInfo,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -1133,9 +1093,7 @@ async function handleMessagesRequest(req, res) {
cacheReadTokens cacheReadTokens
}, },
model, model,
'claude-non-stream', 'claude-non-stream'
_apiKeyIdNonStream,
accountType
) )
usageRecorded = true usageRecorded = true
@@ -1292,7 +1250,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。 //(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') { if (forcedVendor === 'antigravity') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) { const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
@@ -1485,25 +1444,34 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) // 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null const forcedVendor = req._anthropicVendor || null
const requiredService = if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Gemini'
}
})
}
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
message: message: 'This API key does not have permission to access Claude'
requiredService === 'gemini'
? 'This API key does not have permission to access Gemini'
: 'This API key does not have permission to access Claude'
} }
}) })
} }
if (requiredService === 'gemini') {
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 🔗 会话绑定验证(与 messages 端点保持一致) // 🔗 会话绑定验证(与 messages 端点保持一致)
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
const sessionValidation = await claudeRelayConfigService.validateNewSession( const sessionValidation = await claudeRelayConfigService.validateNewSession(

View File

@@ -5,39 +5,10 @@ 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')
@@ -68,7 +39,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
@@ -116,7 +87,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'
@@ -184,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients, allowedClients,
permissions: keyData.permissions, permissions: keyData.permissions || 'all',
// 添加激活相关字段 // 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed', expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true', isActivated: keyData.isActivated === 'true',
@@ -195,7 +166,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'
@@ -220,7 +191,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'
@@ -253,16 +224,17 @@ router.post('/api/user-stats', async (req, res) => {
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`) logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else { } else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键 // Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`) const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map() const modelUsageMap = new Map()
for (const { key, data } of allModelResults) { for (const key of allModelKeys) {
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)) {
@@ -503,20 +475,7 @@ 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({
@@ -639,18 +598,7 @@ 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 {}
}
})()
} }
}) })
) )
@@ -754,7 +702,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')}`
@@ -769,9 +717,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 results = await redis.scanAndGetAllChunked(pattern) const keys = await client.keys(pattern)
for (const { key, data } of results) { for (const key of keys) {
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}$/
@@ -783,6 +731,7 @@ 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)) {
@@ -792,10 +741,7 @@ 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
}) })
} }
@@ -806,18 +752,12 @@ 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 = {
@@ -827,18 +767,8 @@ 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,
@@ -849,8 +779,7 @@ 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
}) })
} }
@@ -873,19 +802,13 @@ 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', prompt = 'hi' } = req.body const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
if (!apiKey) { if (!apiKey) {
return res.status(400).json({ return res.status(400).json({
@@ -918,7 +841,7 @@ router.post('/api-key/test', async (req, res) => {
apiUrl, apiUrl,
authorization: apiKey, authorization: apiKey,
responseStream: res, responseStream: res,
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }), payload: createClaudeTestPayload(model, { stream: true }),
timeout: 60000, timeout: 60000,
extraHeaders: { 'x-api-key': apiKey } extraHeaders: { 'x-api-key': apiKey }
}) })
@@ -928,317 +851,13 @@ 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: getSafeMessage(error) message: error.message || 'Internal server error'
}) })
} }
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) res.write(
res.end() `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
}
})
// 🧪 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()
} }
}) })
@@ -1267,7 +886,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'
@@ -1323,37 +942,33 @@ 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')}`
let pattern const pattern =
let matchRegex period === 'daily'
if (period === 'daily') { ? `usage:${keyId}:model:daily:*:${today}`
pattern = `usage:${keyId}:model:daily:*:${today}` : `usage:${keyId}:model:monthly:*:${currentMonth}`
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 results = await redis.scanAndGetAllChunked(pattern) const keys = await client.keys(pattern)
const modelStats = [] const modelStats = []
for (const { key, data } of results) { for (const key of keys) {
const match = key.match(matchRegex) const match = key.match(
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 = {
@@ -1363,30 +978,8 @@ 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,
@@ -1394,11 +987,10 @@ 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, allTokens: parseInt(data.allTokens) || 0,
costs: costData.costs, costs: costData.costs,
formatted: costData.formatted, formatted: costData.formatted,
pricing: costData.pricing, pricing: costData.pricing
isLegacy: !hasStoredCost
}) })
} }
} }
@@ -1426,170 +1018,4 @@ 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,8 +86,7 @@ class AtomicUsageReporter {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
modelToRecord, modelToRecord,
accountId, accountId
'azure-openai'
) )
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量 // 同步更新 Azure 账户的 lastUsedAt 和累计使用量

View File

@@ -29,7 +29,6 @@ const {
handleStreamGenerateContent, handleStreamGenerateContent,
handleLoadCodeAssist, handleLoadCodeAssist,
handleOnboardUser, handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens, handleCountTokens,
handleStandardGenerateContent, handleStandardGenerateContent,
handleStandardStreamGenerateContent, handleStandardStreamGenerateContent,
@@ -69,7 +68,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
router.get('/key-info', authenticateApiKey, handleKeyInfo) router.get('/key-info', authenticateApiKey, handleKeyInfo)
// ============================================================================ // ============================================================================
// v1internal 独有路由 // v1internal 独有路由listExperiments
// ============================================================================ // ============================================================================
/** /**
@@ -82,12 +81,6 @@ 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

@@ -8,12 +8,10 @@ const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const claudeRelayService = require('../services/claudeRelayService') const claudeRelayService = require('../services/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
const openaiToClaude = require('../services/openaiToClaude') 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')
@@ -21,24 +19,18 @@ const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限 // 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') { function checkPermissions(apiKeyData, requiredPermission = 'claude') {
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
} }
function queueRateLimitUpdate( function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
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, keyId, accountType) updateRateLimitCounters(rateLimitInfo, usageSummary, model)
.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`)
@@ -243,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
} }
throw error throw error
} }
const { accountId, accountType } = accountSelection const { accountId } = accountSelection
// 获取该账号存储的 Claude Code headers // 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
@@ -273,107 +265,72 @@ async function handleChatCompletion(req, res, apiKeyData) {
} }
}) })
// 使用转换后的响应流 (根据账户类型选择转发服务) // 使用转换后的响应流 (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
// 创建 usage 回调函数 await claudeRelayService.relayStreamRequestWithUsageCapture(
const usageCallback = (usage) => { claudeRequest,
// 记录使用统计 apiKeyData,
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { res,
const model = usage.model || claudeRequest.model claudeCodeHeaders,
const cacheCreateTokens = (usage) => {
(usage.cache_creation && typeof usage.cache_creation === 'object' // 记录使用统计
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
(usage.cache_creation.ephemeral_1h_input_tokens || 0) const model = usage.model || claudeRequest.model
: usage.cache_creation_input_tokens || 0) || 0 const cacheCreateTokens =
const cacheReadTokens = usage.cache_read_input_tokens || 0 (usage.cache_creation && typeof usage.cache_creation === 'object'
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
: usage.cache_creation_input_tokens || 0) || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
apiKeyService apiKeyService
.recordUsageWithDetails( .recordUsageWithDetails(
apiKeyData.id, apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
model,
accountId
)
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens,
cacheReadTokens
},
model, model,
accountId, 'openai-claude-stream'
accountType
) )
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens,
cacheReadTokens
},
model,
`openai-${accountType}-stream`,
req.apiKey?.id,
accountType
)
}
}
// 创建流转换器
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
const streamTransformer = (chunk) =>
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
// 根据账户类型选择转发服务
if (accountType === 'claude-console') {
// Claude Console 账户使用 Console 转发服务
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
accountId,
streamTransformer
)
} else {
// Claude Official 账户使用标准转发服务
await claudeRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
streamTransformer,
{
betaHeader:
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
} }
) },
} // 流转换器
(() => {
// 为每个请求创建独立的会话ID
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
})(),
{
betaHeader:
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
}
)
} else { } else {
// 非流式请求 // 非流式请求
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`) logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
// 根据账户类型选择转发服务 // 发送请求到 Claude (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
let claudeResponse const claudeResponse = await claudeRelayService.relayRequest(
if (accountType === 'claude-console') { claudeRequest,
// Claude Console 账户使用 Console 转发服务 apiKeyData,
claudeResponse = await claudeConsoleRelayService.relayRequest( req,
claudeRequest, res,
apiKeyData, claudeCodeHeaders,
req, { betaHeader: 'oauth-2025-04-20' }
res, )
claudeCodeHeaders,
accountId
)
} else {
// Claude Official 账户使用标准转发服务
claudeResponse = await claudeRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
res,
claudeCodeHeaders,
{ betaHeader: 'oauth-2025-04-20' }
)
}
// 解析 Claude 响应 // 解析 Claude 响应
let claudeData let claudeData
@@ -419,8 +376,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
apiKeyData.id, apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
claudeRequest.model, claudeRequest.model,
accountId, accountId
accountType
) )
.catch((error) => { .catch((error) => {
logger.error('❌ Failed to record usage:', error) logger.error('❌ Failed to record usage:', error)
@@ -435,9 +391,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens cacheReadTokens
}, },
claudeRequest.model, claudeRequest.model,
`openai-${accountType}-non-stream`, 'openai-claude-non-stream'
req.apiKey?.id,
accountType
) )
} }
@@ -448,29 +402,16 @@ async function handleChatCompletion(req, res, apiKeyData) {
const duration = Date.now() - startTime const duration = Date.now() - startTime
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`) logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
} catch (error) { } catch (error) {
// 客户端主动断开连接是正常情况,使用 INFO 级别 logger.error('❌ OpenAI-Claude request error:', error)
if (error.message === 'Client disconnected') {
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
} else {
logger.error('❌ OpenAI-Claude request error:', error)
}
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT const status = error.status || 500
if (!res.headersSent) { res.status(status).json({
// 客户端断开使用 499 状态码 (Client Closed Request) error: {
if (error.message === 'Client disconnected') { message: error.message || 'Internal server error',
res.status(499).end() type: 'server_error',
} else { code: 'internal_error'
const status = error.status || 500
res.status(status).json({
error: {
message: getSafeMessage(error),
type: 'server_error',
code: 'internal_error'
}
})
} }
} })
} finally { } finally {
// 清理资源 // 清理资源
if (abortController) { if (abortController) {

View File

@@ -539,8 +539,7 @@ 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}`
@@ -641,8 +640,7 @@ 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}`
@@ -675,24 +673,17 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
} }
} }
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT // 返回 OpenAI 格式的错误响应
if (!res.headersSent) { const status = error.status || 500
// 客户端断开使用 499 状态码 (Client Closed Request) const errorResponse = {
if (error.message === 'Client disconnected') { error: error.error || {
res.status(499).end() message: error.message || 'Internal server error',
} else { type: 'server_error',
// 返回 OpenAI 格式的错误响应 code: 'internal_error'
const status = error.status || 500
const errorResponse = {
error: error.error || {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
}
res.status(status).json(errorResponse)
} }
} }
res.status(status).json(errorResponse)
} finally { } finally {
// 清理资源 // 清理资源
if (abortController) { if (abortController) {
@@ -702,8 +693,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
return undefined return undefined
}) })
// 获取可用模型列表的共享处理器 // OpenAI 兼容的模型列表端点
async function handleGetModels(req, res) { router.get('/v1/models', authenticateApiKey, async (req, res) => {
try { try {
const apiKeyData = req.apiKey const apiKeyData = req.apiKey
@@ -791,13 +782,8 @@ async function handleGetModels(req, res) {
} }
}) })
} }
} return undefined
})
// OpenAI 兼容的模型列表端点 (带 v1 版)
router.get('/v1/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
router.get('/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型详情端点 // OpenAI 兼容的模型详情端点
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {

View File

@@ -9,12 +9,9 @@ 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) {
@@ -70,7 +67,7 @@ function extractCodexUsageHeaders(headers) {
return hasData ? snapshot : null return hasData ? snapshot : null
} }
async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) { async function applyRateLimitTracking(req, usageSummary, model, context = '') {
if (!req.rateLimitInfo) { if (!req.rateLimitInfo) {
return return
} }
@@ -81,9 +78,7 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '', ac
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) {
@@ -279,9 +274,7 @@ 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]
@@ -582,6 +575,7 @@ 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
@@ -617,8 +611,7 @@ 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(
@@ -634,8 +627,7 @@ const handleResponses = async (req, res) => {
cacheReadTokens cacheReadTokens
}, },
actualModel, actualModel,
'openai-non-stream', 'openai-non-stream'
'openai'
) )
} }
@@ -651,50 +643,74 @@ const handleResponses = async (req, res) => {
} }
} }
// 使用增量 SSE 解析器 // 解析 SSE 事件以捕获 usage 数据和 model
const sseParser = new IncrementalSSEParser() const parseSSEForUsage = (data) => {
const lines = data.split('\n')
// 处理解析出的事件 for (const line of lines) {
const processSSEEvent = (eventData) => { if (line.startsWith('event: response.completed')) {
// 检查是否是 response.completed 事件 // 下一行应该是数据
if (eventData.type === 'response.completed' && eventData.response) { continue
// 从响应中获取真实的 model
if (eventData.response.model) {
actualModel = eventData.response.model
logger.debug(`📊 Captured actual model: ${actualModel}`)
} }
// 获取 usage 数据 if (line.startsWith('data: ')) {
if (eventData.response.usage) { try {
usageData = eventData.response.usage const jsonStr = line.slice(6) // 移除 'data: ' 前缀
logger.debug('📊 Captured OpenAI usage data:', usageData) const eventData = JSON.parse(jsonStr)
}
}
// 检查是否有限流错误 // 检查是否是 response.completed 事件
if (eventData.error && eventData.error.type === 'usage_limit_reached') { if (eventData.type === 'response.completed' && eventData.response) {
rateLimitDetected = true // 从响应中获取真实的 model
if (eventData.error.resets_in_seconds) { if (eventData.response.model) {
rateLimitResetsInSeconds = eventData.error.resets_in_seconds actualModel = eventData.response.model
logger.warn( logger.debug(`📊 Captured actual model: ${actualModel}`)
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds` }
)
// 获取 usage 数据
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.debug('📊 Captured OpenAI usage data:', usageData)
}
}
// 检查是否有限流错误
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
rateLimitDetected = true
if (eventData.error.resets_in_seconds) {
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
logger.warn(
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
)
}
}
} 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 信息
const events = sseParser.feed(chunk.toString()) buffer += chunkStr
for (const event of events) {
if (event.type === 'data' && event.data) { // 处理完整的 SSE 事件
processSSEEvent(event.data) if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim()) {
parseSSEForUsage(event)
}
} }
} }
} catch (error) { } catch (error) {
@@ -704,14 +720,8 @@ const handleResponses = async (req, res) => {
upstream.data.on('end', async () => { upstream.data.on('end', async () => {
// 处理剩余的 buffer // 处理剩余的 buffer
const remaining = sseParser.getRemaining() if (buffer.trim()) {
if (remaining.trim()) { parseSSEForUsage(buffer)
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
for (const event of events) {
if (event.type === 'data' && event.data) {
processSSEEvent(event.data)
}
}
} }
// 记录使用统计 // 记录使用统计
@@ -733,8 +743,7 @@ 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(
@@ -751,8 +760,7 @@ 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)
@@ -842,15 +850,13 @@ const handleResponses = async (req, res) => {
let responsePayload = error.response?.data let responsePayload = error.response?.data
if (!responsePayload) { if (!responsePayload) {
responsePayload = { error: { message: getSafeMessage(error) } } responsePayload = { error: { message: error.message || 'Internal server error' } }
} else if (typeof responsePayload === 'string') { } else if (typeof responsePayload === 'string') {
responsePayload = { error: { message: getSafeMessage(responsePayload) } } responsePayload = { error: { message: responsePayload } }
} else if (typeof responsePayload === 'object' && !responsePayload.error) { } else if (typeof responsePayload === 'object' && !responsePayload.error) {
responsePayload = { responsePayload = {
error: { message: getSafeMessage(responsePayload.message || error) } error: { message: responsePayload.message || error.message || 'Internal server error' }
} }
} else if (responsePayload.error?.message) {
responsePayload.error.message = getSafeMessage(responsePayload.error.message)
} }
if (!res.headersSent) { if (!res.headersSent) {
@@ -868,18 +874,16 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
// 使用情况统计端点 // 使用情况统计端点
router.get('/usage', authenticateApiKey, async (req, res) => { router.get('/usage', authenticateApiKey, async (req, res) => {
try { try {
const keyData = req.apiKey const { usage } = req.apiKey
// 按需查询 usage 数据
const usage = await redis.getUsageStats(keyData.id)
res.json({ res.json({
object: 'usage', object: 'usage',
total_tokens: usage?.total?.tokens || 0, total_tokens: usage.total.tokens,
total_requests: usage?.total?.requests || 0, total_requests: usage.total.requests,
daily_tokens: usage?.daily?.tokens || 0, daily_tokens: usage.daily.tokens,
daily_requests: usage?.daily?.requests || 0, daily_requests: usage.daily.requests,
monthly_tokens: usage?.monthly?.tokens || 0, monthly_tokens: usage.monthly.tokens,
monthly_requests: usage?.monthly?.requests || 0 monthly_requests: usage.monthly.requests
}) })
} catch (error) { } catch (error) {
logger.error('Failed to get usage stats:', error) logger.error('Failed to get usage stats:', error)
@@ -896,26 +900,25 @@ 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 || 'all',
token_limit: keyData.tokenLimit, token_limit: keyData.tokenLimit,
tokens_used: tokensUsed, tokens_used: keyData.usage.total.tokens,
tokens_remaining: tokens_remaining:
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, keyData.tokenLimit > 0
? 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: usage?.total || {}, total: keyData.usage.total,
daily: usage?.daily || {}, daily: keyData.usage.daily,
monthly: usage?.monthly || {} monthly: keyData.usage.monthly
} }
}) })
} catch (error) { } catch (error) {

View File

@@ -46,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`) logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限 // 检查权限
const { permissions } = req.apiKey const permissions = req.apiKey.permissions || 'all'
if (backend === 'claude') { if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层 // Claude 后端:通过 OpenAI 兼容层
if (!apiKeyService.hasPermission(permissions, 'claude')) { if (permissions !== 'all' && permissions !== 'claude') {
return res.status(403).json({ return res.status(403).json({
error: { error: {
message: 'This API key does not have permission to access Claude', message: 'This API key does not have permission to access Claude',
@@ -62,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
await handleChatCompletion(req, res, req.apiKey) await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') { } else if (backend === 'openai') {
// OpenAI 后端 // OpenAI 后端
if (!apiKeyService.hasPermission(permissions, 'openai')) { if (permissions !== 'all' && permissions !== 'openai') {
return res.status(403).json({ return res.status(403).json({
error: { error: {
message: 'This API key does not have permission to access OpenAI', message: 'This API key does not have permission to access OpenAI',

View File

@@ -761,166 +761,4 @@ 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

@@ -226,15 +226,7 @@ class AccountBalanceService {
return null return null
} }
const result = await service.getAccount(accountId) return await service.getAccount(accountId)
// 处理不同服务返回格式的差异
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.success ? result.data : null
}
return result
} }
async getAllAccountsByPlatform(platform) { async getAllAccountsByPlatform(platform) {
@@ -278,32 +270,15 @@ class AccountBalanceService {
} }
async _getAccountBalanceForAccount(account, platform, options = {}) { async _getAccountBalanceForAccount(account, platform, options = {}) {
const queryMode = this._parseQueryMode(options.queryApi) const queryApi = this._parseBoolean(options.queryApi) || false
const useCache = options.useCache !== false const useCache = options.useCache !== false
const accountId = account?.id const accountId = account?.id
if (!accountId) { if (!accountId) {
// 如果账户缺少 id返回空响应而不是抛出错误避免接口报错和UI错误 throw new Error('账户缺少 id')
this.logger.warn('账户缺少 id返回空余额数据', { account, platform })
return this._buildResponse(
{
status: 'error',
errorMessage: '账户数据异常',
balance: null,
currency: 'USD',
quota: null,
statistics: {},
lastRefreshAt: new Date().toISOString()
},
'unknown',
platform,
'local',
null,
{ scriptEnabled: false, scriptConfigured: false }
)
} }
// 余额脚本配置状态(用于前端控制"刷新余额"按钮) // 余额脚本配置状态(用于前端控制刷新余额按钮)
let scriptConfig = null let scriptConfig = null
let scriptConfigured = false let scriptConfigured = false
if (typeof this.redis?.getBalanceScriptConfig === 'function') { if (typeof this.redis?.getBalanceScriptConfig === 'function') {
@@ -322,14 +297,8 @@ class AccountBalanceService {
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics) const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
// 安全限制queryApi=auto 仅用于 Antigravitygemini + oauthProvider=antigravity账户 // 非强制查询:优先读缓存
const effectiveQueryMode = if (!queryApi) {
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
? 'local'
: queryMode
// local: 仅本地统计/缓存auto: 优先缓存,无缓存则尝试远程 Provider并缓存结果
if (effectiveQueryMode !== 'api') {
if (useCache) { if (useCache) {
const cached = await this.redis.getAccountBalance(platform, accountId) const cached = await this.redis.getAccountBalance(platform, accountId)
if (cached && cached.status === 'success') { if (cached && cached.status === 'success') {
@@ -352,24 +321,22 @@ class AccountBalanceService {
} }
} }
if (effectiveQueryMode === 'local') { return this._buildResponse(
return this._buildResponse( {
{ status: 'success',
status: 'success', errorMessage: null,
errorMessage: null, balance: quotaFromLocal.balance,
balance: quotaFromLocal.balance, currency: quotaFromLocal.currency || 'USD',
currency: quotaFromLocal.currency || 'USD', quota: quotaFromLocal.quota,
quota: quotaFromLocal.quota, statistics: localStatistics,
statistics: localStatistics, lastRefreshAt: localBalance.lastCalculated
lastRefreshAt: localBalance.lastCalculated },
}, accountId,
accountId, platform,
platform, 'local',
'local', null,
null, scriptMeta
scriptMeta )
)
}
} }
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider失败自动降级到本地统计 // 强制查询:优先脚本(如启用且已配置),否则调用 Provider失败自动降级到本地统计
@@ -756,14 +723,6 @@ class AccountBalanceService {
return null return null
} }
_parseQueryMode(value) {
if (value === 'auto') {
return 'auto'
}
const parsed = this._parseBoolean(value)
return parsed ? 'api' : 'local'
}
async _mapWithConcurrency(items, limit, mapper) { async _mapWithConcurrency(items, limit, mapper) {
const concurrency = Math.max(1, Number(limit) || 1) const concurrency = Math.max(1, Number(limit) || 1)
const list = Array.isArray(items) ? items : [] const list = Array.isArray(items) ? items : []

View File

@@ -7,62 +7,6 @@ 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)
}
} }
/** /**
@@ -106,7 +50,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) {
@@ -157,7 +101,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) {
@@ -199,7 +143,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
@@ -290,10 +234,7 @@ 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
@@ -304,26 +245,15 @@ 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, platform = null) { async removeAccountFromGroup(accountId, groupId) {
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
@@ -469,7 +399,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
@@ -479,9 +409,8 @@ class AccountGroupService {
/** /**
* 从所有分组中移除账户 * 从所有分组中移除账户
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @param {string} platform - 平台(可选,用于清理反向索引)
*/ */
async removeAccountFromAllGroups(accountId, platform = null) { async removeAccountFromAllGroups(accountId) {
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)
@@ -490,155 +419,12 @@ 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()

File diff suppressed because it is too large Load Diff

View File

@@ -64,8 +64,7 @@ function getAntigravityHeaders(accessToken, baseUrl) {
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64', 'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept-Encoding': 'gzip', 'Accept-Encoding': 'gzip'
requestType: 'agent'
} }
} }
@@ -305,11 +304,6 @@ async function request({
} }
const isRetryable = (error) => { const isRetryable = (error) => {
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true
}
const status = error?.response?.status const status = error?.response?.status
if (status === 429) { if (status === 429) {
return true return true
@@ -435,37 +429,7 @@ async function request({
const status = error?.response?.status const status = error?.response?.status
if (status === 429 && !retriedAfterDelay && !signal?.aborted) { if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
const data = error?.response?.data const data = error?.response?.data
const msg = typeof data === 'string' ? data : JSON.stringify(data || '')
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
const safeDataToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// stream 对象存在循环引用,不能 JSON.stringify
if (typeof value === 'object' && typeof value.pipe === 'function') {
return ''
}
if (Buffer.isBuffer(value)) {
try {
return value.toString('utf8')
} catch (_) {
return ''
}
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (_) {
return ''
}
}
return String(value)
}
const msg = safeDataToString(data)
if ( if (
msg.toLowerCase().includes('resource_exhausted') || msg.toLowerCase().includes('resource_exhausted') ||
msg.toLowerCase().includes('no capacity') msg.toLowerCase().includes('no capacity')

View File

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

View File

@@ -1,654 +0,0 @@
/**
* 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,7 +150,6 @@ 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') {
@@ -271,9 +270,6 @@ 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)
@@ -283,22 +279,16 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const accountIds = await redisClient.getAllIdsByIndex( const client = redisClient.getClientSafe()
'azure_openai:account:index', const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
/^azure_openai:account:(.+)$/
)
if (!accountIds || accountIds.length === 0) { if (!keys || keys.length === 0) {
return [] return []
} }
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
const accounts = [] const accounts = []
const dataList = await redisClient.batchHgetallChunked(keys) for (const key of keys) {
const accountData = await client.hgetall(key)
for (let i = 0; i < keys.length; i++) {
const accountData = dataList[i]
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
// 不返回敏感数据给前端 // 不返回敏感数据给前端
delete accountData.apiKey delete accountData.apiKey

View File

@@ -1,250 +0,0 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
const antigravityClient = require('../antigravityClient')
const geminiAccountService = require('../geminiAccountService')
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
function clamp01(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
if (value < 0) {
return 0
}
if (value > 1) {
return 1
}
return value
}
function round2(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
return Math.round(value * 100) / 100
}
function normalizeQuotaCategory(displayName, modelId) {
const name = String(displayName || '')
const id = String(modelId || '')
if (name.includes('Gemini') && name.includes('Pro')) {
return 'Gemini Pro'
}
if (name.includes('Gemini') && name.includes('Flash')) {
return 'Gemini Flash'
}
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
return 'Gemini Image'
}
if (name.includes('Claude') || name.includes('GPT-OSS')) {
return 'Claude'
}
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
return 'Gemini Pro'
}
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
return 'Gemini Flash'
}
if (id.includes('image')) {
return 'Gemini Image'
}
if (id.includes('claude') || id.includes('gpt-oss')) {
return 'Claude'
}
return name || id || 'Unknown'
}
function buildAntigravityQuota(modelsResponse) {
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
if (!models || typeof models !== 'object') {
return null
}
const parseRemainingFraction = (quotaInfo) => {
if (!quotaInfo || typeof quotaInfo !== 'object') {
return null
}
const raw =
quotaInfo.remainingFraction ??
quotaInfo.remaining_fraction ??
quotaInfo.remaining ??
undefined
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
if (!Number.isFinite(num)) {
return null
}
return clamp01(num)
}
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const categoryMap = new Map()
for (const [modelId, modelDataRaw] of Object.entries(models)) {
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
continue
}
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
const remainingFraction = parseRemainingFraction(quotaInfo)
if (remainingFraction === null) {
continue
}
const remainingPercent = round2(remainingFraction * 100)
const usedPercent = round2(100 - remainingPercent)
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
const category = normalizeQuotaCategory(displayName, modelId)
if (!allowedCategories.has(category)) {
continue
}
const entry = {
category,
modelId,
displayName: String(displayName || modelId || category),
remainingPercent,
usedPercent,
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
}
const existing = categoryMap.get(category)
if (!existing || entry.remainingPercent < existing.remainingPercent) {
categoryMap.set(category, entry)
}
}
const buckets = fixedOrder.map((category) => {
const existing = categoryMap.get(category) || null
if (existing) {
return existing
}
return {
category,
modelId: '',
displayName: category,
remainingPercent: null,
usedPercent: null,
resetAt: null
}
})
if (buckets.length === 0) {
return null
}
const critical = buckets
.filter((item) => item.remainingPercent !== null)
.reduce((min, item) => {
if (!min) {
return item
}
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
}, null)
if (!critical) {
return null
}
return {
balance: null,
currency: 'USD',
quota: {
type: 'antigravity',
total: 100,
used: critical.usedPercent,
remaining: critical.remainingPercent,
percentage: critical.usedPercent,
resetAt: critical.resetAt,
buckets: buckets.map((item) => ({
category: item.category,
remaining: item.remainingPercent,
used: item.usedPercent,
percentage: item.usedPercent,
resetAt: item.resetAt
}))
},
queryMethod: 'api',
rawData: {
modelsCount: Object.keys(models).length,
bucketCount: buckets.length
}
}
}
class GeminiBalanceProvider extends BaseBalanceProvider {
constructor() {
super('gemini')
}
async queryBalance(account) {
const oauthProvider = account?.oauthProvider
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return this.readQuotaFromFields(account)
}
return { balance: null, currency: 'USD', queryMethod: 'local' }
}
const accessToken = String(account?.accessToken || '').trim()
const refreshToken = String(account?.refreshToken || '').trim()
const proxyConfig = account?.proxyConfig || account?.proxy || null
if (!accessToken) {
throw new Error('Antigravity 账户缺少 accessToken')
}
const fetch = async (token) =>
await antigravityClient.fetchAvailableModels({
accessToken: token,
proxyConfig
})
let data
try {
data = await fetch(accessToken)
} catch (error) {
const status = error?.response?.status
if ((status === 401 || status === 403) && refreshToken) {
const refreshed = await geminiAccountService.refreshAccessToken(
refreshToken,
proxyConfig,
OAUTH_PROVIDER_ANTIGRAVITY
)
const nextToken = String(refreshed?.access_token || '').trim()
if (!nextToken) {
throw error
}
data = await fetch(nextToken)
} else {
throw error
}
}
const mapped = buildAntigravityQuota(data)
if (!mapped) {
return {
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: data || null
}
}
return mapped
}
}
module.exports = GeminiBalanceProvider

View File

@@ -2,7 +2,6 @@ const ClaudeBalanceProvider = require('./claudeBalanceProvider')
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider') const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider') const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
const GenericBalanceProvider = require('./genericBalanceProvider') const GenericBalanceProvider = require('./genericBalanceProvider')
const GeminiBalanceProvider = require('./geminiBalanceProvider')
function registerAllProviders(balanceService) { function registerAllProviders(balanceService) {
// Claude // Claude
@@ -15,7 +14,7 @@ function registerAllProviders(balanceService) {
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai')) balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
// 其他平台(降级) // 其他平台(降级)
balanceService.registerProvider('gemini', new GeminiBalanceProvider()) balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api')) balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock')) balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
balanceService.registerProvider('droid', new GenericBalanceProvider('droid')) balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))

View File

@@ -2,50 +2,6 @@ const vm = require('vm')
const axios = require('axios') const axios = require('axios')
const { isBalanceScriptEnabled } = require('../utils/featureFlags') const { isBalanceScriptEnabled } = require('../utils/featureFlags')
/**
* SSRF防护检查URL是否访问内网或敏感地址
* @param {string} url - 要检查的URL
* @returns {boolean} - true表示URL安全
*/
function isUrlSafe(url) {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
// 禁止的协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false
}
// 禁止访问localhost和私有IP
const privatePatterns = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./, // AWS metadata
/^0\./, // 0.0.0.0
/^::1$/,
/^fc00:/i,
/^fe80:/i,
/\.local$/i,
/\.internal$/i,
/\.localhost$/i
]
for (const pattern of privatePatterns) {
if (pattern.test(hostname)) {
return false
}
}
return true
} catch {
return false
}
}
/** /**
* 可配置脚本余额查询执行器 * 可配置脚本余额查询执行器
* - 脚本格式:({ request: {...}, extractor: function(response){...} }) * - 脚本格式:({ request: {...}, extractor: function(response){...} })
@@ -99,11 +55,6 @@ class BalanceScriptService {
throw new Error('脚本 request.url 不能为空') throw new Error('脚本 request.url 不能为空')
} }
// SSRF防护验证URL安全性
if (!isUrlSafe(request.url)) {
throw new Error('脚本 request.url 不安全禁止访问内网地址、localhost或使用非HTTP(S)协议')
}
if (typeof extractor !== 'function') { if (typeof extractor !== 'function') {
throw new Error('脚本 extractor 必须是函数') throw new Error('脚本 extractor 必须是函数')
} }

View File

@@ -35,13 +35,12 @@ class BedrockAccountService {
description = '', description = '',
region = process.env.AWS_REGION || 'us-east-1', region = process.env.AWS_REGION || 'us-east-1',
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken } awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0', defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
isActive = true, isActive = true,
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key credentialType = 'default' // 'default', 'access_key', 'bearer_token'
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -72,14 +71,8 @@ class BedrockAccountService {
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
} }
// 加密存储 Bearer Token
if (bearerToken) {
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
}
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}`)
@@ -113,85 +106,9 @@ class BedrockAccountService {
const account = JSON.parse(accountData) const account = JSON.parse(accountData)
// 根据凭证类型解密对应的凭证 // 解密AWS凭证用于内部使用
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底) if (account.awsCredentials) {
try { account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
let accessKeyDecrypted = false
let bearerTokenDecrypted = false
// 第一步:按照 credentialType 尝试解密对应的凭证
if (account.credentialType === 'access_key' && account.awsCredentials) {
// Access Key 模式:解密 AWS 凭证
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.debug(
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
// Bearer Token 模式:解密 Bearer Token
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.debug(
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (!account.credentialType || account.credentialType === 'default') {
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
}
logger.debug(
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
)
}
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.warn(
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
)
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.warn(
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
)
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.warn(
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
)
}
}
// 验证至少解密了一种凭证
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.error(
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
)
return {
success: false,
error: 'No valid credentials found in account data'
}
}
} catch (decryptError) {
logger.error(
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
decryptError
)
return {
success: false,
error: `Credentials decryption failed: ${decryptError.message}`
}
} }
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
@@ -209,18 +126,12 @@ class BedrockAccountService {
// 📋 获取所有账户列表 // 📋 获取所有账户列表
async getAllAccounts() { async getAllAccounts() {
try { try {
const _client = redis.getClientSafe() const client = redis.getClientSafe()
const accountIds = await redis.getAllIdsByIndex( const keys = await client.keys('bedrock_account:*')
'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 (let i = 0; i < keys.length; i++) { for (const key of keys) {
const accountData = dataList[i] const accountData = await client.get(key)
if (accountData) { if (accountData) {
const account = JSON.parse(accountData) const account = JSON.parse(accountData)
@@ -244,11 +155,7 @@ class BedrockAccountService {
updatedAt: account.updatedAt, updatedAt: account.updatedAt,
type: 'bedrock', type: 'bedrock',
platform: 'bedrock', platform: 'bedrock',
// 根据凭证类型判断是否有凭证 hasCredentials: !!account.awsCredentials
hasCredentials:
account.credentialType === 'bearer_token'
? !!account.bearerToken
: !!account.awsCredentials
}) })
} }
} }
@@ -328,15 +235,6 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
} }
// 更新 Bearer Token
if (updates.bearerToken !== undefined) {
if (updates.bearerToken) {
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
} else {
delete account.bearerToken
}
}
// ✅ 直接保存 subscriptionExpiresAt如果提供 // ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段 // Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) { if (updates.subscriptionExpiresAt !== undefined) {
@@ -382,7 +280,6 @@ 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}`)
@@ -448,45 +345,13 @@ class BedrockAccountService {
const account = accountResult.data const account = accountResult.data
logger.info( logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
)
// 验证凭证是否已解密 // 尝试获取模型列表来测试连接
const hasValidCredentials =
(account.credentialType === 'access_key' && account.awsCredentials) ||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
if (!hasValidCredentials) {
logger.error(
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
)
return {
success: false,
error: 'No valid credentials found after decryption'
}
}
// 尝试创建 Bedrock 客户端来验证凭证格式
try {
bedrockRelayService._getBedrockClient(account.region, account)
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
} catch (clientError) {
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
return {
success: false,
error: `Failed to create Bedrock client: ${clientError.message}`
}
}
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
const models = await bedrockRelayService.getAvailableModels(account) const models = await bedrockRelayService.getAvailableModels(account)
if (models && models.length > 0) { if (models && models.length > 0) {
logger.info( logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
)
return { return {
success: true, success: true,
data: { data: {
@@ -511,135 +376,6 @@ class BedrockAccountService {
} }
} }
/**
* 🧪 测试 Bedrock 账户连接SSE 流式返回,供前端测试页面使用)
* @param {string} accountId - 账户ID
* @param {Object} res - Express response 对象
* @param {string} model - 测试使用的模型
*/
async testAccountConnection(accountId, res, model = null) {
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
try {
// 获取账户信息
const accountResult = await this.getAccount(accountId)
if (!accountResult.success) {
throw new Error(accountResult.error || 'Account not found')
}
const account = accountResult.data
// 根据账户类型选择合适的测试模型
if (!model) {
// Access Key 模式使用 Haiku更快更便宜
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
logger.info(
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.status(200)
// 发送 test_start 事件
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
// 构造测试请求体Bedrock 格式)
const bedrockPayload = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 256,
messages: [
{
role: 'user',
content:
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
}
]
}
// 获取 Bedrock 客户端
const region = account.region || bedrockRelayService.defaultRegion
const client = bedrockRelayService._getBedrockClient(region, account)
// 创建流式调用命令
const command = new InvokeModelWithResponseStreamCommand({
modelId: model,
body: JSON.stringify(bedrockPayload),
contentType: 'application/json',
accept: 'application/json'
})
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
const startTime = Date.now()
const response = await client.send(command)
// 处理流式响应
// let responseText = ''
for await (const chunk of response.body) {
if (chunk.chunk) {
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
// 提取文本内容
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
const { text } = chunkData.delta
// responseText += text
// 发送 content 事件
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
// 检测错误
if (chunkData.type === 'error') {
throw new Error(chunkData.error?.message || 'Bedrock API error')
}
}
}
const duration = Date.now() - startTime
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
// 发送 message_stop 事件(前端兼容)
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
// 发送 test_complete 事件
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
// 结束响应
res.end()
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
} catch (error) {
logger.error(`❌ Test Bedrock account connection failed:`, error)
// 发送错误事件给前端
try {
// 检查响应流是否仍然可写
if (!res.writableEnded && !res.destroyed) {
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.status(200)
}
const errorMsg = error.message || '测试失败'
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
res.end()
}
} catch (writeError) {
logger.error('Failed to write error to response stream:', writeError)
}
// 不再重新抛出错误,避免路由层再次处理
// throw error
}
}
/** /**
* 检查账户订阅是否过期 * 检查账户订阅是否过期
* @param {Object} account - 账户对象 * @param {Object} account - 账户对象

View File

@@ -48,17 +48,13 @@ class BedrockRelayService {
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
sessionToken: bedrockAccount.awsCredentials.sessionToken sessionToken: bedrockAccount.awsCredentials.sessionToken
} }
} else if (bedrockAccount?.bearerToken) {
// Bearer Token 模式AWS SDK >= 3.400.0 会自动检测环境变量
clientConfig.token = { token: bedrockAccount.bearerToken }
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
} else { } else {
// 检查是否有环境变量凭证 // 检查是否有环境变量凭证
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
clientConfig.credentials = fromEnv() clientConfig.credentials = fromEnv()
} else { } else {
throw new Error( throw new Error(
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
) )
} }
} }
@@ -343,8 +339,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_delta' && claudeEvent.data.usage) { if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage totalUsage = claudeEvent.data.usage
} }
@@ -435,18 +431,6 @@ class BedrockRelayService {
_mapToBedrockModel(modelName) { _mapToBedrockModel(modelName) {
// 标准Claude模型名到Bedrock模型名的映射表 // 标准Claude模型名到Bedrock模型名的映射表
const modelMapping = { const modelMapping = {
// Claude 4.5 Opus
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
// Claude 4.5 Sonnet
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
// Claude 4.5 Haiku
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
// Claude Sonnet 4 // Claude Sonnet 4
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
@@ -576,28 +560,14 @@ class BedrockRelayService {
return { return {
type: 'message_start', type: 'message_start',
data: { data: {
type: 'message_start', type: 'message',
message: { id: `msg_${Date.now()}_bedrock`,
id: `msg_${Date.now()}_bedrock`, role: 'assistant',
type: 'message', content: [],
role: 'assistant', model: this.defaultModel,
content: [], stop_reason: null,
model: this.defaultModel, stop_sequence: null,
stop_reason: null, usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
stop_sequence: null,
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
}
}
}
}
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: '' }
} }
} }
} }
@@ -606,28 +576,16 @@ class BedrockRelayService {
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 || {}
} }
@@ -638,7 +596,7 @@ class BedrockRelayService {
return { return {
type: 'message_stop', type: 'message_stop',
data: { data: {
type: 'message_stop' usage: bedrockChunk.usage || {}
} }
} }
} }

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,23 +1,33 @@
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 { createEncryptor } = require('../utils/commonHelper') const config = require('../../config/config')
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 的加密器 // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
this._encryptor = createEncryptor('ccr-account-salt') // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
this._encryptor.clearCache() this._decryptCache.cleanup()
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats()) logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -96,7 +106,6 @@ 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') {
@@ -130,17 +139,12 @@ class CcrAccountService {
// 📋 获取所有CCR账户 // 📋 获取所有CCR账户
async getAllAccounts() { async getAllAccounts() {
try { try {
const accountIds = await redis.getAllIdsByIndex( const client = redis.getClientSafe()
'ccr_account:index', const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
`${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 (let i = 0; i < keys.length; i++) { for (const key of keys) {
const accountData = dataList[i] const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
// 获取限流状态信息 // 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData) const rateLimitInfo = this._getRateLimitInfo(accountData)
@@ -327,9 +331,6 @@ 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}`)
@@ -402,7 +403,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, {
@@ -487,7 +488,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)
@@ -605,12 +606,70 @@ class CcrAccountService {
// 🔐 加密敏感数据 // 🔐 加密敏感数据
_encryptSensitiveData(data) { _encryptSensitiveData(data) {
return this._encryptor.encrypt(data) if (!data) {
return ''
}
try {
const key = this._generateEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(data, 'utf8', 'hex')
encrypted += cipher.final('hex')
return `${iv.toString('hex')}:${encrypted}`
} catch (error) {
logger.error('❌ CCR encryption error:', error)
return data
}
} }
// 🔓 解密敏感数据 // 🔓 解密敏感数据
_decryptSensitiveData(encryptedData) { _decryptSensitiveData(encryptedData) {
return this._encryptor.decrypt(encryptedData) if (!encryptedData) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const parts = encryptedData.split(':')
if (parts.length === 2) {
const key = this._generateEncryptionKey()
const iv = Buffer.from(parts[0], 'hex')
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} else {
logger.error('❌ Invalid CCR encrypted data format')
return encryptedData
}
} catch (error) {
logger.error('❌ CCR decryption error:', error)
return encryptedData
}
}
// 🔑 生成加密密钥
_generateEncryptionKey() {
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
}
return this._encryptionKeyCache
} }
// 🔍 获取限流状态信息 // 🔍 获取限流状态信息
@@ -784,7 +843,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)
@@ -856,7 +915,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,11 +5,6 @@
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() {
@@ -46,9 +41,6 @@ class ClaudeCodeHeadersService {
'sec-fetch-mode', 'sec-fetch-mode',
'accept-encoding' 'accept-encoding'
] ]
// Headers 缓存 TTL60秒
this.headersCacheTtl = 60000
} }
/** /**
@@ -155,9 +147,6 @@ 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)
@@ -165,27 +154,18 @@ 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 data = await redis.getClient().get(cacheKey) const key = `claude_code_headers:${accountId}`
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
} }
@@ -203,10 +183,8 @@ class ClaudeCodeHeadersService {
*/ */
async clearAccountHeaders(accountId) { async clearAccountHeaders(accountId) {
try { try {
const cacheKey = `claude_code_headers:${accountId}` const key = `claude_code_headers:${accountId}`
await redis.getClient().del(cacheKey) await redis.getClient().del(key)
// 删除内存缓存
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)
@@ -214,12 +192,12 @@ class ClaudeCodeHeadersService {
} }
/** /**
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys * 获取所有账号的 headers 信息
*/ */
async getAllAccountHeaders() { async getAllAccountHeaders() {
try { try {
const pattern = 'claude_code_headers:*' const pattern = 'claude_code_headers:*'
const keys = await redis.scanKeys(pattern) const keys = await redis.getClient().keys(pattern)
const results = {} const results = {}
for (const key of keys) { for (const key of keys) {

View File

@@ -129,7 +129,6 @@ 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') {
@@ -168,18 +167,11 @@ class ClaudeConsoleAccountService {
async getAllAccounts() { async getAllAccounts() {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const accountIds = await redis.getAllIdsByIndex( const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
'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 (let i = 0; i < keys.length; i++) { for (const key of keys) {
const key = keys[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) {
if (!accountData.id) { if (!accountData.id) {
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`) logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`)
@@ -457,7 +449,6 @@ 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') {
@@ -586,7 +577,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')) {
@@ -594,7 +585,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 }
@@ -867,7 +858,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')) {
@@ -875,7 +866,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 }
@@ -976,7 +967,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(
@@ -1425,7 +1416,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)
} }
@@ -1498,7 +1489,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: 60000, // 队列等待超时(毫秒) userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
userMessageQueueLockTtlMs: 120000, // 锁TTL毫秒 userMessageQueueLockTtlMs: 5000, // 锁TTL毫秒请求发送后立即释放无需长TTL
// 并发请求排队配置 // 并发请求排队配置
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭) concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3 concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3

View File

@@ -1,5 +1,6 @@
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')
@@ -16,64 +17,55 @@ 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() {
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true' this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
this.bodyStore = new Map()
this._bodyStoreIdCounter = 0
this.apiVersion = config.claude.apiVersion this.apiVersion = config.claude.apiVersion
this.betaHeader = config.claude.betaHeader this.betaHeader = config.claude.betaHeader
this.systemPrompt = config.claude.systemPrompt this.systemPrompt = config.claude.systemPrompt
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
this.toolNameSuffix = null
this.toolNameSuffixGeneratedAt = 0
this.toolNameSuffixTtlMs = 60 * 60 * 1000
} }
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header // 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
// 规则:
// 1. 如果客户端传递了 anthropic-beta检查是否包含 oauth-2025-04-20
// 2. 如果没有 oauth-2025-04-20则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
// 3. 如果客户端没传递则根据模型判断haiku 不需要 claude-code其他模型需要
_getBetaHeader(modelId, clientBetaHeader) { _getBetaHeader(modelId, clientBetaHeader) {
const OAUTH_BETA = 'oauth-2025-04-20' const OAUTH_BETA = 'oauth-2025-04-20'
const CLAUDE_CODE_BETA = 'claude-code-20250219' const CLAUDE_CODE_BETA = 'claude-code-20250219'
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
const baseBetas = isHaikuModel
? [OAUTH_BETA, INTERLEAVED_THINKING_BETA]
: [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA]
const betaList = []
const seen = new Set()
const addBeta = (beta) => {
if (!beta || seen.has(beta)) {
return
}
seen.add(beta)
betaList.push(beta)
}
baseBetas.forEach(addBeta)
// 如果客户端传递了 anthropic-beta
if (clientBetaHeader) { if (clientBetaHeader) {
clientBetaHeader // 检查是否已包含 oauth-2025-04-20
.split(',') if (clientBetaHeader.includes(OAUTH_BETA)) {
.map((p) => p.trim()) return clientBetaHeader
.filter(Boolean) }
.forEach(addBeta)
// 需要添加 oauth-2025-04-20
const parts = clientBetaHeader.split(',').map((p) => p.trim())
// 找到 claude-code-20250219 的位置
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
if (claudeCodeIndex !== -1) {
// 在 claude-code-20250219 后面插入
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
} else {
// 放在第一位
parts.unshift(OAUTH_BETA)
}
return parts.join(',')
} }
return betaList.join(',') // 客户端没有传递,根据模型判断
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
if (isHaikuModel) {
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14'
}
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
} }
_buildStandardRateLimitMessage(resetTime) { _buildStandardRateLimitMessage(resetTime) {
@@ -148,235 +140,6 @@ class ClaudeRelayService {
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
} }
_isClaudeCodeUserAgent(clientHeaders) {
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent)
}
_isActualClaudeCodeRequest(requestBody, clientHeaders) {
return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders)
}
_getHeaderValueCaseInsensitive(headers, key) {
if (!headers || typeof headers !== 'object') {
return undefined
}
const lowerKey = key.toLowerCase()
for (const candidate of Object.keys(headers)) {
if (candidate.toLowerCase() === lowerKey) {
return headers[candidate]
}
}
return undefined
}
_isClaudeCodeCredentialError(body) {
const message = this._extractErrorMessage(body)
if (!message) {
return false
}
const lower = message.toLowerCase()
return (
lower.includes('only authorized for use with claude code') ||
lower.includes('cannot be used for other api requests')
)
}
_toPascalCaseToolName(name) {
const parts = name.split(/[_-]/).filter(Boolean)
if (parts.length === 0) {
return name
}
const pascal = parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('')
return `${pascal}_tool`
}
_getToolNameSuffix() {
const now = Date.now()
if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) {
this.toolNameSuffix = Math.random().toString(36).substring(2, 8)
this.toolNameSuffixGeneratedAt = now
}
return this.toolNameSuffix
}
_toRandomizedToolName(name) {
const suffix = this._getToolNameSuffix()
return `${name}_${suffix}`
}
_transformToolNamesInRequestBody(body, options = {}) {
if (!body || typeof body !== 'object') {
return null
}
const useRandomized = options.useRandomizedToolNames === true
const forwardMap = new Map()
const reverseMap = new Map()
const transformName = (name) => {
if (typeof name !== 'string' || name.length === 0) {
return name
}
if (forwardMap.has(name)) {
return forwardMap.get(name)
}
const transformed = useRandomized
? this._toRandomizedToolName(name)
: this._toPascalCaseToolName(name)
if (transformed !== name) {
forwardMap.set(name, transformed)
reverseMap.set(transformed, name)
}
return transformed
}
if (Array.isArray(body.tools)) {
body.tools.forEach((tool) => {
if (tool && typeof tool.name === 'string') {
tool.name = transformName(tool.name)
}
})
}
if (body.tool_choice && typeof body.tool_choice === 'object') {
if (typeof body.tool_choice.name === 'string') {
body.tool_choice.name = transformName(body.tool_choice.name)
}
}
if (Array.isArray(body.messages)) {
body.messages.forEach((message) => {
const content = message?.content
if (Array.isArray(content)) {
content.forEach((block) => {
if (block?.type === 'tool_use' && typeof block.name === 'string') {
block.name = transformName(block.name)
}
})
}
})
}
return reverseMap.size > 0 ? reverseMap : null
}
_restoreToolName(name, toolNameMap) {
if (!toolNameMap || toolNameMap.size === 0) {
return name
}
return toolNameMap.get(name) || name
}
_restoreToolNamesInContentBlocks(content, toolNameMap) {
if (!Array.isArray(content)) {
return
}
content.forEach((block) => {
if (block?.type === 'tool_use' && typeof block.name === 'string') {
block.name = this._restoreToolName(block.name, toolNameMap)
}
})
}
_restoreToolNamesInResponseObject(responseBody, toolNameMap) {
if (!responseBody || typeof responseBody !== 'object') {
return
}
if (Array.isArray(responseBody.content)) {
this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap)
}
if (responseBody.message && Array.isArray(responseBody.message.content)) {
this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap)
}
}
_restoreToolNamesInResponseBody(responseBody, toolNameMap) {
if (!responseBody || !toolNameMap || toolNameMap.size === 0) {
return responseBody
}
if (typeof responseBody === 'string') {
try {
const parsed = JSON.parse(responseBody)
this._restoreToolNamesInResponseObject(parsed, toolNameMap)
return JSON.stringify(parsed)
} catch (error) {
return responseBody
}
}
if (typeof responseBody === 'object') {
this._restoreToolNamesInResponseObject(responseBody, toolNameMap)
}
return responseBody
}
_restoreToolNamesInStreamEvent(event, toolNameMap) {
if (!event || typeof event !== 'object') {
return
}
if (event.content_block && event.content_block.type === 'tool_use') {
if (typeof event.content_block.name === 'string') {
event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap)
}
}
if (event.delta && event.delta.type === 'tool_use') {
if (typeof event.delta.name === 'string') {
event.delta.name = this._restoreToolName(event.delta.name, toolNameMap)
}
}
if (event.message && Array.isArray(event.message.content)) {
this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap)
}
if (Array.isArray(event.content)) {
this._restoreToolNamesInContentBlocks(event.content, toolNameMap)
}
}
_createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) {
if (!toolNameMap || toolNameMap.size === 0) {
return streamTransformer
}
return (payload) => {
const transformed = streamTransformer ? streamTransformer(payload) : payload
if (!transformed || typeof transformed !== 'string') {
return transformed
}
const lines = transformed.split('\n')
const updated = lines.map((line) => {
if (!line.startsWith('data:')) {
return line
}
const jsonStr = line.slice(5).trimStart()
if (!jsonStr || jsonStr === '[DONE]') {
return line
}
try {
const data = JSON.parse(jsonStr)
this._restoreToolNamesInStreamEvent(data, toolNameMap)
return `data: ${JSON.stringify(data)}`
} catch (error) {
return line
}
})
return updated.join('\n')
}
}
// 🚀 转发请求到Claude API // 🚀 转发请求到Claude API
async relayRequest( async relayRequest(
requestBody, requestBody,
@@ -390,7 +153,6 @@ class ClaudeRelayService {
let queueLockAcquired = false let queueLockAcquired = false
let queueRequestId = null let queueRequestId = null
let selectedAccountId = null let selectedAccountId = null
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
try { try {
// 调试日志查看API Key数据 // 调试日志查看API Key数据
@@ -549,12 +311,7 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, account) const processedBody = this._processRequestBody(requestBody, account)
// 🧹 内存优化:存储到 bodyStore避免闭包捕获
const originalBodyString = JSON.stringify(processedBody)
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -575,59 +332,36 @@ class ClaudeRelayService {
clientResponse.once('close', handleClientDisconnect) clientResponse.once('close', handleClientDisconnect)
} }
const makeRequestWithRetries = async (requestOptions) => { // 发送请求到Claude API传入回调以获取请求对象
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 // 🔄 403 重试机制:仅对 claude-official 类型账户OAuth 或 Setup Token
let retryCount = 0 const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
let response let retryCount = 0
let shouldRetry = false let response
let shouldRetry = false
do { do {
// 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获 response = await this._makeClaudeRequest(
let retryRequestBody processedBody,
try { accessToken,
retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream)) proxyAgent,
} catch (parseError) { clientHeaders,
logger.error(`❌ Failed to parse body for retry: ${parseError.message}`) accountId,
throw new Error(`Request body parse failed: ${parseError.message}`) (req) => {
} upstreamRequest = req
response = await this._makeClaudeRequest( },
retryRequestBody, options
accessToken, )
proxyAgent,
clientHeaders, // 检查是否需要重试 403
accountId, shouldRetry = response.statusCode === 403 && retryCount < maxRetries
(req) => { if (shouldRetry) {
upstreamRequest = req retryCount++
}, logger.warn(
{ `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
...requestOptions,
isRealClaudeCodeRequest
}
) )
await this._sleep(2000)
shouldRetry = response.statusCode === 403 && retryCount < maxRetries }
if (shouldRetry) { } while (shouldRetry)
retryCount++
logger.warn(
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
)
await this._sleep(2000)
}
} while (shouldRetry)
return { response, retryCount }
}
let requestOptions = options
let { response, retryCount } = await makeRequestWithRetries(requestOptions)
if (
this._isClaudeCodeCredentialError(response.body) &&
requestOptions.useRandomizedToolNames !== true
) {
requestOptions = { ...requestOptions, useRandomizedToolNames: true }
;({ response, retryCount } = await makeRequestWithRetries(requestOptions))
}
// 如果进行了重试,记录最终结果 // 如果进行了重试,记录最终结果
if (retryCount > 0) { if (retryCount > 0) {
@@ -927,10 +661,6 @@ class ClaudeRelayService {
) )
throw error throw error
} finally { } finally {
// 🧹 清理 bodyStore
if (bodyStoreIdNonStream !== null) {
this.bodyStore.delete(bodyStoreIdNonStream)
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && selectedAccountId) { if (queueLockAcquired && queueRequestId && selectedAccountId) {
try { try {
@@ -954,8 +684,8 @@ class ClaudeRelayService {
return body return body
} }
// 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能 // 深拷贝请求体
const processedBody = safeClone(body) const processedBody = JSON.parse(JSON.stringify(body))
// 验证并限制max_tokens参数 // 验证并限制max_tokens参数
this._validateAndLimitMaxTokens(processedBody) this._validateAndLimitMaxTokens(processedBody)
@@ -1085,15 +815,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 (!pricingData) { if (!fs.existsSync(pricingFilePath)) {
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'
// 查找对应模型的配置 // 查找对应模型的配置
@@ -1259,20 +989,20 @@ class ClaudeRelayService {
} }
// 🌐 获取代理Agent使用统一的代理工具 // 🌐 获取代理Agent使用统一的代理工具
async _getProxyAgent(accountId, account = null) { async _getProxyAgent(accountId) {
try { try {
// 优先使用传入的 account 对象,避免重复查询 const accountData = await claudeAccountService.getAllAccounts()
const accountData = account || (await claudeAccountService.getAccount(accountId)) const account = accountData.find((acc) => acc.id === accountId)
if (!accountData || !accountData.proxy) { if (!account || !account.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(accountData.proxy) const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
if (proxyAgent) { if (proxyAgent) {
logger.info( logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}` `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
) )
} }
return proxyAgent return proxyAgent
@@ -1305,19 +1035,23 @@ class ClaudeRelayService {
// 获取过滤后的客户端 headers // 获取过滤后的客户端 headers
const filteredHeaders = this._filterClientHeaders(clientHeaders) const filteredHeaders = this._filterClientHeaders(clientHeaders)
const isRealClaudeCode = // 判断是否是真实的 Claude Code 请求
requestOptions.isRealClaudeCodeRequest === undefined const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
? this.isRealClaudeCodeRequest(body)
: requestOptions.isRealClaudeCodeRequest === true
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
let finalHeaders = { ...filteredHeaders } let finalHeaders = { ...filteredHeaders }
let requestPayload = body let requestPayload = body
if (!isRealClaudeCode) { if (!isRealClaudeCode) {
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
// 只添加客户端没有提供的 headers
Object.keys(claudeCodeHeaders).forEach((key) => { Object.keys(claudeCodeHeaders).forEach((key) => {
finalHeaders[key] = claudeCodeHeaders[key] const lowerKey = key.toLowerCase()
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
finalHeaders[key] = claudeCodeHeaders[key]
}
}) })
} }
@@ -1339,13 +1073,6 @@ class ClaudeRelayService {
requestPayload = extensionResult.body requestPayload = extensionResult.body
finalHeaders = extensionResult.headers finalHeaders = extensionResult.headers
let toolNameMap = null
if (!isRealClaudeCode) {
toolNameMap = this._transformToolNamesInRequestBody(requestPayload, {
useRandomizedToolNames: requestOptions.useRandomizedToolNames === true
})
}
// 序列化请求体,计算 content-length // 序列化请求体,计算 content-length
const bodyString = JSON.stringify(requestPayload) const bodyString = JSON.stringify(requestPayload)
const contentLength = Buffer.byteLength(bodyString, 'utf8') const contentLength = Buffer.byteLength(bodyString, 'utf8')
@@ -1369,18 +1096,19 @@ class ClaudeRelayService {
headers['User-Agent'] = userAgent headers['User-Agent'] = userAgent
headers['Accept'] = acceptHeader headers['Accept'] = acceptHeader
logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`) logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
// 根据模型和客户端传递的 anthropic-beta 动态设置 header // 根据模型和客户端传递的 anthropic-beta 动态设置 header
const modelId = requestPayload?.model || body?.model const modelId = requestPayload?.model || body?.model
const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta') const clientBetaHeader = clientHeaders?.['anthropic-beta']
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader) headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
return { return {
requestPayload, requestPayload,
bodyString, bodyString,
headers, headers,
isRealClaudeCode, isRealClaudeCode
toolNameMap
} }
} }
@@ -1446,8 +1174,7 @@ class ClaudeRelayService {
return prepared.abortResponse return prepared.abortResponse
} }
let { bodyString } = prepared const { bodyString, headers } = prepared
const { headers, isRealClaudeCode, toolNameMap } = prepared
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens // 支持自定义路径(如 count_tokens
@@ -1464,22 +1191,19 @@ class ClaudeRelayService {
path: requestPath + (url.search || ''), path: requestPath + (url.search || ''),
method: 'POST', method: 'POST',
headers, headers,
agent: proxyAgent || getHttpsAgentForNonStream(), agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
// 使用数组收集 chunks避免 O(n²) 的 Buffer.concat let responseData = Buffer.alloc(0)
const chunks = []
res.on('data', (chunk) => { res.on('data', (chunk) => {
chunks.push(chunk) responseData = Buffer.concat([responseData, chunk])
}) })
res.on('end', () => { res.on('end', () => {
try { try {
// 一次性合并所有 chunks
const responseData = Buffer.concat(chunks)
let responseBody = '' let responseBody = ''
// 根据Content-Encoding处理响应数据 // 根据Content-Encoding处理响应数据
@@ -1502,10 +1226,6 @@ class ClaudeRelayService {
responseBody = responseData.toString('utf8') responseBody = responseData.toString('utf8')
} }
if (!isRealClaudeCode) {
responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap)
}
const response = { const response = {
statusCode: res.statusCode, statusCode: res.statusCode,
headers: res.headers, headers: res.headers,
@@ -1564,8 +1284,6 @@ class ClaudeRelayService {
// 写入请求体 // 写入请求体
req.write(bodyString) req.write(bodyString)
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
bodyString = null
req.end() req.end()
}) })
} }
@@ -1747,12 +1465,7 @@ class ClaudeRelayService {
// 获取有效的访问token // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
const processedBody = this._processRequestBody(requestBody, account) const processedBody = this._processRequestBody(requestBody, account)
// 🧹 内存优化:存储到 bodyStore不放入 requestOptions 避免闭包捕获
const originalBodyString = JSON.stringify(processedBody)
const bodyStoreId = ++this._bodyStoreIdCounter
this.bodyStore.set(bodyStoreId, originalBodyString)
// 获取代理配置 // 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId) const proxyAgent = await this._getProxyAgent(accountId)
@@ -1774,11 +1487,7 @@ class ClaudeRelayService {
accountType, accountType,
sessionHash, sessionHash,
streamTransformer, streamTransformer,
{ options,
...options,
bodyStoreId,
isRealClaudeCodeRequest
},
isDedicatedOfficialAccount, isDedicatedOfficialAccount,
// 📬 新增回调:在收到响应头时释放队列锁 // 📬 新增回调:在收到响应头时释放队列锁
async () => { async () => {
@@ -1867,12 +1576,7 @@ class ClaudeRelayService {
return prepared.abortResponse return prepared.abortResponse
} }
let { bodyString } = prepared const { bodyString, headers } = prepared
const { headers, toolNameMap } = prepared
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
streamTransformer,
toolNameMap
)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl) const url = new URL(this.claudeApiUrl)
@@ -1882,7 +1586,7 @@ class ClaudeRelayService {
path: url.pathname + (url.search || ''), path: url.pathname + (url.search || ''),
method: 'POST', method: 'POST',
headers, headers,
agent: proxyAgent || getHttpsAgentForStream(), agent: proxyAgent,
timeout: config.requestTimeout || 600000 timeout: config.requestTimeout || 600000
} }
@@ -1980,22 +1684,8 @@ class ClaudeRelayService {
try { try {
// 递归调用自身进行重试 // 递归调用自身进行重试
// 🧹 从 bodyStore 获取字符串用于重试
if (
!requestOptions.bodyStoreId ||
!this.bodyStore.has(requestOptions.bodyStoreId)
) {
throw new Error('529 retry requires valid bodyStoreId')
}
let retryBody
try {
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
} catch (parseError) {
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
throw new Error(`529 retry body parse failed: ${parseError.message}`)
}
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
retryBody, body,
accessToken, accessToken,
proxyAgent, proxyAgent,
clientHeaders, clientHeaders,
@@ -2090,48 +1780,11 @@ class ClaudeRelayService {
errorData += chunk.toString() errorData += chunk.toString()
}) })
res.on('end', async () => { res.on('end', () => {
logger.error( logger.error(
`❌ Claude API error response (Account: ${account?.name || accountId}):`, `❌ Claude API error response (Account: ${account?.name || accountId}):`,
errorData errorData
) )
if (
this._isClaudeCodeCredentialError(errorData) &&
requestOptions.useRandomizedToolNames !== true &&
requestOptions.bodyStoreId &&
this.bodyStore.has(requestOptions.bodyStoreId)
) {
let retryBody
try {
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
} catch (parseError) {
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
return
}
try {
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
retryBody,
accessToken,
proxyAgent,
clientHeaders,
responseStream,
usageCallback,
accountId,
accountType,
sessionHash,
streamTransformer,
{ ...requestOptions, useRandomizedToolNames: true },
isDedicatedOfficialAccount,
onResponseStart,
retryCount
)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
return
}
if (this._isOrganizationDisabledError(res.statusCode, errorData)) { if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
;(async () => { ;(async () => {
try { try {
@@ -2166,7 +1819,7 @@ class ClaudeRelayService {
} }
// 如果有 streamTransformer如测试请求使用前端期望的格式 // 如果有 streamTransformer如测试请求使用前端期望的格式
if (toolNameStreamTransformer) { if (streamTransformer) {
responseStream.write( responseStream.write(
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
) )
@@ -2205,11 +1858,6 @@ class ClaudeRelayService {
let rateLimitDetected = false // 限流检测标志 let rateLimitDetected = false // 限流检测标志
// 监听数据块解析SSE并寻找usage信息 // 监听数据块解析SSE并寻找usage信息
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
const requestedModel = body?.model || 'unknown'
const { isRealClaudeCodeRequest } = requestOptions
res.on('data', (chunk) => { res.on('data', (chunk) => {
try { try {
const chunkStr = chunk.toString() const chunkStr = chunk.toString()
@@ -2225,8 +1873,8 @@ class ClaudeRelayService {
if (isStreamWritable(responseStream)) { if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 如果有流转换器,应用转换 // 如果有流转换器,应用转换
if (toolNameStreamTransformer) { if (streamTransformer) {
const transformed = toolNameStreamTransformer(linesToForward) const transformed = streamTransformer(linesToForward)
if (transformed) { if (transformed) {
responseStream.write(transformed) responseStream.write(transformed)
} }
@@ -2359,8 +2007,8 @@ class ClaudeRelayService {
try { try {
// 处理缓冲区中剩余的数据 // 处理缓冲区中剩余的数据
if (buffer.trim() && isStreamWritable(responseStream)) { if (buffer.trim() && isStreamWritable(responseStream)) {
if (toolNameStreamTransformer) { if (streamTransformer) {
const transformed = toolNameStreamTransformer(buffer) const transformed = streamTransformer(buffer)
if (transformed) { if (transformed) {
responseStream.write(transformed) responseStream.write(transformed)
} }
@@ -2415,7 +2063,7 @@ class ClaudeRelayService {
// 打印原始的usage数据为JSON字符串避免嵌套问题 // 打印原始的usage数据为JSON字符串避免嵌套问题
logger.info( logger.info(
`📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` `📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
) )
// 一般一个请求只会使用一个模型即使有多个usage事件也应该合并 // 一般一个请求只会使用一个模型即使有多个usage事件也应该合并
@@ -2425,7 +2073,7 @@ class ClaudeRelayService {
output_tokens: totalUsage.output_tokens, output_tokens: totalUsage.output_tokens,
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
cache_read_input_tokens: totalUsage.cache_read_input_tokens, cache_read_input_tokens: totalUsage.cache_read_input_tokens,
model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型 model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
} }
// 如果有详细的cache_creation数据合并它们 // 如果有详细的cache_creation数据合并它们
@@ -2534,15 +2182,15 @@ class ClaudeRelayService {
} }
// 只有真实的 Claude Code 请求才更新 headers流式请求 // 只有真实的 Claude Code 请求才更新 headers流式请求
if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) { if (
clientHeaders &&
Object.keys(clientHeaders).length > 0 &&
this.isRealClaudeCodeRequest(body)
) {
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
} }
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
logger.debug('🌊 Claude stream response with usage capture completed') logger.debug('🌊 Claude stream response with usage capture completed')
resolve() resolve()
}) })
@@ -2599,10 +2247,6 @@ class ClaudeRelayService {
) )
responseStream.end() responseStream.end()
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
reject(error) reject(error)
}) })
@@ -2632,10 +2276,6 @@ class ClaudeRelayService {
) )
responseStream.end() responseStream.end()
} }
// 🧹 清理 bodyStore
if (requestOptions.bodyStoreId) {
this.bodyStore.delete(requestOptions.bodyStoreId)
}
reject(new Error('Request timeout')) reject(new Error('Request timeout'))
}) })
@@ -2649,8 +2289,6 @@ class ClaudeRelayService {
// 写入请求体 // 写入请求体
req.write(bodyString) req.write(bodyString)
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
bodyString = null
req.end() req.end()
}) })
} }

View File

@@ -1,65 +1,9 @@
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的费用数据
* 扫描历史使用记录并计算费用 * 扫描历史使用记录并计算费用
@@ -68,57 +12,25 @@ class CostInitService {
try { try {
logger.info('💰 Starting cost initialization for all API Keys...') logger.info('💰 Starting cost initialization for all API Keys...')
// 用 scanApiKeyIds 获取 ID然后过滤已删除的 const apiKeys = await apiKeyService.getAllApiKeys()
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
// 优化6: 并行处理 + 并发限制 for (const apiKey of apiKeys) {
await this.parallelLimit( try {
apiKeyIds, await this.initializeApiKeyCosts(apiKey.id, client)
async (apiKeyId) => { processedCount++
try {
await this.initializeApiKeyCosts(apiKeyId, client)
processedCount++
if (processedCount % 100 === 0) { if (processedCount % 10 === 0) {
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`) logger.info(`💰 Processed ${processedCount} API Keys...`)
}
} catch (error) {
errorCount++
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
} }
}, } catch (error) {
20 // 并发数 errorCount++
) logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
}
}
logger.success( logger.success(
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}` `💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
@@ -134,55 +46,16 @@ class CostInitService {
* 初始化单个API Key的费用数据 * 初始化单个API Key的费用数据
*/ */
async initializeApiKeyCosts(apiKeyId, client) { async initializeApiKeyCosts(apiKeyId, client) {
// 优化4: 使用 SCAN 获取 keys带去重 // 获取所有时间的模型使用统计
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`) const modelKeys = await client.keys(`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() const dailyCosts = new Map() // date -> cost
const monthlyCosts = new Map() const monthlyCosts = new Map() // month -> cost
const hourlyCosts = new Map() const hourlyCosts = new Map() // hour -> cost
for (const { key, data } of allData) { for (const key of modelKeys) {
// 解析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})?)$/
) )
@@ -192,6 +65,13 @@ 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,
@@ -204,34 +84,47 @@ 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') {
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost) const currentCost = dailyCosts.get(dateStr) || 0
dailyCosts.set(dateStr, currentCost + cost)
} else if (period === 'monthly') { } else if (period === 'monthly') {
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost) const currentCost = monthlyCosts.get(dateStr) || 0
monthlyCosts.set(dateStr, currentCost + cost)
} else if (period === 'hourly') { } else if (period === 'hourly') {
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost) const currentCost = hourlyCosts.get(dateStr) || 0
hourlyCosts.set(dateStr, currentCost + cost)
} }
} }
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的 // 将计算出的费用写入Redis
const pipeline = client.pipeline() const promises = []
// 写入每日费用(只补缺失) // 写入每日费用
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}`
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX') promises.push(
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}`
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX') promises.push(
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}`
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX') promises.push(
client.set(key, cost.toString()),
client.expire(key, 86400 * 7) // 7天过期
)
} }
// 计算总费用 // 计算总费用
@@ -240,25 +133,37 @@ 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) {
pipeline.set(totalKey, totalCost.toString()) // 仅在总费用不存在或为0时才初始化
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)
if (totalCost > existing * 1.1) { const calculated = totalCost
if (calculated > existing * 1.1) {
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
logger.warn( logger.warn(
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.` `💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
)
} else {
logger.debug(
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
) )
} }
} }
} }
await pipeline.exec() await Promise.all(promises)
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)}`
@@ -267,70 +172,41 @@ class CostInitService {
/** /**
* 检查是否需要初始化费用数据 * 检查是否需要初始化费用数据
* 使用 SCAN 代替 KEYS正确处理 cursor
*/ */
async needsInitialization() { async needsInitialization() {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
// 正确循环 SCAN 检查是否有任何费用数据 // 检查是否有任何费用数据
let cursor = '0' const costKeys = await client.keys('usage:cost:*')
let hasCostData = false
do { // 如果没有费用数据,需要初始化
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100) if (costKeys.length === 0) {
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
} }
// 抽样检查使用数据是否有对应的费用数据 // 检查是否有使用数据但没有对应的费用数据
cursor = '0' const sampleKeys = await client.keys('usage:*:model:daily:*:*')
let samplesChecked = 0 if (sampleKeys.length > 10) {
const maxSamples = 10 // 抽样检查
const sampleSize = Math.min(10, sampleKeys.length)
do { for (let i = 0; i < sampleSize; i++) {
const [newCursor, usageKeys] = await client.scan( const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
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,32 +391,17 @@ class CostRankService {
return {} return {}
} }
// 使用 Pipeline 批量获取
const pipeline = client.pipeline()
for (const timeRange of VALID_TIME_RANGES) {
pipeline.hgetall(RedisKeys.metaKey(timeRange))
}
const results = await pipeline.exec()
const status = {} const status = {}
VALID_TIME_RANGES.forEach((timeRange, i) => {
const [err, meta] = results[i] for (const timeRange of VALID_TIME_RANGES) {
if (err || !meta) { const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
status[timeRange] = { status[timeRange] = {
lastUpdate: null, lastUpdate: meta.lastUpdate || null,
keyCount: 0, keyCount: parseInt(meta.keyCount || 0),
status: 'unknown', status: meta.status || 'unknown',
updateDuration: 0 updateDuration: parseInt(meta.updateDuration || 0)
}
} else {
status[timeRange] = {
lastUpdate: meta.lastUpdate || null,
keyCount: parseInt(meta.keyCount || 0),
status: meta.status || 'unknown',
updateDuration: parseInt(meta.updateDuration || 0)
}
} }
}) }
return status return status
} }

View File

@@ -2,10 +2,11 @@ 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 { createEncryptor, isTruthy } = require('../utils/commonHelper') const LRUCache = require('../utils/lruCache')
/** /**
* Droid 账户管理服务 * Droid 账户管理服务
@@ -25,14 +26,21 @@ class DroidAccountService {
this.refreshIntervalHours = 6 // 每6小时刷新一次 this.refreshIntervalHours = 6 // 每6小时刷新一次
this.tokenValidHours = 8 // Token 有效期8小时 this.tokenValidHours = 8 // Token 有效期8小时
// 使用 commonHelper 的加密器 // 加密相关常量
this._encryptor = createEncryptor('droid-account-salt') this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'droid-account-salt'
// 🚀 性能优化:缓存派生的加密密钥
this._encryptionKeyCache = null
// 🔄 解密结果缓存
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟 // 🧹 定期清理缓存每10分钟
setInterval( setInterval(
() => { () => {
this._encryptor.clearCache() this._decryptCache.cleanup()
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats()) logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -61,19 +69,92 @@ class DroidAccountService {
return 'anthropic' return 'anthropic'
} }
// 使用 commonHelper 的 isTruthy
_isTruthy(value) { _isTruthy(value) {
return isTruthy(value) if (value === undefined || value === null) {
return false
}
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true') {
return true
}
if (normalized === 'false') {
return false
}
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
}
return Boolean(value)
} }
// 加密敏感数据 /**
* 生成加密密钥(缓存优化)
*/
_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) {
return this._encryptor.encrypt(text) if (!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) {
return this._encryptor.decrypt(encryptedText) if (!encryptedText) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = this._generateEncryptionKey()
const parts = encryptedText.split(':')
const iv = Buffer.from(parts[0], 'hex')
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} catch (error) {
logger.error('❌ Failed to decrypt Droid data:', error)
return ''
}
} }
_parseApiKeyEntries(rawEntries) { _parseApiKeyEntries(rawEntries) {
@@ -602,7 +683,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}`)
@@ -1287,7 +1368,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 = '', keyId = null) { async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
if (!rateLimitInfo) { if (!rateLimitInfo) {
return return
} }
@@ -99,9 +99,7 @@ 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) {
@@ -405,7 +403,6 @@ 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 = {
@@ -609,11 +606,10 @@ 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`
@@ -1199,7 +1195,6 @@ 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 || {}
@@ -1230,8 +1225,7 @@ class DroidRelayService {
clientRequest?.rateLimitInfo, clientRequest?.rateLimitInfo,
usageSummary, usageSummary,
model, model,
endpointLabel, endpointLabel
keyId
) )
logger.success( logger.success(

View File

@@ -2,40 +2,103 @@ 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 isTruthy(account?.schedulable ?? true) return this._isTruthy(account?.schedulable ?? true)
} }
_matchesEndpoint(account, endpointType) { _matchesEndpoint(account, endpointType) {
const normalizedEndpoint = normalizeEndpointType(endpointType) const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const accountEndpoint = normalizeEndpointType(account?.endpointType) const accountEndpoint = this._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 = normalizeEndpointType(endpointType) const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const apiKeyPart = apiKeyId || 'default' const apiKeyPart = apiKeyId || 'default'
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
} }
@@ -58,7 +121,7 @@ class DroidScheduler {
) )
return accounts.filter( return accounts.filter(
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account) (account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
) )
} }
@@ -82,7 +145,7 @@ class DroidScheduler {
} }
async selectAccount(apiKeyData, endpointType, sessionHash) { async selectAccount(apiKeyData, endpointType, sessionHash) {
const normalizedEndpoint = normalizeEndpointType(endpointType) const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id) const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
let candidates = [] let candidates = []
@@ -112,7 +175,7 @@ class DroidScheduler {
const filtered = candidates.filter( const filtered = candidates.filter(
(account) => (account) =>
account && account &&
isAccountHealthy(account) && this._isAccountActive(account) &&
this._isAccountSchedulable(account) && this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint) this._matchesEndpoint(account, normalizedEndpoint)
) )
@@ -140,7 +203,7 @@ class DroidScheduler {
} }
} }
const sorted = sortAccountsByPriority(filtered) const sorted = this._sortCandidates(filtered)
const selected = sorted[0] const selected = sorted[0]
if (!selected) { if (!selected) {

View File

@@ -1,6 +1,8 @@
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')
@@ -13,14 +15,9 @@ const {
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('./tokenRefreshService')
const { createEncryptor } = require('../utils/commonHelper') const LRUCache = require('../utils/lruCache')
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'
@@ -88,10 +85,6 @@ 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,
@@ -203,11 +196,91 @@ 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(
() => { () => {
encryptor.clearCache() decryptCache.cleanup()
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats()) logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -532,7 +605,6 @@ 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') {
@@ -734,20 +806,19 @@ 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 sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`) const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
if (sessionHashes.length > 0) { for (const key of sessionMappings) {
const pipeline = client.pipeline() const mappedAccountId = await client.get(key)
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`)) if (mappedAccountId === accountId) {
pipeline.del(`gemini_account_sessions:${accountId}`) await client.del(key)
await pipeline.exec() }
} }
logger.info(`Deleted Gemini account: ${accountId}`) logger.info(`Deleted Gemini account: ${accountId}`)
@@ -756,18 +827,12 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const _client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex( const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
'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 (let i = 0; i < keys.length; i++) { for (const key of keys) {
const accountData = dataList[i] const accountData = await client.hgetall(key)
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)
@@ -870,8 +935,6 @@ 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
@@ -931,8 +994,6 @@ 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
@@ -1889,7 +1950,8 @@ module.exports = {
setupUser, setupUser,
encrypt, encrypt,
decrypt, decrypt,
encryptor, // 暴露加密器以便测试和监控 generateEncryptionKey,
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,9 +172,6 @@ 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)
@@ -226,17 +223,11 @@ class GeminiApiAccountService {
} }
// 直接从 Redis 获取所有账户(包括非共享账户) // 直接从 Redis 获取所有账户(包括非共享账户)
const allAccountIds = await redis.getAllIdsByIndex( const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
'gemini_api_account:index', for (const key of keys) {
`${this.ACCOUNT_KEY_PREFIX}*`, const accountId = key.replace(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 = dataList[i] const accountData = await client.hgetall(key)
if (accountData && accountData.id) { if (accountData && accountData.id) {
// 过滤非活跃账户 // 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') { if (includeInactive || accountData.isActive === 'true') {
@@ -585,9 +576,6 @@ 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,8 +163,7 @@ 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)
@@ -318,8 +317,7 @@ 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,5 +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 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')
@@ -12,23 +13,104 @@ 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 encryptor = createEncryptor('openai-account-salt') const ALGORITHM = 'aes-256-cbc'
const { encrypt, decrypt } = encryptor const ENCRYPTION_SALT = 'openai-account-salt'
const IV_LENGTH = 16
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
let _encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
const decryptCache = new LRUCache(500)
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
if (!_encryptionKeyCache) {
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
}
return _encryptionKeyCache
}
// 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(
() => { () => {
encryptor.clearCache() decryptCache.cleanup()
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats()) logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
}, },
10 * 60 * 1000 10 * 60 * 1000
) )
@@ -509,7 +591,6 @@ 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') {
@@ -644,20 +725,19 @@ 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 sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`) const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
if (sessionHashes.length > 0) { for (const key of sessionMappings) {
const pipeline = client.pipeline() const mappedAccountId = await client.get(key)
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`)) if (mappedAccountId === accountId) {
pipeline.del(`openai_account_sessions:${accountId}`) await client.del(key)
await pipeline.exec() }
} }
logger.info(`Deleted OpenAI account: ${accountId}`) logger.info(`Deleted OpenAI account: ${accountId}`)
@@ -666,18 +746,12 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const _client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex( const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
'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 (let i = 0; i < keys.length; i++) { for (const key of keys) {
const accountData = dataList[i] const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
const codexUsage = buildCodexUsageSnapshot(accountData) const codexUsage = buildCodexUsageSnapshot(accountData)
@@ -852,9 +926,6 @@ 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
@@ -905,8 +976,6 @@ 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
@@ -1209,5 +1278,6 @@ module.exports = {
updateCodexUsageSnapshot, updateCodexUsageSnapshot,
encrypt, encrypt,
decrypt, decrypt,
encryptor // 暴露加密器以便测试和监控 generateEncryptionKey,
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,9 +180,6 @@ 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)
@@ -194,68 +191,97 @@ 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) {
if (!includeInactive && accountData.isActive !== 'true') { const account = await this.getAccount(accountId)
return if (account) {
} // 过滤非活跃账户
if (includeInactive || account.isActive === 'true') {
// 隐藏敏感信息
account.apiKey = '***'
// 隐藏敏感信息 // 获取限流状态信息与普通OpenAI账号保持一致的格式
accountData.apiKey = '***' const rateLimitInfo = this._getRateLimitInfo(account)
// 解析 JSON 字段 // 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
if (accountData.proxy) { account.rateLimitStatus = rateLimitInfo.isRateLimited
try { ? {
accountData.proxy = JSON.parse(accountData.proxy) isRateLimited: true,
} catch { rateLimitedAt: account.rateLimitedAt || null,
accountData.proxy = 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 rateLimitInfo = this._getRateLimitInfo(accountData) const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
accountData.rateLimitStatus = rateLimitInfo.isRateLimited for (const key of keys) {
? { const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
isRateLimited: true, if (!accountIds.includes(accountId)) {
rateLimitedAt: accountData.rateLimitedAt || null, const accountData = await client.hgetall(key)
minutesRemaining: rateLimitInfo.remainingMinutes || 0 if (accountData && accountData.id) {
// 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') {
// 隐藏敏感信息
accountData.apiKey = '***'
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 获取限流状态信息与普通OpenAI账号保持一致的格式
const rateLimitInfo = this._getRateLimitInfo(accountData)
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
rateLimitedAt: accountData.rateLimitedAt || null,
minutesRemaining: rateLimitInfo.remainingMinutes || 0
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
accountData.schedulable = accountData.schedulable !== 'false'
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
accountData.expiresAt = accountData.subscriptionExpiresAt || null
accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData)
} }
: { }
isRateLimited: false, }
rateLimitedAt: null, }
minutesRemaining: 0
}
// 转换字段类型
accountData.schedulable = accountData.schedulable !== 'false'
accountData.isActive = accountData.isActive === 'true'
accountData.expiresAt = accountData.subscriptionExpiresAt || null
accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData)
})
return accounts return accounts
} }
@@ -618,9 +644,6 @@ 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,11 +7,6 @@ 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) {
@@ -44,21 +39,6 @@ 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
@@ -279,8 +259,10 @@ class OpenAIResponsesRelayService {
return res.status(response.status).json(errorData) return res.status(response.status).json(errorData)
} }
// 更新最后使用时间(节流) // 更新最后使用时间
await this._throttledUpdateLastUsedAt(account.id) await openaiResponsesAccountService.updateAccount(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') {
@@ -557,8 +539,7 @@ class OpenAIResponsesRelayService {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
modelToRecord, modelToRecord,
account.id, account.id
'openai-responses'
) )
logger.info( logger.info(
@@ -686,8 +667,7 @@ class OpenAIResponsesRelayService {
cacheCreateTokens, cacheCreateTokens,
cacheReadTokens, cacheReadTokens,
actualModel, actualModel,
account.id, account.id
'openai-responses'
) )
logger.info( logger.info(

View File

@@ -36,15 +36,28 @@ class OpenAIToClaudeConverter {
// 如果 OpenAI 请求中包含系统消息,提取并检查 // 如果 OpenAI 请求中包含系统消息,提取并检查
const systemMessage = this._extractSystemMessage(openaiRequest.messages) const systemMessage = this._extractSystemMessage(openaiRequest.messages)
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
// Xcode 系统提示词 const passThroughSystemPrompt =
String(process.env.CRS_PASSTHROUGH_SYSTEM_PROMPT || '').toLowerCase() === 'true'
if (
systemMessage &&
(passThroughSystemPrompt || systemMessage.includes('You are currently in Xcode'))
) {
claudeRequest.system = systemMessage claudeRequest.system = systemMessage
logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` if (systemMessage.includes('You are currently in Xcode')) {
) logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
)
} else {
logger.info(
`🧩 Using caller-provided system prompt (${systemMessage.length} chars) because CRS_PASSTHROUGH_SYSTEM_PROMPT=true`
)
}
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
} else { } else {
// 使用 Claude Code 默认系统提示词 // 默认行为:兼容 Claude Code(忽略外部 system
claudeRequest.system = claudeCodeSystemMessage claudeRequest.system = claudeCodeSystemMessage
logger.debug( logger.debug(
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`

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

@@ -1,698 +0,0 @@
/**
* 额度卡/时间卡服务
* 管理员生成卡,用户核销,管理员可撤销
*/
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

@@ -72,8 +72,7 @@ class RateLimitCleanupService {
const results = { const results = {
openai: { checked: 0, cleared: 0, errors: [] }, openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] }, claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }, claudeConsole: { checked: 0, cleared: 0, errors: [] }
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
} }
// 清理 OpenAI 账号 // 清理 OpenAI 账号
@@ -85,29 +84,21 @@ class RateLimitCleanupService {
// 清理 Claude Console 账号 // 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole) await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
// 主动刷新等待重置的 Claude 账户 Token防止 5小时/7天 等待期间 Token 过期)
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
const totalChecked = const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked results.openai.checked + results.claude.checked + results.claudeConsole.checked
const totalCleared = const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
const duration = Date.now() - startTime const duration = Date.now() - startTime
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) { if (totalCleared > 0) {
logger.info( logger.info(
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)` `✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
) )
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`) logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`) logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
logger.info( logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}` ` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
) )
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
logger.info(
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
)
}
// 发送 webhook 恢复通知 // 发送 webhook 恢复通知
if (this.clearedAccounts.length > 0) { if (this.clearedAccounts.length > 0) {
@@ -123,8 +114,7 @@ class RateLimitCleanupService {
const allErrors = [ const allErrors = [
...results.openai.errors, ...results.openai.errors,
...results.claude.errors, ...results.claude.errors,
...results.claudeConsole.errors, ...results.claudeConsole.errors
...results.tokenRefresh.errors
] ]
if (allErrors.length > 0) { if (allErrors.length > 0) {
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors) logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
@@ -358,75 +348,6 @@ class RateLimitCleanupService {
} }
} }
/**
* 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期)
* 仅对因限流/配额限制而等待重置的账户执行刷新:
* - 429 限流账户rateLimitAutoStopped=true
* - 5小时限制自动停止账户fiveHourAutoStopped=true
* 不处理错误状态账户error/temp_error
*/
async proactiveRefreshClaudeTokens(result) {
try {
const redis = require('../models/redis')
const accounts = await redis.getAllClaudeAccounts()
const now = Date.now()
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
for (const account of accounts) {
// 1. 必须激活
if (account.isActive !== 'true') {
continue
}
// 2. 必须有 refreshToken
if (!account.refreshToken) {
continue
}
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
// 正常调度的账户会在请求时自动刷新,无需主动刷新
// 错误状态账户的 Token 可能已失效,刷新也会失败
const isWaitingForReset =
account.rateLimitAutoStopped === 'true' || // 429 限流
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
if (!isWaitingForReset) {
continue
}
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
if (now - lastRefreshAt < recentRefreshMs) {
continue
}
// 5. 检查 Token 是否即将过期30分钟内
const expiresAt = parseInt(account.expiresAt)
if (expiresAt && now < expiresAt - refreshAheadMs) {
continue
}
// 符合条件,执行刷新
result.checked++
try {
await claudeAccountService.refreshAccountToken(account.id)
result.refreshed++
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
}
}
} catch (error) {
logger.error('Failed to proactively refresh Claude tokens:', error)
result.errors.push({ error: error.message })
}
}
/** /**
* 手动触发一次清理(供 API 或 CLI 调用) * 手动触发一次清理(供 API 或 CLI 调用)
*/ */

View File

@@ -1,259 +0,0 @@
/**
* 服务倍率配置服务
* 管理不同服务的消费倍率,以 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,7 +6,6 @@ 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)
@@ -39,6 +38,16 @@ 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) {
@@ -277,7 +286,7 @@ class UnifiedClaudeScheduler {
throw error throw error
} }
if (!isSchedulable(boundAccount.schedulable)) { if (!this._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`
) )
@@ -310,7 +319,7 @@ class UnifiedClaudeScheduler {
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active' &&
isSchedulable(boundConsoleAccount.schedulable) this._isSchedulable(boundConsoleAccount.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -345,7 +354,7 @@ class UnifiedClaudeScheduler {
if ( if (
boundBedrockAccountResult.success && boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true && boundBedrockAccountResult.data.isActive === true &&
isSchedulable(boundBedrockAccountResult.data.schedulable) this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -427,7 +436,7 @@ class UnifiedClaudeScheduler {
} }
// 按优先级和最后使用时间排序 // 按优先级和最后使用时间排序
const sortedAccounts = sortAccountsByPriority(availableAccounts) const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -487,7 +496,7 @@ class UnifiedClaudeScheduler {
throw error throw error
} }
if (!isSchedulable(boundAccount.schedulable)) { if (!this._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})`
) )
@@ -521,7 +530,7 @@ class UnifiedClaudeScheduler {
boundConsoleAccount && boundConsoleAccount &&
boundConsoleAccount.isActive === true && boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active' && boundConsoleAccount.status === 'active' &&
isSchedulable(boundConsoleAccount.schedulable) this._isSchedulable(boundConsoleAccount.schedulable)
) { ) {
// 主动触发一次额度检查 // 主动触发一次额度检查
try { try {
@@ -570,7 +579,7 @@ class UnifiedClaudeScheduler {
if ( if (
boundBedrockAccountResult.success && boundBedrockAccountResult.success &&
boundBedrockAccountResult.data.isActive === true && boundBedrockAccountResult.data.isActive === true &&
isSchedulable(boundBedrockAccountResult.data.schedulable) this._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})`
@@ -600,7 +609,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) && // 兼容旧数据
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查是否可调度 // 检查是否可调度
@@ -682,7 +691,7 @@ class UnifiedClaudeScheduler {
currentAccount.isActive === true && currentAccount.isActive === true &&
currentAccount.status === 'active' && currentAccount.status === 'active' &&
currentAccount.accountType === 'shared' && currentAccount.accountType === 'shared' &&
isSchedulable(currentAccount.schedulable) this._isSchedulable(currentAccount.schedulable)
) { ) {
// 检查是否可调度 // 检查是否可调度
@@ -817,7 +826,7 @@ class UnifiedClaudeScheduler {
if ( if (
account.isActive === true && account.isActive === true &&
account.accountType === 'shared' && account.accountType === 'shared' &&
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查是否临时不可用 // 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable( const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
@@ -861,7 +870,7 @@ class UnifiedClaudeScheduler {
account.isActive === true && account.isActive === true &&
account.status === 'active' && account.status === 'active' &&
account.accountType === 'shared' && account.accountType === 'shared' &&
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
@@ -940,6 +949,21 @@ 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 {
@@ -954,7 +978,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(account.schedulable)) { if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 Account ${accountId} is not schedulable`) logger.info(`🚫 Account ${accountId} is not schedulable`)
return false return false
} }
@@ -1005,7 +1029,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(account.schedulable)) { if (!this._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
} }
@@ -1069,7 +1093,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(accountResult.data.schedulable)) { if (!this._isSchedulable(accountResult.data.schedulable)) {
logger.info(`🚫 Bedrock account ${accountId} is not schedulable`) logger.info(`🚫 Bedrock account ${accountId} is not schedulable`)
return false return false
} }
@@ -1089,7 +1113,7 @@ class UnifiedClaudeScheduler {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(account.schedulable)) { if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 CCR account ${accountId} is not schedulable`) logger.info(`🚫 CCR account ${accountId} is not schedulable`)
return false return false
} }
@@ -1520,7 +1544,7 @@ class UnifiedClaudeScheduler {
? account.status === 'active' ? account.status === 'active'
: account.status === 'active' : account.status === 'active'
if (isActive && status && isSchedulable(account.schedulable)) { if (isActive && status && this._isSchedulable(account.schedulable)) {
// 检查模型支持 // 检查模型支持
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) { if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
continue continue
@@ -1570,7 +1594,7 @@ class UnifiedClaudeScheduler {
} }
// 使用现有的优先级排序逻辑 // 使用现有的优先级排序逻辑
const sortedAccounts = sortAccountsByPriority(availableAccounts) const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -1640,7 +1664,7 @@ class UnifiedClaudeScheduler {
} }
// 3. 按优先级和最后使用时间排序 // 3. 按优先级和最后使用时间排序
const sortedAccounts = sortAccountsByPriority(availableCcrAccounts) const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
// 4. 建立会话映射 // 4. 建立会话映射
@@ -1686,7 +1710,7 @@ class UnifiedClaudeScheduler {
account.isActive === true && account.isActive === true &&
account.status === 'active' && account.status === 'active' &&
account.accountType === 'shared' && account.accountType === 'shared' &&
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {

View File

@@ -3,7 +3,6 @@ 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'
@@ -45,9 +44,9 @@ class UnifiedGeminiScheduler {
} }
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值) // 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
_isActive(activeValue) { _isActive(isActive) {
// 兼容布尔值 true 和字符串 'true' // 兼容布尔值 true 和字符串 'true'
return activeValue === true || activeValue === 'true' return isActive === true || isActive === 'true'
} }
// 🎯 统一调度Gemini账号 // 🎯 统一调度Gemini账号
@@ -67,7 +66,11 @@ 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 (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { if (
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}`
) )
@@ -180,7 +183,7 @@ class UnifiedGeminiScheduler {
} }
// 按优先级和最后使用时间排序 // 按优先级和最后使用时间排序
const sortedAccounts = sortAccountsByPriority(availableAccounts) const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]
@@ -240,7 +243,11 @@ 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 (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { if (
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
const isRateLimited = await this.isAccountRateLimited(accountId) const isRateLimited = await this.isAccountRateLimited(accountId)
if (!isRateLimited) { if (!isRateLimited) {
// 检查模型支持 // 检查模型支持
@@ -342,10 +349,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 (
isActive(account.isActive) && this._isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
if ( if (
normalizedOauthProvider && normalizedOauthProvider &&
@@ -398,10 +405,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 (
isActive(account.isActive) && this._isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && (account.accountType === 'shared' || !account.accountType) &&
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 检查模型支持 // 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
@@ -438,27 +445,42 @@ 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 || !isActive(account.isActive) || account.status === 'error') { if (!account || !this._isActive(account.isActive) || account.status === 'error') {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(account.schedulable)) { if (!this._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 || !isActive(account.isActive) || account.status === 'error') { if (!account || !this._isActive(account.isActive) || account.status === 'error') {
return false return false
} }
// 检查是否可调度 // 检查是否可调度
if (!isSchedulable(account.schedulable)) { if (!this._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
} }
@@ -716,9 +738,9 @@ class UnifiedGeminiScheduler {
// 检查账户是否可用 // 检查账户是否可用
if ( if (
isActive(account.isActive) && this._isActive(account.isActive) &&
account.status !== 'error' && account.status !== 'error' &&
isSchedulable(account.schedulable) this._isSchedulable(account.schedulable)
) { ) {
// 对于 Gemini OAuth 账户,检查 token 是否过期 // 对于 Gemini OAuth 账户,检查 token 是否过期
if (accountType === 'gemini') { if (accountType === 'gemini') {
@@ -765,7 +787,7 @@ class UnifiedGeminiScheduler {
} }
// 使用现有的优先级排序逻辑 // 使用现有的优先级排序逻辑
const sortedAccounts = sortAccountsByPriority(availableAccounts) const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户 // 选择第一个账户
const selectedAccount = sortedAccounts[0] const selectedAccount = sortedAccounts[0]

View File

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

View File

@@ -10,7 +10,6 @@ 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分钟
@@ -20,9 +19,6 @@ 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
@@ -68,23 +64,18 @@ class UserMessageQueueService {
} }
/** /**
* 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存 * 获取当前配置(支持 Web 界面配置优先)
* @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 ?? 60000, timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000因为锁持有时间短
lockTtlMs: queueConfig.lockTtlMs ?? 120000 lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 50005秒足以覆盖请求发送
} }
// 尝试从 claudeRelayConfigService 获取 Web 界面配置 // 尝试从 claudeRelayConfigService 获取 Web 界面配置
@@ -92,7 +83,7 @@ class UserMessageQueueService {
const claudeRelayConfigService = require('./claudeRelayConfigService') const claudeRelayConfigService = require('./claudeRelayConfigService')
const webConfig = await claudeRelayConfigService.getConfig() const webConfig = await claudeRelayConfigService.getConfig()
const result = { return {
enabled: enabled:
webConfig.userMessageQueueEnabled !== undefined webConfig.userMessageQueueEnabled !== undefined
? webConfig.userMessageQueueEnabled ? webConfig.userMessageQueueEnabled
@@ -110,13 +101,8 @@ 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,7 +74,6 @@ 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) {
@@ -168,8 +167,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).length const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
return { return {
totalUsage, totalUsage,
@@ -192,18 +191,14 @@ 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 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 users = [] const users = []
for (let i = 0; i < keys.length; i++) { for (const key of keys) {
const userData = dataList[i] const userData = await client.get(key)
if (userData) { if (userData) {
const user = JSON.parse(userData) const user = JSON.parse(userData)
@@ -403,15 +398,14 @@ class UserService {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const pattern = `${this.userSessionPrefix}*` const pattern = `${this.userSessionPrefix}*`
const keys = await redis.scanKeys(pattern) const keys = await client.keys(pattern)
const dataList = await redis.batchGetChunked(keys)
for (let i = 0; i < keys.length; i++) { for (const key of keys) {
const sessionData = dataList[i] const sessionData = await client.get(key)
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(keys[i]) await client.del(key)
} }
} }
} }
@@ -460,13 +454,9 @@ class UserService {
// 📊 获取用户统计信息 // 📊 获取用户统计信息
async getUserStats() { async getUserStats() {
try { try {
const userIds = await redis.getAllIdsByIndex( const client = redis.getClientSafe()
'user:index', const pattern = `${this.userPrefix}*`
`${this.userPrefix}*`, const keys = await client.keys(pattern)
/^user:(.+)$/
)
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
const dataList = await redis.batchGetChunked(keys)
const stats = { const stats = {
totalUsers: 0, totalUsers: 0,
@@ -482,8 +472,8 @@ class UserService {
} }
} }
for (let i = 0; i < keys.length; i++) { for (const key of keys) {
const userData = dataList[i] const userData = await client.get(key)
if (userData) { if (userData) {
const user = JSON.parse(userData) const user = JSON.parse(userData)
stats.totalUsers++ stats.totalUsers++
@@ -532,7 +522,7 @@ class UserService {
const { displayName, username, email } = user const { displayName, username, email } = user
// 获取所有API Keys // 获取所有API Keys
const allApiKeys = await apiKeyService.getAllApiKeysFast() const allApiKeys = await apiKeyService.getAllApiKeys()
// 找到没有用户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

@@ -1,283 +0,0 @@
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()

View File

@@ -1,7 +1,7 @@
const fs = require('fs/promises')
const path = require('path') const path = require('path')
const logger = require('./logger') const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths') const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP' const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES' const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
@@ -108,7 +108,7 @@ async function dumpAnthropicMessagesRequest(req, meta = {}) {
const line = `${safeJsonStringify(record, maxBytes)}\n` const line = `${safeJsonStringify(record, maxBytes)}\n`
try { try {
await safeRotatingAppend(filename, line) await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) { } catch (e) {
logger.warn('Failed to dump Anthropic request', { logger.warn('Failed to dump Anthropic request', {
filename, filename,

View File

@@ -1,7 +1,7 @@
const fs = require('fs/promises')
const path = require('path') const path = require('path')
const logger = require('./logger') const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths') const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP' const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES' const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
@@ -89,7 +89,7 @@ async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
const line = `${safeJsonStringify(record, maxBytes)}\n` const line = `${safeJsonStringify(record, maxBytes)}\n`
try { try {
await safeRotatingAppend(filename, line) await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) { } catch (e) {
logger.warn('Failed to dump Anthropic response', { logger.warn('Failed to dump Anthropic response', {
filename, filename,

View File

@@ -1,7 +1,7 @@
const fs = require('fs/promises')
const path = require('path') const path = require('path')
const logger = require('./logger') const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths') const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP' const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES' const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
@@ -103,7 +103,7 @@ async function dumpAntigravityUpstreamRequest(requestInfo) {
const line = `${safeJsonStringify(record, maxBytes)}\n` const line = `${safeJsonStringify(record, maxBytes)}\n`
try { try {
await safeRotatingAppend(filename, line) await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) { } catch (e) {
logger.warn('Failed to dump Antigravity upstream request', { logger.warn('Failed to dump Antigravity upstream request', {
filename, filename,

View File

@@ -1,175 +0,0 @@
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP'
const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES'
const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl'
function isEnabled() {
const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV]
if (!raw) {
return false
}
const normalized = String(raw).trim().toLowerCase()
return normalized === '1' || normalized === 'true'
}
function getMaxBytes() {
const raw = process.env[UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV]
if (!raw) {
return 2 * 1024 * 1024
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2 * 1024 * 1024
}
return parsed
}
function safeJsonStringify(payload, maxBytes) {
let json = ''
try {
json = JSON.stringify(payload)
} catch (e) {
return JSON.stringify({
type: 'antigravity_upstream_response_dump_error',
error: 'JSON.stringify_failed',
message: e?.message || String(e)
})
}
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
return json
}
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
return JSON.stringify({
type: 'antigravity_upstream_response_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
/**
* 记录 Antigravity 上游 API 的响应
* @param {Object} responseInfo - 响应信息
* @param {string} responseInfo.requestId - 请求 ID
* @param {string} responseInfo.model - 模型名称
* @param {number} responseInfo.statusCode - HTTP 状态码
* @param {string} responseInfo.statusText - HTTP 状态文本
* @param {Object} responseInfo.headers - 响应头
* @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error)
* @param {Object} responseInfo.summary - 响应摘要
* @param {Object} responseInfo.error - 错误信息(如果有)
*/
async function dumpAntigravityUpstreamResponse(responseInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_upstream_response',
requestId: responseInfo?.requestId || null,
model: responseInfo?.model || null,
statusCode: responseInfo?.statusCode || null,
statusText: responseInfo?.statusText || null,
responseType: responseInfo?.responseType || null,
headers: responseInfo?.headers || null,
summary: responseInfo?.summary || null,
error: responseInfo?.error || null,
rawData: responseInfo?.rawData || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
logger.warn('Failed to dump Antigravity upstream response', {
filename,
requestId: responseInfo?.requestId || null,
error: e?.message || String(e)
})
}
}
/**
* 记录 SSE 流中的每个事件(用于详细调试)
*/
async function dumpAntigravityStreamEvent(eventInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_stream_event',
requestId: eventInfo?.requestId || null,
eventIndex: eventInfo?.eventIndex || null,
eventType: eventInfo?.eventType || null,
data: eventInfo?.data || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
// 静默处理,避免日志过多
}
}
/**
* 记录流式响应的最终摘要
*/
async function dumpAntigravityStreamSummary(summaryInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_stream_summary',
requestId: summaryInfo?.requestId || null,
model: summaryInfo?.model || null,
totalEvents: summaryInfo?.totalEvents || 0,
finishReason: summaryInfo?.finishReason || null,
hasThinking: summaryInfo?.hasThinking || false,
hasToolCalls: summaryInfo?.hasToolCalls || false,
toolCallNames: summaryInfo?.toolCallNames || [],
usage: summaryInfo?.usage || null,
error: summaryInfo?.error || null,
textPreview: summaryInfo?.textPreview || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
logger.warn('Failed to dump Antigravity stream summary', {
filename,
requestId: summaryInfo?.requestId || null,
error: e?.message || String(e)
})
}
}
module.exports = {
dumpAntigravityUpstreamResponse,
dumpAntigravityStreamEvent,
dumpAntigravityStreamSummary,
UPSTREAM_RESPONSE_DUMP_ENV,
UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV,
UPSTREAM_RESPONSE_DUMP_FILENAME
}

View File

@@ -1,408 +0,0 @@
// 通用工具函数集合
// 抽取自各服务的重复代码,统一管理
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,11 +79,6 @@ 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,260 +1,217 @@
/** /**
* 错误消息清理工具 - 白名单错误码制 * 错误消息清理工具
* 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端 * 用于移除上游错误中的供应商特定信息(如 URL、引用等
*/ */
const logger = require('./logger')
// 标准错误码定义
const ERROR_CODES = {
E001: { message: 'Service temporarily unavailable', status: 503 },
E002: { message: 'Network connection failed', status: 502 },
E003: { message: 'Authentication failed', status: 401 },
E004: { message: 'Rate limit exceeded', status: 429 },
E005: { message: 'Invalid request', status: 400 },
E006: { message: 'Model not available', status: 503 },
E007: { message: 'Upstream service error', status: 502 },
E008: { message: 'Request timeout', status: 504 },
E009: { message: 'Permission denied', status: 403 },
E010: { message: 'Resource not found', status: 404 },
E011: { message: 'Account temporarily unavailable', status: 503 },
E012: { message: 'Server overloaded', status: 529 },
E013: { message: 'Invalid API key', status: 401 },
E014: { message: 'Quota exceeded', status: 429 },
E015: { message: 'Internal server error', status: 500 }
}
// 错误特征匹配规则(按优先级排序)
const ERROR_MATCHERS = [
// 网络层错误
{ pattern: /ENOTFOUND|DNS|getaddrinfo/i, code: 'E002' },
{ pattern: /ECONNREFUSED|ECONNRESET|connection refused/i, code: 'E002' },
{ pattern: /ETIMEDOUT|timeout/i, code: 'E008' },
{ pattern: /ECONNABORTED|aborted/i, code: 'E002' },
// 认证错误
{ pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' },
{ pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' },
{ pattern: /authentication|auth.*fail/i, code: 'E003' },
// 权限错误
{ pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' },
{ pattern: /does not have.*permission/i, code: 'E009' },
// 限流错误
{ pattern: /rate.*limit|too many requests|429/i, code: 'E004' },
{ pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' },
// 过载错误
{ pattern: /overloaded|529|capacity/i, code: 'E012' },
// 账户错误
{ pattern: /account.*disabled|organization.*disabled/i, code: 'E011' },
{ pattern: /too many active sessions/i, code: 'E011' },
// 模型错误
{ pattern: /model.*not.*found|model.*unavailable|unsupported.*model/i, code: 'E006' },
// 请求错误
{ pattern: /bad.*request|invalid.*request|malformed/i, code: 'E005' },
{ pattern: /not.*found|404/i, code: 'E010' },
// 上游错误
{ pattern: /upstream|502|bad.*gateway/i, code: 'E007' },
{ pattern: /503|service.*unavailable/i, code: 'E001' }
]
/** /**
* 根据原始错误匹配标准错误码 * 清理错误消息中的 URL 和供应商引用
* @param {Error|string|object} error - 原始错误 * @param {string} message - 原始错误消息
* @param {object} options - 选项 * @returns {string} - 清理后的消息
* @param {string} options.context - 错误上下文(用于日志)
* @param {boolean} options.logOriginal - 是否记录原始错误默认true
* @returns {{ code: string, message: string, status: number }}
*/ */
function mapToErrorCode(error, options = {}) { function sanitizeErrorMessage(message) {
const { context = 'unknown', logOriginal = true } = options if (typeof message !== 'string') {
return message
// 提取原始错误信息
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
})
} }
// 匹配错误码 // 移除 URLhttp:// 或 https://
let matchedCode = 'E015' // 默认:内部服务器错误 let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '')
// 先按 HTTP 状态码快速匹配 // 移除常见的供应商引用模式
if (statusCode) { cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '')
if (statusCode === 401) { cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx"
matchedCode = 'E003' cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx"
} else if (statusCode === 403) { cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息
matchedCode = 'E009'
} else if (statusCode === 404) { // 移除供应商特定关键词(包括整个单词)
matchedCode = 'E010' cleaned = cleaned.replace(/88code\S*/gi, '')
} else if (statusCode === 429) { cleaned = cleaned.replace(/duck\S*/gi, '')
matchedCode = 'E004' cleaned = cleaned.replace(/packy\S*/gi, '')
} else if (statusCode === 502) { cleaned = cleaned.replace(/ikun\S*/gi, '')
matchedCode = 'E007' cleaned = cleaned.replace(/privnode\S*/gi, '')
} else if (statusCode === 503) { cleaned = cleaned.replace(/yescode\S*/gi, '')
matchedCode = 'E001' cleaned = cleaned.replace(/yes.vg\S*/gi, '')
} else if (statusCode === 504) { cleaned = cleaned.replace(/share\S*/gi, '')
matchedCode = 'E008' cleaned = cleaned.replace(/yhlxj\S*/gi, '')
} else if (statusCode === 529) { cleaned = cleaned.replace(/gac\S*/gi, '')
matchedCode = 'E012' cleaned = cleaned.replace(/driod\S*/gi, '')
cleaned = cleaned.replace(/\s+/g, ' ').trim()
// 如果消息被清理得太短或为空,返回通用消息
if (cleaned.length < 5) {
return 'The requested model is currently unavailable'
}
return cleaned
}
/**
* 递归清理对象中的所有错误消息字段
* @param {Object} errorData - 原始错误数据对象
* @returns {Object} - 清理后的错误数据
*/
function sanitizeUpstreamError(errorData) {
if (!errorData || typeof errorData !== 'object') {
return errorData
}
// AxiosError / Error返回摘要避免泄露请求体/headers/token 等敏感信息
const looksLikeAxiosError =
errorData.isAxiosError ||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
if (looksLikeAxiosError || looksLikeError) {
const statusCode = errorData.response?.status
const upstreamBody = errorData.response?.data
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
return {
name: errorData.name || 'Error',
code: errorData.code,
statusCode,
message: sanitizeErrorMessage(errorData.message || ''),
upstreamMessage: upstreamMessage || undefined,
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
} }
} }
// 再按消息内容精确匹配(可能覆盖状态码匹配) // 递归清理嵌套的错误对象
if (originalMessage) { const visited = new WeakSet()
for (const matcher of ERROR_MATCHERS) {
if (matcher.pattern.test(originalMessage)) { const shouldRedactKey = (key) => {
matchedCode = matcher.code if (!key) {
break 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
} }
// 按错误 code 匹配(网络错误) // 尽量不修改原对象:浅拷贝后递归清理
if (errorCode) { const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
const codeStr = String(errorCode).toUpperCase() return sanitizeObject(clone)
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 {
code: matchedCode,
message: result.message,
status: result.status
}
} }
/** /**
* 提取原始错误消息 * 提取错误消息(支持多种错误格式)
* @param {*} body - 错误响应体(字符串或对象)
* @returns {string} - 提取的错误消息
*/ */
function extractOriginalMessage(error) { function extractErrorMessage(body) {
if (!error) { if (!body) {
return '' return ''
} }
if (typeof error === 'string') {
return error // 处理字符串类型
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 (error.message) {
return error.message // 处理对象类型
} if (typeof body === 'object') {
if (error.response?.data?.error?.message) { // 常见错误格式: { error: "message" }
return error.response.data.error.message if (typeof body.error === 'string') {
} return body.error
if (error.response?.data?.error) { }
return String(error.response.data.error) // 嵌套错误格式: { error: { message: "..." } }
} if (body.error && typeof body.error === 'object') {
if (error.response?.data?.message) { if (typeof body.error.message === 'string') {
return error.response.data.message return body.error.message
}
if (typeof body.error.error === 'string') {
return body.error.error
}
}
// 直接消息格式: { message: "..." }
if (typeof body.message === 'string') {
return body.message
}
} }
return '' return ''
} }
/** /**
* 创建安全的错误响应对象 * 检测是否为账户被禁用或不可用的 400 错误
* @param {Error|string|object} error - 原始错误 * @param {number} statusCode - HTTP 状态码
* @param {object} options - 选项 * @param {*} body - 响应体
* @returns {{ error: { code: string, message: string }, status: number }} * @returns {boolean} - 是否为账户禁用错误
*/ */
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) {
return extractOriginalMessage(body)
}
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 (
lower.includes('organization has been disabled') || lowerMessage.includes('organization has been disabled') ||
lower.includes('account has been disabled') || lowerMessage.includes('account has been disabled') ||
lower.includes('account is disabled') || lowerMessage.includes('account is disabled') ||
lower.includes('no account supporting') || lowerMessage.includes('no account supporting') ||
lower.includes('account not found') || lowerMessage.includes('account not found') ||
lower.includes('invalid account') || lowerMessage.includes('invalid account') ||
lower.includes('too many active sessions') lowerMessage.includes('too many active sessions')
) )
} }
module.exports = { module.exports = {
ERROR_CODES,
mapToErrorCode,
createSafeErrorResponse,
createSafeSSEError,
getSafeMessage,
// 兼容旧接口
sanitizeErrorMessage, sanitizeErrorMessage,
sanitizeUpstreamError, sanitizeUpstreamError,
extractErrorMessage, extractErrorMessage,

View File

@@ -20,9 +20,8 @@ const parseBooleanEnv = (value) => {
} }
/** /**
* 是否允许执行"余额脚本"(安全开关) * 是否允许执行余额脚本(安全开关)
* ⚠️ 安全警告vm模块非安全沙箱默认禁用。如需用请显式设置 BALANCE_SCRIPT_ENABLED=true * 默认开启,便于保持现有行为;如需用请显式设置 BALANCE_SCRIPT_ENABLED=false环境变量优先
* 仅在完全信任管理员且了解RCE风险时才启用此功能
*/ */
const isBalanceScriptEnabled = () => { const isBalanceScriptEnabled = () => {
if ( if (
@@ -37,8 +36,7 @@ const isBalanceScriptEnabled = () => {
config?.features?.balanceScriptEnabled ?? config?.features?.balanceScriptEnabled ??
config?.security?.enableBalanceScript config?.security?.enableBalanceScript
// 默认禁用,需显式启用 return typeof fromConfig === 'boolean' ? fromConfig : true
return typeof fromConfig === 'boolean' ? fromConfig : false
} }
module.exports = { module.exports = {

View File

@@ -188,54 +188,10 @@ 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://platform.claude.com/oauth/code/callback', REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code', SCOPES: 'org:create_api_key user:profile user:inference',
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
} }
@@ -35,7 +35,6 @@ function generateState() {
/** /**
* 生成随机的 code verifierPKCE * 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串 * @returns {string} base64url 编码的随机字符串
*/ */
function generateCodeVerifier() { function generateCodeVerifier() {
@@ -211,7 +210,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,
@@ -431,7 +430,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,
@@ -661,7 +660,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
}) })
@@ -778,7 +777,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)}...`
}) })
@@ -854,7 +853,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

@@ -1,168 +0,0 @@
/**
* 性能优化工具模块
* 提供 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
}

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