Compare commits

..

1 Commits

Author SHA1 Message Date
Wesley Liddick
96f213e3e2 Revert "feat: 完整国际化支持 - Web 管理界面多语言实现" 2025-09-12 11:09:52 +08:00
152 changed files with 30590 additions and 67043 deletions

View File

@@ -15,7 +15,6 @@ logs/
# Data files
data/
temp/
redis_data/
# Git
.git/

View File

@@ -37,26 +37,11 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
# 400错误处理0表示禁用>0表示临时禁用时间分钟
# 只有匹配特定错误模式的 400 才会触发临时禁用
# - organization has been disabled
# - account has been disabled
# - account is disabled
# - no account supporting
# - account not found
# - invalid account
# - Too many active sessions
CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES=10
# 🌐 代理配置
DEFAULT_PROXY_TIMEOUT=600000
MAX_PROXY_RETRIES=3
# IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
PROXY_USE_IPV4=true
# 代理连接池 / Keep-Alive 配置(默认关闭,如需启用请取消注释)
# PROXY_KEEP_ALIVE=true
# PROXY_MAX_SOCKETS=50
# PROXY_MAX_FREE_SOCKETS=10
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Your GitHub username for GitHub Sponsors
patreon: # Replace with your Patreon username if you have one
open_collective: # Replace with your Open Collective username if you have one
ko_fi: # Replace with your Ko-fi username if you have one
tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with your Community Bridge project-name
liberapay: # Replace with your Liberapay username
issuehunt: # Replace with your IssueHunt username
otechie: # Replace with your Otechie username
custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian)

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
workflow_dispatch: # 支持手动触发
permissions:
contents: write
@@ -25,17 +24,6 @@ jobs:
- name: Check if version bump is needed
id: check
run: |
# 检查提交消息是否包含强制发布标记([force release]
COMMIT_MSG=$(git log -1 --pretty=%B | tr -d '\r')
echo "Latest commit message:"
echo "$COMMIT_MSG"
FORCE_RELEASE=false
if echo "$COMMIT_MSG" | grep -qi "\[force release\]"; then
echo "Detected [force release] marker, forcing version bump"
FORCE_RELEASE=true
fi
# 检测是否是合并提交
PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w)
PARENT_COUNT=$((PARENT_COUNT - 1))
@@ -80,14 +68,7 @@ jobs:
fi
done <<< "$CHANGED_FILES"
# 检查是否是手动触发
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual workflow trigger detected, forcing version bump"
echo "needs_bump=true" >> $GITHUB_OUTPUT
elif [ "$FORCE_RELEASE" = true ]; then
echo "Force release marker detected, forcing version bump"
echo "needs_bump=true" >> $GITHUB_OUTPUT
elif [ "$SIGNIFICANT_CHANGES" = true ]; then
if [ "$SIGNIFICANT_CHANGES" = true ]; then
echo "Significant changes detected, version bump needed"
echo "needs_bump=true" >> $GITHUB_OUTPUT
else
@@ -265,23 +246,6 @@ jobs:
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
- name: Prepare image names
id: image_names
if: steps.check.outputs.needs_bump == 'true'
run: |
DOCKER_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}"
if [ -z "$DOCKER_USERNAME" ]; then
DOCKER_USERNAME="weishaw"
fi
DOCKER_IMAGE=$(echo "${DOCKER_USERNAME}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
GHCR_IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/claude-relay-service" | tr '[:upper:]' '[:lower:]')
{
echo "docker_image=${DOCKER_IMAGE}"
echo "ghcr_image=${GHCR_IMAGE}"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
if: steps.check.outputs.needs_bump == 'true'
uses: softprops/action-gh-release@v1
@@ -292,10 +256,8 @@ jobs:
## 🐳 Docker 镜像
```bash
docker pull ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
docker pull ${{ steps.image_names.outputs.docker_image }}:latest
docker pull ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
docker pull ${{ steps.image_names.outputs.ghcr_image }}:latest
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
docker pull ${{ secrets.DOCKERHUB_USERNAME || 'weishaw' }}/claude-relay-service:latest
```
## 📦 主要更新
@@ -426,32 +388,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
if: steps.check.outputs.needs_bump == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
if: steps.check.outputs.needs_bump == 'true'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }}
${{ steps.image_names.outputs.docker_image }}:latest
${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_version }}
${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }}
${{ steps.image_names.outputs.ghcr_image }}:latest
${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_version }}
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_tag }}
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest
${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ steps.next_version.outputs.new_version }}
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -460,8 +410,6 @@ jobs:
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DOCKER_IMAGE: ${{ steps.image_names.outputs.docker_image }}
GHCR_IMAGE: ${{ steps.image_names.outputs.ghcr_image }}
continue-on-error: true
run: |
VERSION="${{ steps.next_version.outputs.new_version }}"
@@ -482,16 +430,13 @@ jobs:
MESSAGE+="${CHANGELOG_TRUNCATED}"$'\n'$'\n'
MESSAGE+="🐳 *Docker 部署:*"$'\n'
MESSAGE+="\`\`\`bash"$'\n'
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG}"$'\n'
MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n'
MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG}"$'\n'
MESSAGE+="docker pull ${GHCR_IMAGE}:latest"$'\n'
MESSAGE+="docker pull weishaw/claude-relay-service:${TAG}"$'\n'
MESSAGE+="docker pull weishaw/claude-relay-service:latest"$'\n'
MESSAGE+="\`\`\`"$'\n'$'\n'
MESSAGE+="🔗 *相关链接:*"$'\n'
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG})"$'\n'
MESSAGE+="• [完整更新日志](https://github.com/${REPO}/releases)"$'\n'
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE%/*}/claude-relay-service)"$'\n'
MESSAGE+="• [GHCR](https://ghcr.io/${GHCR_IMAGE#ghcr.io/})"$'\n'$'\n'
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/weishaw/claude-relay-service)"$'\n'$'\n'
MESSAGE+="#ClaudeRelay #Update #v${VERSION//./_}"
# 使用 jq 构建 JSON 并发送

View File

@@ -1,62 +0,0 @@
name: 同步模型价格数据
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
jobs:
sync-pricing:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: 检出 price-mirror 分支
uses: actions/checkout@v4
with:
ref: price-mirror
fetch-depth: 0
- name: 下载上游价格文件
id: fetch
run: |
set -euo pipefail
curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json \
-o model_prices_and_context_window.json.new
NEW_HASH=$(sha256sum model_prices_and_context_window.json.new | awk '{print $1}')
if [ -f model_prices_and_context_window.sha256 ]; then
OLD_HASH=$(cat model_prices_and_context_window.sha256 | tr -d ' \n\r')
else
OLD_HASH=""
fi
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo "价格文件无变化,跳过提交"
echo "changed=false" >> "$GITHUB_OUTPUT"
rm -f model_prices_and_context_window.json.new
exit 0
fi
mv model_prices_and_context_window.json.new model_prices_and_context_window.json
echo "$NEW_HASH" > model_prices_and_context_window.sha256
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "hash=$NEW_HASH" >> "$GITHUB_OUTPUT"
- name: 提交并推送变更
if: steps.fetch.outputs.changed == 'true'
env:
NEW_HASH: ${{ steps.fetch.outputs.hash }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add model_prices_and_context_window.json model_prices_and_context_window.sha256
COMMIT_MSG="chore: 同步模型价格数据"
if [ -n "${NEW_HASH}" ]; then
COMMIT_MSG="$COMMIT_MSG (${NEW_HASH})"
fi
git commit -m "$COMMIT_MSG"
git push origin price-mirror

429
CLAUDE.md
View File

@@ -6,87 +6,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述
Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (官方/Console)、Gemini、OpenAI Responses (Codex)、AWS Bedrock、Azure OpenAI、Droid (Factory.ai)、CCR** 等多种账户类型。提供完整的多账户管理、API Key 认证、代理配置、用户管理、LDAP认证、Webhook通知和现代化 Web 管理界面。该服务作为客户端(如 Claude Code、Gemini CLI、Codex、Droid CLI、Cherry Studio 等)与 AI API 之间的中间件,提供认证、限流、监控、定价计算、成本统计等功能。
Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台。提供多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern、Claude Code、Gemini CLI与 AI API 之间的中间件,提供认证、限流、监控等功能。
## 核心架构
### 关键架构概念
- **统一调度系统**: 使用 unifiedClaudeScheduler、unifiedGeminiScheduler、unifiedOpenAIScheduler、droidScheduler 实现跨账户类型的智能调度
- **多账户类型支持**: 支持 claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai 等账户类型
- **代理认证流**: 客户端用自建API Key → 验证 → 统一调度器选择账户 → 获取账户token → 转发到对应API
- **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
- **Token管理**: 自动监控OAuth token过期并刷新支持10秒提前刷新策略
- **代理支持**: 每个账户支持独立代理配置OAuth token交换也通过代理进行
- **数据加密**: 敏感数据refreshToken, accessToken, credentials使用AES加密存储在Redis
- **粘性会话**: 支持会话级别的账户绑定,同一会话使用同一账户,确保上下文连续性
- **权限控制**: API Key支持权限配置all/claude/gemini/openai等控制可访问的服务类型
- **客户端限制**: 基于User-Agent的客户端识别和限制支持ClaudeCode、Gemini-CLI等预定义客户端
- **模型黑名单**: 支持API Key级别的模型访问限制
- **代理支持**: 每个Claude账户支持独立代理配置OAuth token交换也通过代理进行
- **数据加密**: 敏感数据refreshToken, accessToken使用AES加密存储在Redis
### 主要服务组件
#### 核心转发服务
- **claudeRelayService.js**: Claude官方API转发处理OAuth认证和流式响应
- **claudeConsoleRelayService.js**: Claude Console账户转发服务
- **geminiRelayService.js**: Gemini API转发服务
- **bedrockRelayService.js**: AWS Bedrock API转发服务
- **azureOpenaiRelayService.js**: Azure OpenAI API转发服务
- **droidRelayService.js**: Droid (Factory.ai) API转发服务
- **ccrRelayService.js**: CCR账户转发服务
- **openaiResponsesRelayService.js**: OpenAI Responses (Codex) 转发服务
#### 账户管理服务
- **claudeAccountService.js**: Claude官方账户管理OAuth token刷新和账户选择
- **claudeConsoleAccountService.js**: Claude Console账户管理
- **geminiAccountService.js**: Gemini账户管理Google OAuth token刷新
- **bedrockAccountService.js**: AWS Bedrock账户管理
- **azureOpenaiAccountService.js**: Azure OpenAI账户管理
- **droidAccountService.js**: Droid账户管理
- **ccrAccountService.js**: CCR账户管理
- **openaiResponsesAccountService.js**: OpenAI Responses账户管理
- **openaiAccountService.js**: OpenAI兼容账户管理
- **accountGroupService.js**: 账户组管理,支持账户分组和优先级
#### 统一调度器
- **unifiedClaudeScheduler.js**: Claude多账户类型统一调度claude-official/console/bedrock/ccr
- **unifiedGeminiScheduler.js**: Gemini账户统一调度
- **unifiedOpenAIScheduler.js**: OpenAI兼容服务统一调度
- **droidScheduler.js**: Droid账户调度
#### 核心功能服务
- **apiKeyService.js**: API Key管理验证、限流、使用统计、成本计算
- **userService.js**: 用户管理系统支持用户注册、登录、API Key管理
- **pricingService.js**: 定价服务,模型价格管理和成本计算
- **costInitService.js**: 成本数据初始化服务
- **webhookService.js**: Webhook通知服务
- **webhookConfigService.js**: Webhook配置管理
- **ldapService.js**: LDAP认证服务
- **tokenRefreshService.js**: Token自动刷新服务
- **rateLimitCleanupService.js**: 速率限制状态清理服务
- **claudeCodeHeadersService.js**: Claude Code客户端请求头处理
#### 工具服务
- **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
- **claudeAccountService.js**: Claude账户管理OAuth token刷新和账户选择
- **geminiAccountService.js**: Gemini账户管理Google OAuth token刷新和账户选择
- **apiKeyService.js**: API Key管理验证、限流和使用统计
- **oauthHelper.js**: OAuth工具PKCE流程实现和代理支持
- **workosOAuthHelper.js**: WorkOS OAuth集成
- **openaiToClaude.js**: OpenAI格式到Claude格式的转换
### 认证和代理流程
1. 客户端使用自建API Keycr\_前缀格式发送请求到对应路由(/api、/claude、/gemini、/openai、/droid等
2. **authenticateApiKey中间件**验证API Key有效性速率限制、权限、客户端限制、模型黑名单
3. **统一调度器**(如 unifiedClaudeScheduler根据请求模型、会话hash、API Key权限选择最优账户
4. 检查选中账户的token有效性过期则自动刷新使用代理
5. 根据账户类型调用对应的转发服务claudeRelayService、geminiRelayService等
6. 移除客户端API Key使用账户凭据OAuth Bearer token、API Key等转发请求
7. 通过账户配置的代理发送到目标APIAnthropic、Google、AWS等
8. 流式或非流式返回响应捕获真实usage数据
9. 记录使用统计input/output/cache_create/cache_read tokens和成本计算
10. 更新速率限制计数器和并发控制
1. 客户端使用自建API Keycr\_前缀格式发送请求
2. authenticateApiKey中间件验证API Key有效性速率限制
3. claudeAccountService自动选择可用Claude账户
4. 检查OAuth access token有效性过期则自动刷新使用代理
5. 移除客户端API Key使用OAuth Bearer token转发请求
6. 通过账户配置的代理发送到Anthropic API
7. 流式或非流式返回响应,记录使用统计
### OAuth集成
@@ -95,51 +42,6 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **代理支持**: OAuth授权和token交换全程支持代理配置
- **安全存储**: claudeAiOauth数据加密存储包含accessToken、refreshToken、scopes
## 新增功能概览(相比旧版本)
### 多平台支持
-**Claude Console账户**: 支持Claude Console类型账户
-**AWS Bedrock**: 完整的AWS Bedrock API支持
-**Azure OpenAI**: Azure OpenAI服务支持
-**Droid (Factory.ai)**: Factory.ai API支持
-**CCR账户**: CCR凭据支持
-**OpenAI兼容**: OpenAI格式转换和Responses格式支持
### 用户和权限系统
-**用户管理**: 完整的用户注册、登录、API Key管理系统
-**LDAP认证**: 企业级LDAP/Active Directory集成
-**权限控制**: API Key级别的服务权限all/claude/gemini/openai
-**客户端限制**: 基于User-Agent的客户端识别和限制
-**模型黑名单**: API Key级别的模型访问控制
### 统一调度和会话管理
-**统一调度器**: 跨账户类型的智能调度系统
-**粘性会话**: 会话级账户绑定,支持自动续期
-**并发控制**: Redis Sorted Set实现的并发限制
-**负载均衡**: 自动账户选择和故障转移
### 成本和监控
-**定价服务**: 模型价格管理和自动成本计算
-**成本统计**: 详细的token使用和费用统计
-**缓存监控**: 全局缓存统计和命中率分析
-**实时指标**: 可配置窗口的实时统计METRICS_WINDOW
### Webhook和通知
-**Webhook系统**: 事件通知和Webhook配置管理
-**多URL支持**: 支持多个Webhook URL逗号分隔
### 高级功能
-**529错误处理**: 自动识别Claude过载状态并暂时排除账户
-**HTTP调试**: DEBUG_HTTP_TRAFFIC模式详细记录HTTP请求/响应
-**数据迁移**: 完整的数据导入导出工具(含加密/脱敏)
-**自动清理**: 并发计数、速率限制、临时错误状态自动清理
## 常用命令
### 基本开发命令
@@ -167,47 +69,19 @@ npm run service:logs # 查看日志
npm run service:stop # 停止服务
### 开发环境配置
#### 必须配置的环境变量
必须配置的环境变量:
- `JWT_SECRET`: JWT密钥32字符以上随机字符串
- `ENCRYPTION_KEY`: 数据加密密钥32字符固定长度
- `REDIS_HOST`: Redis主机地址默认localhost
- `REDIS_PORT`: Redis端口默认6379
- `REDIS_PASSWORD`: Redis密码可选
#### 新增重要环境变量(可选)
- `USER_MANAGEMENT_ENABLED`: 启用用户管理系统默认false
- `LDAP_ENABLED`: 启用LDAP认证默认false
- `LDAP_URL`: LDAP服务器地址如 ldaps://ldap.example.com:636
- `LDAP_TLS_REJECT_UNAUTHORIZED`: LDAP证书验证默认true
- `WEBHOOK_ENABLED`: 启用Webhook通知默认true
- `WEBHOOK_URLS`: Webhook通知URL列表逗号分隔
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间分钟0表示禁用
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL小时默认1
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值分钟默认0
- `METRICS_WINDOW`: 实时指标统计窗口分钟1-60默认5
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量默认1
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys默认false
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志默认false仅开发环境
- `PROXY_USE_IPV4`: 代理使用IPv4默认true
- `REQUEST_TIMEOUT`: 请求超时时间毫秒默认600000即10分钟
#### AWS Bedrock配置可选
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock设置为1启用
- `AWS_REGION`: AWS默认区域默认us-east-1
- `ANTHROPIC_MODEL`: Bedrock默认模型
- `ANTHROPIC_SMALL_FAST_MODEL`: Bedrock小型快速模型
- `ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION`: 小型模型区域
- `CLAUDE_CODE_MAX_OUTPUT_TOKENS`: 最大输出tokens默认4096
- `MAX_THINKING_TOKENS`: 最大思考tokens默认1024
- `DISABLE_PROMPT_CACHING`: 禁用提示缓存设置为1禁用
#### 初始化命令
初始化命令:
```bash
cp config/config.example.js config/config.js
cp .env.example .env
npm run setup # 自动生成密钥并创建管理员账户
```
````
## Web界面功能
@@ -221,82 +95,31 @@ npm run setup # 自动生成密钥并创建管理员账户
### 核心管理功能
- **实时仪表板**: 系统统计、账户状态、使用量监控、实时指标METRICS_WINDOW配置窗口
- **API Key管理**: 创建、配额设置、使用统计查看、权限配置、客户端限制、模型黑名单
- **多平台账户管理**:
- Claude账户官方/Console: OAuth账户添加、代理配置、状态监控
- Gemini账户: Google OAuth授权、代理配置
- OpenAI Responses (Codex)账户: API Key配置
- AWS Bedrock账户: AWS凭据配置
- Azure OpenAI账户: Azure凭据和端点配置
- Droid账户: Factory.ai API Key配置
- CCR账户: CCR凭据配置
- **用户管理**: 用户注册、登录、API Key分配USER_MANAGEMENT_ENABLED启用时
- **系统日志**: 实时日志查看多级别过滤HTTP调试日志DEBUG_HTTP_TRAFFIC启用时
- **Webhook配置**: Webhook URL管理、事件配置
- **实时仪表板**: 系统统计、账户状态、使用量监控
- **API Key管理**: 创建、配额设置、使用统计查看
- **Claude账户管理**: OAuth账户添加、代理配置、状态监控
- **系统日志**: 实时日志查看,多级别过滤
- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
- **成本分析**: 详细的token使用和成本统计基于pricingService
- **缓存监控**: 解密缓存统计和性能监控
## 重要端点
### API转发端点(多路由支持)
### API转发端点
#### Claude服务路由
- `POST /api/v1/messages` - Claude消息处理支持流式
- `POST /claude/v1/messages` - Claude消息处理别名路由
- `POST /v1/messages/count_tokens` - Token计数Beta API
- `GET /api/v1/models` - 模型列表
- `POST /api/v1/messages` - 主要消息处理端点(支持流式)
- `GET /api/v1/models` - 模型列表(兼容性
- `GET /api/v1/usage` - 使用统计查询
- `GET /api/v1/key-info` - API Key信息
- `GET /v1/me` - 用户信息Claude Code客户端需要
- `GET /v1/organizations/:org_id/usage` - 组织使用统计
#### Gemini服务路由
- `POST /gemini/v1/models/:model:generateContent` - 标准Gemini API格式
- `POST /gemini/v1/models/:model:streamGenerateContent` - Gemini流式
- `GET /gemini/v1/models` - Gemini模型列表
- 其他Gemini兼容路由保持向后兼容
### OAuth管理端点
#### OpenAI兼容路由
- `POST /openai/v1/chat/completions` - OpenAI格式转发支持responses格式
- `POST /openai/claude/v1/chat/completions` - OpenAI格式转Claude
- `POST /openai/gemini/v1/chat/completions` - OpenAI格式转Gemini
- `GET /openai/v1/models` - OpenAI格式模型列表
#### Droid (Factory.ai) 路由
- `POST /droid/claude/v1/messages` - Droid Claude转发
- `POST /droid/openai/v1/chat/completions` - Droid OpenAI转发
#### Azure OpenAI 路由
- `POST /azure/...` - Azure OpenAI API转发
### 管理端点
#### OAuth和账户管理
- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL含代理
- `POST /admin/claude-accounts/exchange-code` - 交换authorization code
- `POST /admin/claude-accounts` - 创建Claude OAuth账户
- 各平台账户CRUD端点gemini、openai、bedrock、azure、droid、ccr
#### 用户管理USER_MANAGEMENT_ENABLED启用时
- `POST /users/register` - 用户注册
- `POST /users/login` - 用户登录
- `GET /users/profile` - 用户资料
- `POST /users/api-keys` - 创建用户API Key
#### Webhook管理
- `GET /admin/webhook/configs` - 获取Webhook配置
- `POST /admin/webhook/configs` - 创建Webhook配置
- `PUT /admin/webhook/configs/:id` - 更新Webhook配置
- `DELETE /admin/webhook/configs/:id` - 删除Webhook配置
- `POST /admin/claude-accounts` - 创建OAuth账户
### 系统端点
- `GET /health` - 健康检查(包含组件状态、版本、内存等)
- `GET /metrics` - 系统指标使用统计、uptime、内存
- `GET /web` - 传统Web管理界面
- `GET /admin-next/` - 新版SPA管理界面主界面
- `GET /health` - 健康检查
- `GET /web` - Web管理界面
- `GET /admin/dashboard` - 系统概览数据
## 故障排除
@@ -315,43 +138,17 @@ npm run setup # 自动生成密钥并创建管理员账户
### 常见开发问题
1. **Redis连接失败**: 确认Redis服务运行检查REDIS_HOST、REDIS_PORT、REDIS_PASSWORD配置
2. **管理员登录失败**: 检查data/init.json存在运行npm run setup重新初始化
3. **API Key格式错误**: 确保使用cr\_前缀格式可通过API_KEY_PREFIX配置修改
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息检查PROXY_USE_IPV4设置
5. **粘性会话失效**: 检查Redis中session数据确认STICKY_SESSION_TTL_HOURS配置通过Nginx代理时需添加 `underscores_in_headers on;`
6. **LDAP认证失败**:
- 检查LDAP_URL、LDAP_BIND_DN、LDAP_BIND_PASSWORD配置
- 自签名证书问题:设置 LDAP_TLS_REJECT_UNAUTHORIZED=false
- 查看日志中的LDAP连接错误详情
7. **用户管理功能不可用**: 确认USER_MANAGEMENT_ENABLED=true检查userService初始化
8. **Webhook通知失败**:
- 确认WEBHOOK_ENABLED=true
- 检查WEBHOOK_URLS格式逗号分隔
- 查看logs/webhook-*.log日志
9. **统一调度器选择账户失败**:
- 检查账户状态status: 'active'
- 确认账户类型与请求路由匹配
- 查看粘性会话绑定情况
10. **并发计数泄漏**: 系统每分钟自动清理过期并发计数concurrency cleanup task重启时也会自动清理
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计调整LRU缓存大小配置
1. **Redis连接失败**: 确认Redis服务运行检查连接配置
2. **管理员登录失败**: 检查init.json同步到Redis运行npm run setup
3. **API Key格式错误**: 确保使用cr\_前缀格式
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
### 调试工具
- **日志系统**: Winston结构化日志支持不同级别logs/目录下分类存储
- `logs/claude-relay-*.log` - 应用主日志
- `logs/token-refresh-error.log` - Token刷新错误
- `logs/webhook-*.log` - Webhook通知日志
- `logs/http-debug-*.log` - HTTP调试日志DEBUG_HTTP_TRAFFIC=true时
- **CLI工具**: 命令行状态查看和管理npm run cli
- **Web界面**: 实时日志查看和系统监控(/admin-next/
- **健康检查**: /health端点提供系统状态redis、logger、内存、版本等
- **系统指标**: /metrics端点提供详细的使用统计和性能指标
- **缓存监控**: cacheMonitor提供全局缓存统计和命中率分析
- **数据导出工具**: npm run data:export 导出Redis数据进行调试
- **Redis Key调试**: npm run data:debug 查看所有Redis键
- **日志系统**: Winston结构化日志支持不同级别
- **CLI工具**: 命令行状态查看和管理
- **Web界面**: 实时日志查看和系统监控
- **健康检查**: /health端点提供系统状态
## 开发最佳实践
@@ -400,57 +197,23 @@ npm run setup # 自动生成密钥并创建管理员账户
### 常见文件位置
- 核心服务逻辑:`src/services/` 目录30+服务文件)
- 路由处理:`src/routes/` 目录api.js、admin.js、geminiRoutes.js、openaiRoutes.js等13个路由文件
- 中间件:`src/middleware/` 目录auth.js、browserFallback.js、debugInterceptor.js等
- 配置管理:`config/config.js`(完整的多平台配置)
- 核心服务逻辑:`src/services/` 目录
- 路由处理:`src/routes/` 目录
- 中间件:`src/middleware/` 目录
- 配置管理:`config/config.js`
- Redis 模型:`src/models/redis.js`
- 工具函数:`src/utils/` 目录
- `logger.js` - 日志系统
- `oauthHelper.js` - OAuth工具
- `proxyHelper.js` - 代理工具
- `sessionHelper.js` - 会话管理
- `cacheMonitor.js` - 缓存监控
- `costCalculator.js` - 成本计算
- `rateLimitHelper.js` - 速率限制
- `webhookNotifier.js` - Webhook通知
- `tokenMask.js` - Token脱敏
- `workosOAuthHelper.js` - WorkOS OAuth
- `modelHelper.js` - 模型工具
- `inputValidator.js` - 输入验证
- CLI工具`cli/index.js` 和 `src/cli/` 目录
- 脚本目录:`scripts/` 目录
- `setup.js` - 初始化脚本
- `manage.js` - 服务管理
- `migrate-apikey-expiry.js` - API Key过期迁移
- `fix-usage-stats.js` - 使用统计修复
- `data-transfer.js` / `data-transfer-enhanced.js` - 数据导入导出
- `update-model-pricing.js` - 模型价格更新
- `test-pricing-fallback.js` - 价格回退测试
- `debug-redis-keys.js` - Redis调试
- 前端主题管理:`web/admin-spa/src/stores/theme.js`
- 前端组件:`web/admin-spa/src/components/` 目录
- 前端页面:`web/admin-spa/src/views/` 目录
- 初始化数据:`data/init.json`(管理员凭据存储)
- 日志目录:`logs/`(各类日志文件)
### 重要架构决策
- **统一调度系统**: 使用统一调度器unifiedClaudeScheduler等实现跨账户类型的智能调度支持粘性会话、负载均衡、故障转移
- **多账户类型支持**: 支持8种账户类型claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai
- **加密存储**: 所有敏感数据OAuth token、refreshToken、credentials都使用 AES 加密存储在 Redis
- **独立代理**: 每个账户支持独立的代理配置SOCKS5/HTTP包括OAuth授权流程
- **API Key哈希**: 使用SHA-256哈希存储支持自定义前缀默认 `cr_`
- **权限系统**: API Key支持细粒度权限控制all/claude/gemini/openai等
- **请求流程**: API Key验证含权限、客户端、模型黑名单 → 统一调度器选择账户 → Token刷新如需→ 请求转发 → Usage捕获 → 成本计算
- **流式响应**: 支持SSE流式响应实时捕获真实usage数据客户端断开时自动清理资源AbortController
- **粘性会话**: 基于请求内容hash的会话绑定同一会话始终使用同一账户支持自动续期
- **自动清理**: 定时清理任务过期Key、错误账户、临时错误、并发计数、速率限制状态
- **缓存优化**: 多层LRU缓存解密缓存、账户缓存全局缓存监控和统计
- **成本追踪**: 实时token使用统计input/output/cache_create/cache_read和成本计算基于pricingService
- **并发控制**: Redis Sorted Set实现的并发计数支持自动过期清理
- **客户端识别**: 基于User-Agent的客户端限制支持预定义客户端ClaudeCode、Gemini-CLI等
- **错误处理**: 529错误自动标记账户过载状态配置时长内自动排除该账户
- 所有敏感数据OAuth token、refreshToken都使用 AES 加密存储在 Redis
- 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
- API Key 使用哈希存储,支持 `cr_` 前缀格式
- 请求流程API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发
- 支持流式和非流式响应,客户端断开时自动清理资源
### 核心数据流和性能优化
@@ -472,107 +235,36 @@ npm run setup # 自动生成密钥并创建管理员账户
### Redis 数据结构
- **API Keys**:
- `api_key:{id}` - API Key详细信息含权限、客户端限制、模型黑名单等
- `api_key_hash:{hash}` - 哈希到ID的快速映射
- `api_key_usage:{keyId}` - 使用统计数据
- `api_key_cost:{keyId}` - 成本统计数据
- **账户数据**(多类型):
- `claude_account:{id}` - Claude官方账户加密的OAuth数据
- `claude_console_account:{id}` - Claude Console账户
- `gemini_account:{id}` - Gemini账户
- `openai_responses_account:{id}` - OpenAI Responses账户
- `bedrock_account:{id}` - AWS Bedrock账户
- `azure_openai_account:{id}` - Azure OpenAI账户
- `droid_account:{id}` - Droid账户
- `ccr_account:{id}` - CCR账户
- **用户管理**:
- `user:{id}` - 用户信息
- `user_email:{email}` - 邮箱到用户ID映射
- `user_session:{token}` - 用户会话
- **管理员**:
- `admin:{id}` - 管理员信息
- `admin_username:{username}` - 用户名映射
- `admin_credentials` - 管理员凭据从data/init.json同步
- **会话管理**:
- `session:{token}` - JWT会话管理
- `sticky_session:{sessionHash}` - 粘性会话账户绑定
- `session_window:{accountId}` - 账户会话窗口
- **使用统计**:
- `usage:daily:{date}:{key}:{model}` - 按日期、Key、模型的使用统计
- `usage:account:{accountId}:{date}` - 按账户的使用统计
- `usage:global:{date}` - 全局使用统计
- **速率限制**:
- `rate_limit:{keyId}:{window}` - 速率限制计数器
- `rate_limit_state:{accountId}` - 账户限流状态
- `overload:{accountId}` - 账户过载状态529错误
- **并发控制**:
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **系统信息**:
- `system_info` - 系统状态缓存
- `model_pricing` - 模型价格数据pricingService
- **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找)
- **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据)
- **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射)
- **会话**: `session:{token}` (JWT 会话管理)
- **使用统计**: `usage:daily:{date}:{key}:{model}` (多维度统计)
- **系统信息**: `system_info` (系统状态缓存)
### 流式响应处理
- 支持 SSE (Server-Sent Events) 流式传输,实时推送响应数据
- 自动从SSE流中解析真实usage数据input/output/cache_create/cache_read tokens
- 客户端断开时通过 AbortController 清理资源和并发计数
- 错误时发送适当的 SSE 错误事件(带时间戳和错误类型)
- 支持大文件流式传输REQUEST_TIMEOUT配置超时时间
- 禁用Nagle算法确保数据立即发送socket.setNoDelay
- 设置 `X-Accel-Buffering: no` 禁用Nginx缓冲
- 支持 SSE (Server-Sent Events) 流式传输
- 自动从流中解析 usage 数据并记录
- 客户端断开时通过 AbortController 清理资源
- 错误时发送适当的 SSE 错误事件
### CLI 工具使用示例
```bash
# API Key管理
# 创建新的 API Key
npm run cli keys create -- --name "MyApp" --limit 1000
npm run cli keys list
npm run cli keys delete -- --id <keyId>
npm run cli keys update -- --id <keyId> --limit 2000
# 系统状态查看
npm run cli status # 查看系统概况
npm run status # 统一状态脚本
npm run status:detail # 详细状态
# 查看系统状态
npm run cli status
# Claude账户管理
# 管理 Claude 账户
npm run cli accounts list
npm run cli accounts refresh <accountId>
npm run cli accounts add -- --name "Account1"
# Gemini账户管理
npm run cli gemini list
npm run cli gemini add -- --name "Gemini1"
# 管理员操作
npm run cli admin create -- --username admin2
npm run cli admin reset-password -- --username admin
npm run cli admin list
# 数据管理
npm run data:export # 导出Redis数据
npm run data:export:sanitized # 导出脱敏数据
npm run data:export:enhanced # 增强导出(含解密)
npm run data:export:encrypted # 导出加密数据
npm run data:import # 导入数据
npm run data:import:enhanced # 增强导入
npm run data:debug # 调试Redis键
# 数据迁移和修复
npm run migrate:apikey-expiry # API Key过期时间迁移
npm run migrate:apikey-expiry:dry # 干跑模式
npm run migrate:fix-usage-stats # 修复使用统计
# 成本和定价
npm run init:costs # 初始化成本数据
npm run update:pricing # 更新模型价格
npm run test:pricing-fallback # 测试价格回退
# 监控
npm run monitor # 增强监控脚本
```
# important-instruction-reminders
@@ -581,4 +273,3 @@ Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one.
NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
````

View File

@@ -1,7 +1,7 @@
# Claude Relay Service Makefile
# 功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台
.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status ci-release-trigger
.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status
# 默认目标:显示帮助信息
help:
@@ -185,10 +185,6 @@ quick-daemon: setup service-daemon
@echo "运行 'make service-status' 查看状态"
@echo "运行 'make logs-follow' 查看实时日志"
# CI 触发占位目标:用于在不影响功能的情况下触发自动发布
ci-release-trigger:
@echo "⚙️ 触发自动发布流水线的占位目标,避免引入功能变更"
# 全栈开发环境
dev-full: install install-web build-web setup dev
@echo "🚀 全栈开发环境启动!"

400
README.md
View File

@@ -11,23 +11,16 @@
**🔐 自行搭建Claude API中转服务支持多账户管理**
[English](README_EN.md) • [快速开始](https://pincc.ai/) • [演示站点](https://demo.pincc.ai/admin-next/login) • [公告频道](https://t.me/claude_relay_service)
[English](#english) • [中文文档](#中文文档) • [📸 界面预览](docs/preview.md) • [📢 公告频道](https://t.me/claude_relay_service)
</div>
---
## 💎 Claude/Codex 拼车服务推荐
## ⭐ 如果觉得有用点个Star支持一下吧
<div align="center">
| 平台 | 类型 | 服务 | 介绍 |
|:---|:---|:---|:---|
| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | <small>✅ Claude Code<br>✅ Codex CLI</small> | 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 |
| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | <small>✅ Claude Code<br>✅ Codex CLI</small> | 社区认证,提供 Claude Code / Codex CLI 拼车 |
</div>
> 开源不易你的Star是我持续更新的动力 🚀
> 欢迎加入 [Telegram 公告频道](https://t.me/claude_relay_service) 获取最新动态
---
@@ -49,14 +42,27 @@
如果有以上困惑,那这个项目可能适合你。
> 💡 **热心网友福利**
> 热心网友正在用本项目正在拼车官方Claude Code Max 20X 200刀版本是现在最稳定的方案。
> 有需要自取: [https://ctok.ai/](https://ctok.ai/)
### 适合的场景
**找朋友拼车**: 三五好友一起分摊Claude Code Max订阅
**找朋友拼车**: 三五好友一起分摊Claude Code Max订阅Opus爽用
**隐私敏感**: 不想让第三方镜像看到你的对话内容
**技术折腾**: 有基本的技术基础,愿意自己搭建和维护
**稳定需求**: 需要长期稳定的Claude访问不想受制于镜像站
**地区受限**: 无法直接访问Claude官方服务
### 不适合的场景
**纯小白**: 完全不懂技术,连服务器都不会买
**偶尔使用**: 一个月用不了几次,没必要折腾
**注册问题**: 无法自行注册Claude账号
**支付问题**: 没有支付渠道订阅Claude Code
**如果你只是普通用户,对隐私要求不高,随便玩玩、想快速体验 Claude那选个你熟知的镜像站会更合适。**
---
## 💭 为什么要自己搭?
@@ -78,6 +84,8 @@
## 🚀 核心功能
> 📸 **[点击查看界面预览](docs/preview.md)** - 查看Web管理界面的详细截图
### 基础功能
-**多账户管理**: 可以添加多个Claude账户自动轮换
@@ -126,7 +134,13 @@
### 快速安装
```bash
curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install
# 下载并运行管理脚本
curl -fsSL https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/main/scripts/manage.sh -o manage.sh
chmod +x manage.sh
./manage.sh install
# 安装后可以使用 crs 命令管理服务
crs # 显示交互式菜单
```
### 脚本功能
@@ -284,15 +298,60 @@ npm run service:status
## 🐳 Docker 部署
### Docker compose
### 使用 Docker Hub 镜像(最简单)
#### 第一步下载构建docker-compose.yml文件的脚本并执行
```bash
curl -fsSL https://pincc.ai/crs-compose.sh -o crs-compose.sh && chmod +x crs-compose.sh && ./crs-compose.sh
```
> 🚀 使用官方镜像,自动构建,始终保持最新版本
#### 第二步:启动
```bash
# 拉取镜像(支持 amd64 和 arm64
docker pull weishaw/claude-relay-service:latest
# 使用 docker-compose
# 创建 .env 文件用于 docker-compose 的环境变量:
cat > .env << 'EOF'
# 必填:安全密钥(请修改为随机值)
JWT_SECRET=your-random-secret-key-at-least-32-chars
ENCRYPTION_KEY=your-32-character-encryption-key
# 可选:管理员凭据
ADMIN_USERNAME=cr_admin
ADMIN_PASSWORD=your-secure-password
EOF
# 创建 docker-compose.yml 文件:
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
claude-relay:
image: weishaw/claude-relay-service:latest
container_name: claude-relay-service
restart: unless-stopped
ports:
- "3000:3000"
environment:
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- REDIS_HOST=redis
- ADMIN_USERNAME=${ADMIN_USERNAME:-}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
volumes:
- ./logs:/app/logs
- ./data:/app/data
depends_on:
- redis
redis:
image: redis:7-alpine
container_name: claude-relay-redis
restart: unless-stopped
volumes:
- redis_data:/data
volumes:
redis_data:
EOF
# 启动服务
docker-compose up -d
```
@@ -305,6 +364,7 @@ docker-compose.yml 已包含:
- ✅ Redis数据库
- ✅ 健康检查
- ✅ 自动重启
- ✅ 所有配置通过环境变量管理
### 环境变量说明
@@ -389,52 +449,19 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:**
默认使用标准 Claude 账号池:
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
```json
{
"primaryApiKey": "crs"
}
```
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
**Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问**
```bash
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥"
GOOGLE_GENAI_USE_GCA="true"
GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填 gemini-3-pro-preview
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可
export GOOGLE_GENAI_USE_GCA="true"
```
> **认证**:只能选 ```Login with Google``` 进行认证,如果跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。
> **注意**gemini-cli 控制台会提示 `Failed to fetch user info: 401 Unauthorized`,但使用不受任何影响。
**方式二:通过 Gemini API 方式访问**
```bash
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
GEMINI_API_KEY="后台创建的API密钥"
GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填 gemini-3-pro-preview
```
> **认证**:只能选 ```Use Gemini API Key``` 进行认证,如果提示 ```Enter Gemini API Key``` 请直接留空按回车。如果一打开就跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。
> 💡 **进阶用法**:想在 Claude Code 中直接使用 Gemini 3 模型?请参考 [Claude Code 调用 Gemini 3 模型指南](docs/claude-code-gemini3-guide/README.md)
**使用 Claude Code**
```bash
@@ -449,11 +476,11 @@ gemini # 或其他 Gemini CLI 命令
**Codex 配置:**
在 `~/.codex/config.toml` 文件**开头**添加以下配置:
在 `~/.codex/config.toml` 文件添加以下配置:
```toml
model_provider = "crs"
model = "gpt-5.1-codex-max"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
preferred_auth_method = "apikey"
@@ -462,71 +489,16 @@ preferred_auth_method = "apikey"
name = "crs"
base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名
wire_api = "responses"
requires_openai_auth = true
env_key = "CRS_OAI_KEY"
```
在 `~/.codex/auth.json` 文件中配置API密钥为 null
在 `~/.codex/auth.json` 文件中配置API密钥
```json
{
"OPENAI_API_KEY": null
"OPENAI_API_KEY": "你的后台创建的API密钥"
}
```
环境变量设置:
```bash
export CRS_OAI_KEY="后台创建的API密钥"
```
> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id一旦该头被丢弃多账号环境下的粘性会话功能将失效。
**Droid CLI 配置:**
Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点:
```json
{
"custom_models": [
{
"model_display_name": "Opus 4.5 [crs]",
"model": "claude-opus-4-5-20251101",
"base_url": "http://127.0.0.1:3000/droid/claude",
"api_key": "后台创建的API密钥",
"provider": "anthropic",
"max_tokens": 64000
},
{
"model_display_name": "GPT5-Codex [crs]",
"model": "gpt-5-codex",
"base_url": "http://127.0.0.1:3000/droid/openai",
"api_key": "后台创建的API密钥",
"provider": "openai",
"max_tokens": 16384
},
{
"model_display_name": "Gemini-3-Pro [crs]",
"model": "gemini-3-pro-preview",
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
"api_key": "后台创建的API密钥",
"provider": "generic-chat-completion-api",
"max_tokens": 65535
},
{
"model_display_name": "GLM-4.6 [crs]",
"model": "glm-4.6",
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
"api_key": "后台创建的API密钥",
"provider": "generic-chat-completion-api",
"max_tokens": 202800
}
]
}
```
> 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥cr_ 开头)。
### 5. 第三方工具API接入
本服务支持多种API端点格式方便接入不同的第三方工具如Cherry Studio等
@@ -539,23 +511,23 @@ Cherry Studio支持多种AI服务的接入下面是不同账号类型的详
```
# API地址
http://你的服务器:3000/claude
http://你的服务器:3000/claude/
# 模型ID示例
claude-sonnet-4-5-20250929 # Claude Sonnet 4.5
claude-sonnet-4-20250514 # Claude Sonnet 4
claude-opus-4-20250514 # Claude Opus 4
```
配置步骤:
- 供应商类型选择"Anthropic"
- API地址填入`http://你的服务器:3000/claude`
- API地址填入`http://你的服务器:3000/claude/`
- API Key填入后台创建的API密钥cr_开头
**2. Gemini账号接入**
```
# API地址
http://你的服务器:3000/gemini
http://你的服务器:3000/gemini/
# 模型ID示例
gemini-2.5-pro # Gemini 2.5 Pro
@@ -563,14 +535,14 @@ gemini-2.5-pro # Gemini 2.5 Pro
配置步骤:
- 供应商类型选择"Gemini"
- API地址填入`http://你的服务器:3000/gemini`
- API地址填入`http://你的服务器:3000/gemini/`
- API Key填入后台创建的API密钥cr_开头
**3. Codex接入**
```
# API地址
http://你的服务器:3000/openai
http://你的服务器:3000/openai/
# 模型ID固定
gpt-5 # Codex使用固定模型ID
@@ -578,18 +550,10 @@ gpt-5 # Codex使用固定模型ID
配置步骤:
- 供应商类型选择"Openai-Response"
- API地址填入`http://你的服务器:3000/openai`
- API地址填入`http://你的服务器:3000/openai/`
- API Key填入后台创建的API密钥cr_开头
- **重要**Codex只支持Openai-Response标准
**Cherry Studio 地址格式重要说明:**
- ✅ **推荐格式**`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1
- ✅ **等效格式**`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`
- 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的
- ❌ **错误格式**`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本)
#### 其他第三方工具接入
**接入要点:**
@@ -597,10 +561,8 @@ gpt-5 # Codex使用固定模型ID
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池只建议api调用或Droid Cli中使用
- 支持所有标准API端点messages、models等
**重要说明:**
@@ -693,6 +655,23 @@ npm run service:status
- 客户端验证失败时会返回403错误并记录详细信息
- 通过日志可以查看实际的User-Agent格式方便配置自定义客户端
### 自定义客户端配置
如需添加自定义客户端,可以修改 `config/config.js` 文件:
```javascript
clientRestrictions: {
predefinedClients: [
// ... 现有客户端配置
{
id: 'my_custom_client',
name: 'My Custom Client',
description: '我的自定义客户端',
userAgentPattern: /^MyClient\/[\d\.]+/i
}
]
}
```
### 日志示例
@@ -739,15 +718,11 @@ redis-cli ping
## 🛠️ 进阶
### 反向代理部署指南
### 生产环境部署建议(重要!)
在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。
**强烈建议使用Caddy反向代理自动HTTPS**
---
## Caddy 方案
Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。
建议使用Caddy作为反向代理它会自动申请和更新SSL证书配置更简单
**1. 安装Caddy**
@@ -765,15 +740,15 @@ sudo yum copr enable @caddy/caddy
sudo yum install caddy
```
**2. Caddy 配置**
**2. Caddy配置(超简单!)**
编辑 `/etc/caddy/Caddyfile`
```caddy
```
your-domain.com {
# 反向代理到本地服务
reverse_proxy 127.0.0.1:3000 {
# 支持流式响应SSE
# 支持流式响应SSE
flush_interval -1
# 传递真实IP
@@ -781,7 +756,7 @@ your-domain.com {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# 长读/写超时配置
# 超时设置(适合长连接)
transport http {
read_timeout 300s
write_timeout 300s
@@ -802,129 +777,39 @@ your-domain.com {
**3. 启动Caddy**
```bash
# 测试配置
sudo caddy validate --config /etc/caddy/Caddyfile
# 启动服务
sudo systemctl start caddy
sudo systemctl enable caddy
# 查看状态
sudo systemctl status caddy
```
**4. 服务配置**
**4. 更新服务配置**
Caddy 会自动管理 HTTPS因此可以将服务限制在本地进行监听
修改你的服务配置,让它只监听本地
```javascript
// config/config.js
module.exports = {
server: {
port: 3000,
host: '127.0.0.1' // 只监听本地
host: '127.0.0.1' // 只监听本地通过nginx代理
}
// ... 其他配置
}
```
**Caddy 特点**
**Caddy优势:**
* 🔒 自动 HTTPS零配置证书管理
* 🛡️ 安全默认配置,启用现代 TLS 套件
* ⚡ HTTP/2 和流式传输支持
* 🔧 配置文件简洁,易于维护
---
## Nginx Proxy Manager (NPM) 方案
Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。
**1. 在 NPM 创建新的 Proxy Host**
Details 配置如下:
| 项目 | 设置 |
| --------------------- | ----------------------- |
| Domain Names | relay.example.com |
| Scheme | http |
| Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) |
| Forward Port | 3000 |
| Block Common Exploits | ☑️ |
| Websockets Support | ❌ **关闭** |
| Cache Assets | ❌ **关闭** |
| Access List | Publicly Accessible |
> 注意:
> - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。
> - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。
**2. Custom locations**
無需添加任何内容,保持为空。
**3. SSL 设置**
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书
* ☑️ **Force SSL**
* ☑️ **HTTP/2 Support**
* ☑️ **HSTS Enabled**
* ☑️ **HSTS Subdomains**
**4. Advanced 配置**
Custom Nginx Configuration 中添加以下内容:
```nginx
# 传递真实用户 IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 支持 WebSocket / SSE 等流式通信
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
# 长连接 / 超时设置(适合 AI 聊天流式传输)
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 30s;
# ---- 安全性设置 ----
# 严格 HTTPS 策略 (HSTS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 阻挡点击劫持与内容嗅探
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Referrer / Permissions 限制策略
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# 隐藏服务器信息(等效于 Caddy 的 `-Server`
proxy_hide_header Server;
# ---- 性能微调 ----
# 关闭代理端缓存确保即时响应SSE / Streaming
proxy_cache_bypass $http_upgrade;
proxy_no_cache $http_upgrade;
proxy_request_buffering off;
```
**4. 启动和验证**
* 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。
* Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。
* 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。
**NPM 特点**
* 🔒 自动申请和续期证书
* 🔧 图形化界面,方便管理多服务
* ⚡ 原生支持 HTTP/2 / HTTPS
* 🚀 适合 Docker 容器部署
---
上述两种方案均可用于生产部署。
- 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置
- 🛡️ **安全默认**: 默认启用现代安全协议和加密套件
- 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输
- 📊 **简单配置**: 配置文件极其简洁,易于维护
- ⚡ **HTTP/2**: 默认启用HTTP/2提升传输性能
---
@@ -962,27 +847,6 @@ proxy_request_buffering off;
---
## ❤️ 赞助支持
如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力!
<div align="center">
<a href="https://afdian.com/a/claude-relay-service" target="_blank">
<img src="https://img.shields.io/badge/请我喝杯咖啡-爱发电-946ce6?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white" alt="Sponsor">
</a>
<table>
<tr>
<td><img src="docs/sponsoring/wechat.jpg" width="200" alt="wechat" /></td>
<td><img src="docs/sponsoring/alipay.jpg" width="200" alt="alipay" /></td>
</tr>
</table>
</div>
---
## 📄 许可证
本项目采用 [MIT许可证](LICENSE)。

View File

@@ -9,7 +9,7 @@
**🔐 Self-hosted Claude API relay service with multi-account management**
[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
[English](#english) • [中文文档](#中文文档) • [📸 Interface Preview](docs/preview.md) • [📢 Telegram Channel](https://t.me/claude_relay_service)
</div>
@@ -30,6 +30,17 @@
📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project.
---
> 💡 **Thanks to [@vista8](https://x.com/vista8) for the recommendation!**
>
> If you're interested in Vibe coding, follow:
>
> - 🐦 **X**: [@vista8](https://x.com/vista8) - Sharing cutting-edge tech trends
> - 📱 **WeChat**: 向阳乔木推荐看
---
## 🤔 Is This Project Right for You?
- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region?
@@ -232,68 +243,21 @@ Assign a key to each user:
4. Set usage limits (optional)
5. Save, note down the generated key
### 4. Start Using Claude Code and Gemini CLI
### 4. Start Using Claude Code
Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:**
Default uses standard Claude account pool:
**Set environment variables:**
```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 according to actual situation
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
```
**VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
```json
{
"primaryApiKey": "crs"
}
```
If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`.
**Gemini CLI Set Environment Variables:**
**Method 1 (Recommended): Via Gemini Assist API**
Each account enjoys 1000 requests per day, 60 requests per minute free quota.
```bash
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend"
GOOGLE_GENAI_USE_GCA="true"
GEMINI_MODEL="gemini-2.5-pro"
```
> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage.
**Method 2: Via Gemini API**
Very limited free quota, easily triggers 429 errors.
```bash
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
GEMINI_API_KEY="API key created in the backend"
GEMINI_MODEL="gemini-2.5-pro"
```
**Use Claude Code:**
**Use claude:**
```bash
claude
```
**Use Gemini CLI:**
```bash
gemini
```
---
## 🔧 Daily Maintenance
@@ -374,18 +338,13 @@ redis-cli ping
## 🛠️ Advanced Usage
### Reverse Proxy Deployment Guide
### Production Deployment Recommendations (Important!)
For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**.
**Strongly recommend using Caddy reverse proxy (Automatic HTTPS)**
---
## Caddy Solution
Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
Recommend using Caddy as reverse proxy, it will automatically apply and renew SSL certificates with simpler configuration:
**1. Install Caddy**
```bash
# Ubuntu/Debian
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
@@ -400,15 +359,14 @@ sudo yum copr enable @caddy/caddy
sudo yum install caddy
```
**2. Caddy Configuration**
**2. Caddy Configuration (Super Simple!)**
Edit `/etc/caddy/Caddyfile`:
```caddy
```
your-domain.com {
# Reverse proxy to local service
reverse_proxy 127.0.0.1:3000 {
# Support streaming responses or SSE
# Support streaming responses (SSE)
flush_interval -1
# Pass real IP
@@ -416,7 +374,7 @@ your-domain.com {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Long read/write timeout configuration
# Timeout settings (suitable for long connections)
transport http {
read_timeout 300s
write_timeout 300s
@@ -435,131 +393,38 @@ your-domain.com {
```
**3. Start Caddy**
```bash
# Test configuration
sudo caddy validate --config /etc/caddy/Caddyfile
# Start service
sudo systemctl start caddy
sudo systemctl enable caddy
# Check status
sudo systemctl status caddy
```
**4. Service Configuration**
Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
**4. Update service configuration**
Modify your service configuration to listen only locally:
```javascript
// config/config.js
module.exports = {
server: {
port: 3000,
host: '127.0.0.1' // Listen locally only
host: '127.0.0.1' // Listen only locally, proxy through nginx
}
// ... other configurations
}
```
**Caddy Features**
* 🔒 Automatic HTTPS with zero-configuration certificate management
* 🛡️ Secure default configuration with modern TLS suites
* ⚡ HTTP/2 and streaming support
* 🔧 Concise configuration files, easy to maintain
---
## Nginx Proxy Manager (NPM) Solution
Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
**1. Create a New Proxy Host in NPM**
Configure the Details as follows:
| Item | Setting |
| --------------------- | ------------------------ |
| Domain Names | relay.example.com |
| Scheme | http |
| Forward Hostname / IP | 192.168.0.1 (docker host IP) |
| Forward Port | 3000 |
| Block Common Exploits | ☑️ |
| Websockets Support | ❌ **Disable** |
| Cache Assets | ❌ **Disable** |
| Access List | Publicly Accessible |
> Note:
> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections.
> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail.
**2. Custom locations**
No content needed, keep it empty.
**3. SSL Settings**
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate
* ☑️ **Force SSL**
* ☑️ **HTTP/2 Support**
* ☑️ **HSTS Enabled**
* ☑️ **HSTS Subdomains**
**4. Advanced Configuration**
Add the following to Custom Nginx Configuration:
```nginx
# Pass real user IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Support WebSocket / SSE streaming
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
# Long connection / timeout settings (for AI chat streaming)
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 30s;
# ---- Security Settings ----
# Strict HTTPS policy (HSTS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Block clickjacking and content sniffing
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Referrer / Permissions restriction policies
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Hide server information (equivalent to Caddy's `-Server`)
proxy_hide_header Server;
# ---- Performance Tuning ----
# Disable proxy caching for real-time responses (SSE / Streaming)
proxy_cache_bypass $http_upgrade;
proxy_no_cache $http_upgrade;
proxy_request_buffering off;
```
**5. Launch and Verify**
* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable).
* Check Proxy Host status in Dashboard to ensure it shows "Online".
* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly.
**NPM Features**
* 🔒 Automatic certificate application and renewal
* 🔧 Graphical interface for easy multi-service management
* ⚡ Native HTTP/2 / HTTPS support
* 🚀 Ideal for Docker container deployments
---
Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**.
**Caddy Advantages:**
- 🔒 **Automatic HTTPS**: Automatically apply and renew Let's Encrypt certificates, zero configuration
- 🛡️ **Secure by Default**: Modern security protocols and cipher suites enabled by default
- 🚀 **Streaming Support**: Native support for SSE/WebSocket streaming
- 📊 **Simple Configuration**: Extremely concise configuration files, easy to maintain
-**HTTP/2**: HTTP/2 enabled by default for improved performance
---

View File

@@ -1 +1 @@
1.1.218
1.1.137

View File

@@ -73,30 +73,6 @@ const config = {
proxy: {
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
// 连接池与 Keep-Alive 配置(默认关闭,需要显式开启)
keepAlive: (() => {
if (process.env.PROXY_KEEP_ALIVE === undefined || process.env.PROXY_KEEP_ALIVE === '') {
return false
}
return process.env.PROXY_KEEP_ALIVE === 'true'
})(),
maxSockets: (() => {
if (process.env.PROXY_MAX_SOCKETS === undefined || process.env.PROXY_MAX_SOCKETS === '') {
return undefined
}
const parsed = parseInt(process.env.PROXY_MAX_SOCKETS)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
})(),
maxFreeSockets: (() => {
if (
process.env.PROXY_MAX_FREE_SOCKETS === undefined ||
process.env.PROXY_MAX_FREE_SOCKETS === ''
) {
return undefined
}
const parsed = parseInt(process.env.PROXY_MAX_FREE_SOCKETS)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
})(),
// IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true只有明确设置为 'false' 才使用 IPv6
},
@@ -137,6 +113,38 @@ const config = {
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
},
// 🔒 客户端限制配置
clientRestrictions: {
// 预定义的客户端列表
predefinedClients: [
{
id: 'claude_code',
name: 'ClaudeCode',
description: 'Official Claude Code CLI',
// 匹配 Claude CLI 的 User-Agent
// 示例: claude-cli/1.0.58 (external, cli)
userAgentPattern: /^claude-cli\/[\d.]+\s+\(/i
},
{
id: 'gemini_cli',
name: 'Gemini-CLI',
description: 'Gemini Command Line Interface',
// 匹配 GeminiCLI 的 User-Agent
// 示例: GeminiCLI/v18.20.8 (darwin; arm64)
userAgentPattern: /^GeminiCLI\/v?[\d.]+\s+\(/i
}
// 添加自定义客户端示例:
// {
// id: 'custom_client',
// name: 'My Custom Client',
// description: 'My custom API client',
// userAgentPattern: /^MyClient\/[\d\.]+/i
// }
],
// 是否允许自定义客户端(未来功能)
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
},
// 🔐 LDAP 认证配置
ldap: {
enabled: process.env.LDAP_ENABLED === 'true',

View File

@@ -1,17 +0,0 @@
const repository =
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
: `https://raw.githubusercontent.com/${repository}/${branch}`
module.exports = {
pricingFileName,
hashFileName,
pricingUrl:
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
}

View File

@@ -46,10 +46,6 @@ services:
# 🌐 代理配置
- DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000}
- MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3}
- PROXY_USE_IPV4=${PROXY_USE_IPV4:-true}
- PROXY_KEEP_ALIVE=${PROXY_KEEP_ALIVE:-}
- PROXY_MAX_SOCKETS=${PROXY_MAX_SOCKETS:-}
- PROXY_MAX_FREE_SOCKETS=${PROXY_MAX_FREE_SOCKETS:-}
# 📈 使用限制
- DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000}

View File

@@ -1,240 +0,0 @@
# Claude Code 调用 Gemini 3 模型指南
本文档介绍如何通过 **claude-code-router (CCR)** 在 Claude Code 中调用 Gemini 3 模型,其他模型也可以参照此教程尝试。
---
## 概述
通过 CCR 转换格式,你可以让 Claude Code 客户端无缝使用 Gemini 3 模型。
### 工作原理
```
Claude Code → CCR (模型路由) → CRS (账户调度) → Gemini API
```
---
## 第一步:安装 claude-code-router
安装 CCR
> **安装位置建议**
> - 如果只是本地使用,可以只安装到使用 Claude Code 的电脑上
> - 如果需要 CRS 项目接入 CCR建议安装在与 CRS 同一台服务器上
```bash
npm install -g @musistudio/claude-code-router
```
验证安装:
```bash
ccr -v
```
---
## 第二步:配置 CCR
创建或编辑 CCR 配置文件(通常位于 `~/.claude-code-router/config.json`
```json
{
"APIKEY": "sk-c0e7fed7b-这里随便你自定义",
"LOG": true,
"HOST": "127.0.0.1",
"API_TIMEOUT_MS": 600000,
"NON_INTERACTIVE_MODE": false,
"Providers": [
{
"name": "gemini",
"api_base_url": "http://127.0.0.1:3000/gemini/v1beta/models/",
"api_key": "cr_xxxxxxxxxxxxxxxxxxxxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"],
"transformer": {
"use": ["gemini"]
}
}
],
"Router": {
"default": "gemini",
"background": "gemini,gemini-3-pro-preview",
"think": "gemini,gemini-3-pro-preview",
"longContext": "gemini,gemini-3-pro-preview",
"longContextThreshold": 60000,
"webSearch": "gemini,gemini-2.5-flash"
}
}
```
### 配置说明
| 字段 | 说明 |
|------|------|
| `APIKEY` | CCR 自定义的 API KeyClaude Code 将使用这个 Key 访问 CCR |
| `api_base_url` | CRS 服务的 Gemini API 地址 |
| `api_key` | CRS 后台创建的 API Keycr_ 开头),用于调度 OAuth、Gemini-API 账号 |
---
## 第三步:在 CRS 中配置 Gemini 账号
确保你的 CRS 服务已添加 Gemini 账号:
1. 登录 CRS 管理界面
2. 进入「Gemini 账户」页面
3. 添加 Gemini OAuth 账号或 API Key 账号
4. 确保账号状态为「活跃」
---
## 第四步:启动 CCR 服务
保存配置后,启动 CCR 服务:
```bash
ccr start
```
查看服务状态:
```bash
ccr status
```
输出示例:
```
API Endpoint: http://127.0.0.1:3456
```
**重要**:每次修改配置后,需要重启 CCR 服务才能生效:
```bash
ccr restart
```
---
## 第五步:配置 Claude Code
现在需要让 Claude Code 连接到 CCR 服务。有两种方式:
### 方式一:本地直接使用
设置环境变量让 Claude Code 直接连接 CCR
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3456/"
export ANTHROPIC_AUTH_TOKEN="sk-c0e7fed7b-你的自定义Key"
```
然后启动 Claude Code
```bash
claude
```
### 方式二:通过 CRS 统一管理(推荐)
如果你希望通过 CRS 统一管理所有用户的访问,可以在 CRS 中添加 Claude Console 类型账号来代理 CCR。
#### 1. 在 CRS 添加 Claude Console 账号
登录 CRS 管理界面,添加一个 **Claude Console** 类型的账号:
| 字段 | 值 |
|------|-----|
| 账户名称 | CCR-Gemini3或自定义名称|
| 账户类型 | Claude Console |
| API 地址 | `http://127.0.0.1:3456`CCR 服务地址)|
| API Key | `sk-c0e7fed7b-你的自定义Key`CCR 配置中的 APIKEY|
> **注意**:如果 CCR 运行在其他服务器上,请将 `127.0.0.1` 替换为实际的服务器地址配置文件中需要修改HOST参数为```0.0.0.0```。
#### 2. 配置模型映射
在 CRS 中配置模型映射,将 Claude 模型名映射到 Gemini 模型:
| Claude 模型 | 映射到 Gemini 模型 |
|-------------|-------------------|
| `claude-opus-4-1-20250805` | `gemini-3-pro-preview` |
| `claude-sonnet-4-5-20250929` | `gemini-3-pro-preview` |
| `claude-haiku-4-5-20251001` | `gemini-2.5-flash` |
**配置界面示例:**
![模型映射配置](./model-mapping.png)
> **说明**
> - Opus 和 Sonnet 映射到性能更强的 `gemini-3-pro-preview`
> - Haiku 映射到响应更快的 `gemini-2.5-flash`
#### 3. 用户使用方式
用户现在可以通过 CRS 统一入口使用 Claude Code
```bash
export ANTHROPIC_BASE_URL="http://你的CRS服务器:3000/api/"
export ANTHROPIC_AUTH_TOKEN="cr_用户的APIKey"
```
Claude Code 会自动将请求路由到 CCR再由 CCR 转发到 Gemini API。
---
## 常见问题
### Q: CCR 配置修改后没有生效?
A: 配置修改后必须重启 CCR 服务:
```bash
ccr restart
```
### Q: 连接超时怎么办?
A: 检查以下几点:
1. CRS 服务是否正常运行
2. CCR 配置中的 `api_base_url` 是否正确
3. 防火墙是否允许相应端口
4. 尝试增加 `API_TIMEOUT_MS` 的值
### Q: 模型映射不生效?
A: 确保:
1. CRS 中已正确配置 Claude Console 账号
2. 模型映射配置已保存
3. 重启 CRS 服务使配置生效
### Q: 如何测试连接?
A: 使用 curl 测试 CCR 服务:
```bash
curl -X POST http://127.0.0.1:3456/api/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: sk-c0e7fed7b-你的自定义Key" \
-d '{
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}]
}'
```
---
## 最佳实践
1. **生产环境**:将 CCR 部署在与 CRS 相同的服务器上,减少网络延迟
2. **API Key 管理**:为每个用户创建独立的 CRS API Key便于使用统计
3. **超时配置**:对于长时间运行的任务,适当增加 `API_TIMEOUT_MS`
---
## 相关资源
- [CCR 官方文档](https://github.com/musistudio/claude-code-router)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

BIN
docs/images/tutorial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

47
docs/preview.md Normal file
View File

@@ -0,0 +1,47 @@
# Claude Relay Service 界面预览
<div align="center">
**🎨 Web管理界面截图展示**
</div>
---
## 📊 管理面板概览
### 仪表板
![仪表板](./images/dashboard-overview.png)
*实时显示API调用次数、Token使用量、成本统计等关键指标*
---
## 🔑 API密钥管理
### API密钥列表
![API密钥管理](./images/api-keys-list.png)
*查看和管理所有创建的API密钥包括使用量统计和状态信息*
---
## 👤 Claude账户管理
### 账户列表
![Claude账户列表](./images/claude-accounts-list.png)
*管理多个Claude账户查看账户状态和使用情况*
### 添加新账户
![添加Claude账户](./images/add-claude-account.png)
*通过OAuth授权添加新的Claude账户*
### 使用教程
![使用教程](./images/tutorial.png)
*windows、macos、linux、wsl不同环境的claude code安装教程*
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

8
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",
"string-similarity": "^4.0.4",
"table": "^6.8.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
@@ -8426,13 +8425,6 @@
"node": ">=10"
}
},
"node_modules/string-similarity": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/string-similarity/-/string-similarity-4.0.4.tgz",
"integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "ISC"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",

View File

@@ -69,7 +69,6 @@
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",
"string-similarity": "^4.0.4",
"table": "^6.8.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",

View File

@@ -1,11 +1,11 @@
# Model Pricing Data
This directory contains a local copy of the mirrored model pricing data as a fallback mechanism.
This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism.
## Source
The original file is maintained by the LiteLLM project and mirrored into the `price-mirror` branch of this repository via GitHub Actions:
- Mirror branch (configurable via `PRICE_MIRROR_REPO`): https://raw.githubusercontent.com/<your-repo>/price-mirror/model_prices_and_context_window.json
- Upstream source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
The original file is maintained by the LiteLLM project:
- Repository: https://github.com/BerriAI/litellm
- File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
## Purpose
This local copy serves as a fallback when the remote file cannot be downloaded due to:
@@ -22,7 +22,7 @@ The pricingService will:
3. Log a warning when using the fallback file
## Manual Update
To manually update this file with the latest pricing data (if automation is unavailable):
To manually update this file with the latest pricing data:
```bash
curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json
```

File diff suppressed because it is too large Load Diff

View File

@@ -84,214 +84,16 @@ function sanitizeData(data, type) {
return sanitized
}
// CSV 字段映射配置
const CSV_FIELD_MAPPING = {
// 基本信息
id: 'ID',
name: '名称',
description: '描述',
isActive: '状态',
createdAt: '创建时间',
lastUsedAt: '最后使用时间',
createdBy: '创建者',
// API Key 信息
apiKey: 'API密钥',
tokenLimit: '令牌限制',
// 过期设置
expirationMode: '过期模式',
expiresAt: '过期时间',
activationDays: '激活天数',
activationUnit: '激活单位',
isActivated: '已激活',
activatedAt: '激活时间',
// 权限设置
permissions: '服务权限',
// 限制设置
rateLimitWindow: '速率窗口(分钟)',
rateLimitRequests: '请求次数限制',
rateLimitCost: '费用限制(美元)',
concurrencyLimit: '并发限制',
dailyCostLimit: '日费用限制(美元)',
totalCostLimit: '总费用限制(美元)',
weeklyOpusCostLimit: '周Opus费用限制(美元)',
// 账户绑定
claudeAccountId: 'Claude专属账户',
claudeConsoleAccountId: 'Claude控制台账户',
geminiAccountId: 'Gemini专属账户',
openaiAccountId: 'OpenAI专属账户',
azureOpenaiAccountId: 'Azure OpenAI专属账户',
bedrockAccountId: 'Bedrock专属账户',
// 限制配置
enableModelRestriction: '启用模型限制',
restrictedModels: '限制的模型',
enableClientRestriction: '启用客户端限制',
allowedClients: '允许的客户端',
// 标签和用户
tags: '标签',
userId: '用户ID',
userUsername: '用户名',
// 其他信息
icon: '图标'
}
// 数据格式化函数
function formatCSVValue(key, value, shouldSanitize = false) {
if (!value || value === '' || value === 'null' || value === 'undefined') {
return ''
}
switch (key) {
case 'apiKey':
if (shouldSanitize && value.length > 10) {
return `${value.substring(0, 10)}...[已脱敏]`
}
return value
case 'isActive':
case 'isActivated':
case 'enableModelRestriction':
case 'enableClientRestriction':
return value === 'true' ? '是' : '否'
case 'expirationMode':
return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
case 'activationUnit':
return value === 'hours' ? '小时' : value === 'days' ? '天' : value
case 'permissions':
switch (value) {
case 'all':
return '全部服务'
case 'claude':
return '仅Claude'
case 'gemini':
return '仅Gemini'
case 'openai':
return '仅OpenAI'
default:
return value
}
case 'restrictedModels':
case 'allowedClients':
case 'tags':
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.join('; ') : value
} catch {
return value
}
case 'createdAt':
case 'lastUsedAt':
case 'activatedAt':
case 'expiresAt':
if (value) {
try {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch {
return value
}
}
return ''
case 'rateLimitWindow':
case 'rateLimitRequests':
case 'concurrencyLimit':
case 'activationDays':
case 'tokenLimit':
return value === '0' || value === 0 ? '无限制' : value
case 'rateLimitCost':
case 'dailyCostLimit':
case 'totalCostLimit':
case 'weeklyOpusCostLimit':
return value === '0' || value === 0 ? '无限制' : `$${value}`
default:
return value
}
}
// 转义 CSV 字段
function escapeCSVField(field) {
if (field === null || field === undefined) {
return ''
}
const str = String(field)
// 如果包含逗号、引号或换行符,需要用引号包围
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
// 先转义引号(双引号变成两个双引号)
const escaped = str.replace(/"/g, '""')
return `"${escaped}"`
}
return str
}
// 转换数据为 CSV 格式
function convertToCSV(exportDataObj, shouldSanitize = false) {
if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
}
const { apiKeys } = exportDataObj.data
const fields = Object.keys(CSV_FIELD_MAPPING)
const headers = Object.values(CSV_FIELD_MAPPING)
// 生成标题行
const csvLines = [headers.map(escapeCSVField).join(',')]
// 生成数据行
for (const apiKey of apiKeys) {
const row = fields.map((field) => {
const value = formatCSVValue(field, apiKey[field], shouldSanitize)
return escapeCSVField(value)
})
csvLines.push(row.join(','))
}
return csvLines.join('\n')
}
// 导出数据
async function exportData() {
try {
const format = params.format || 'json'
const fileExtension = format === 'csv' ? '.csv' : '.json'
const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
const outputFile = params.output || defaultFileName
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`
const types = params.types ? params.types.split(',') : ['all']
const shouldSanitize = params.sanitize === true
// CSV 格式验证
if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
process.exit(1)
}
logger.info('🔄 Starting data export...')
logger.info(`📁 Output file: ${outputFile}`)
logger.info(`📋 Data types: ${types.join(', ')}`)
logger.info(`📄 Output format: ${format.toUpperCase()}`)
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
// 连接 Redis
@@ -401,16 +203,8 @@ async function exportData() {
logger.success(`✅ Exported ${admins.length} admins`)
}
// 根据格式写入文件
let fileContent
if (format === 'csv') {
fileContent = convertToCSV(exportDataObj, shouldSanitize)
// 添加 UTF-8 BOM 以便 Excel 正确识别中文
fileContent = `\ufeff${fileContent}`
await fs.writeFile(outputFile, fileContent, 'utf8')
} else {
await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
}
// 写入文件
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2))
// 显示导出摘要
console.log(`\n${'='.repeat(60)}`)
@@ -677,9 +471,8 @@ Commands:
import Import data from a JSON file to Redis
Export Options:
--output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
--format=FORMAT Output format: json,csv (default: json)
--sanitize Remove sensitive data from export
Import Options:
@@ -699,12 +492,6 @@ Examples:
# Export specific data types
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
# Export API keys to CSV format
node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
# Export to CSV with custom filename
node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
`)
}

View File

@@ -288,12 +288,12 @@ check_redis() {
# 测试Redis连接
print_info "测试 Redis 连接..."
if command_exists redis-cli; then
local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT")
local redis_test_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
if [ -n "$REDIS_PASSWORD" ]; then
redis_args+=(-a "$REDIS_PASSWORD")
redis_test_cmd="$redis_test_cmd -a '$REDIS_PASSWORD'"
fi
if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then
if $redis_test_cmd ping 2>/dev/null | grep -q "PONG"; then
print_success "Redis 连接成功"
return 0
else
@@ -363,19 +363,6 @@ check_installation() {
return 1
}
# 将安装路径持久化到本地(用于后续 update/status 自动识别自定义安装目录)
persist_install_path() {
local conf_dir="$HOME/.config/crs"
local conf_file="$conf_dir/install.conf"
mkdir -p "$conf_dir" 2>/dev/null || true
if ! { echo "INSTALL_DIR=\"$INSTALL_DIR\"" > "$conf_file" && echo "APP_DIR=\"$APP_DIR\"" >> "$conf_file"; }; then
print_warning "无法写入 $conf_file,后续 update 可能找不到安装目录"
return 1
fi
return 0
}
# 安装服务
install_service() {
print_info "开始安装 Claude Relay Service..."
@@ -753,9 +740,6 @@ update_service() {
# 更新软链接到最新版本
create_symlink
# 持久化安装路径,便于后续 update/status 自动识别
persist_install_path || true
# 如果之前在运行,则重新启动服务
if [ "$was_running" = true ]; then
print_info "重新启动服务..."
@@ -1647,88 +1631,31 @@ create_symlink() {
# 加载已安装的配置
load_config() {
# 1) 优先使用外部显式提供的 APP_DIR
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then
:
else
# 2) 若提供了 INSTALL_DIR则据此推导 APP_DIR
if [ -n "$INSTALL_DIR" ]; then
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
APP_DIR="$INSTALL_DIR/app"
elif [ -f "$INSTALL_DIR/package.json" ]; then
APP_DIR="$INSTALL_DIR"
fi
fi
# 3) 尝试从持久化配置读取安装位置
if [ -z "$APP_DIR" ]; then
local conf_file="$HOME/.config/crs/install.conf"
if [ -f "$conf_file" ]; then
local conf_install_dir
local conf_app_dir
conf_install_dir=$(awk -F= '/^INSTALL_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null)
conf_app_dir=$(awk -F= '/^APP_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null)
if [ -n "$conf_app_dir" ] && [ -f "$conf_app_dir/package.json" ]; then
APP_DIR="$conf_app_dir"
[ -z "$INSTALL_DIR" ] && INSTALL_DIR="$(cd "$conf_app_dir/.." 2>/dev/null && pwd)"
elif [ -n "$conf_install_dir" ]; then
if [ -d "$conf_install_dir/app" ] && [ -f "$conf_install_dir/app/package.json" ]; then
INSTALL_DIR="$conf_install_dir"
APP_DIR="$conf_install_dir/app"
elif [ -f "$conf_install_dir/package.json" ]; then
INSTALL_DIR="$conf_install_dir"
APP_DIR="$conf_install_dir"
fi
fi
fi
fi
# 4) 基于脚本自身路径推导(处理从 app/scripts/manage.sh 或软链调用的情形)
if [ -z "$APP_DIR" ]; then
local script_path=""
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then
script_path="$APP_DIR/scripts/manage.sh"
elif command_exists realpath; then
script_path="$(realpath "$0" 2>/dev/null)"
elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
script_path="$(readlink -f "$0")"
else
script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
fi
local script_dir="$(cd "$(dirname "$script_path")" && pwd)"
local parent_dir="$(cd "$script_dir/.." && pwd)"
if [ -f "$parent_dir/package.json" ]; then
APP_DIR="$parent_dir"
INSTALL_DIR="$(cd "$parent_dir/.." 2>/dev/null && pwd)"
elif [ -f "$parent_dir/app/package.json" ]; then
APP_DIR="$parent_dir/app"
INSTALL_DIR="$parent_dir"
fi
fi
# 5) 退回到默认目录逻辑
# 尝试找到安装目录
if [ -z "$INSTALL_DIR" ]; then
if [ -d "$DEFAULT_INSTALL_DIR" ]; then
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
fi
fi
if [ -n "$INSTALL_DIR" ] && [ -z "$APP_DIR" ]; then
if [ -n "$INSTALL_DIR" ]; then
# 检查是否使用了标准的安装结构(项目在 app 子目录)
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
APP_DIR="$INSTALL_DIR/app"
# 检查是否直接克隆了项目(项目在根目录)
elif [ -f "$INSTALL_DIR/package.json" ]; then
APP_DIR="$INSTALL_DIR"
else
APP_DIR="$INSTALL_DIR/app"
fi
fi
fi
# 6) 加载 .env 配置(如存在)
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/.env" ]; then
# 加载.env配置
if [ -f "$APP_DIR/.env" ]; then
export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs)
# 特别加载端口配置
APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2)
fi
fi
}
# 主函数

View File

@@ -1,340 +0,0 @@
#!/usr/bin/env node
/**
* 计费事件测试脚本
*
* 用于测试计费事件的发布和消费功能
*
* 使用方法:
* node scripts/test-billing-events.js [command]
*
* 命令:
* publish - 发布测试事件
* consume - 消费事件(测试模式)
* info - 查看队列状态
* clear - 清空队列(危险操作)
*/
const path = require('path')
const Redis = require('ioredis')
// 加载配置
require('dotenv').config({ path: path.join(__dirname, '../.env') })
const config = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || '',
db: parseInt(process.env.REDIS_DB) || 0
}
const redis = new Redis(config)
const STREAM_KEY = 'billing:events'
// ========================================
// 命令实现
// ========================================
/**
* 发布测试事件
*/
async function publishTestEvent() {
console.log('📤 Publishing test billing event...')
const testEvent = {
eventId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
eventType: 'usage.recorded',
timestamp: new Date().toISOString(),
version: '1.0',
apiKey: {
id: 'test-key-123',
name: 'Test API Key',
userId: 'test-user-456'
},
usage: {
model: 'claude-sonnet-4-20250514',
inputTokens: 1500,
outputTokens: 800,
cacheCreateTokens: 200,
cacheReadTokens: 100,
ephemeral5mTokens: 150,
ephemeral1hTokens: 50,
totalTokens: 2600
},
cost: {
total: 0.0156,
currency: 'USD',
breakdown: {
input: 0.0045,
output: 0.012,
cacheCreate: 0.00075,
cacheRead: 0.00003,
ephemeral5m: 0.0005625,
ephemeral1h: 0.0001875
}
},
account: {
id: 'test-account-789',
type: 'claude-official'
},
context: {
isLongContext: false,
requestTimestamp: new Date().toISOString()
}
}
try {
const messageId = await redis.xadd(
STREAM_KEY,
'MAXLEN',
'~',
100000,
'*',
'data',
JSON.stringify(testEvent)
)
console.log('✅ Event published successfully!')
console.log(` Message ID: ${messageId}`)
console.log(` Event ID: ${testEvent.eventId}`)
console.log(` Cost: $${testEvent.cost.total}`)
} catch (error) {
console.error('❌ Failed to publish event:', error.message)
process.exit(1)
}
}
/**
* 消费事件(测试模式,不创建消费者组)
*/
async function consumeTestEvents() {
console.log('📬 Consuming test events...')
console.log(' Press Ctrl+C to stop\n')
let isRunning = true
process.on('SIGINT', () => {
console.log('\n⏹ Stopping consumer...')
isRunning = false
})
let lastId = '0' // 从头开始
while (isRunning) {
try {
// 使用 XREAD 而不是 XREADGROUP测试模式
const messages = await redis.xread('BLOCK', 5000, 'COUNT', 10, 'STREAMS', STREAM_KEY, lastId)
if (!messages || messages.length === 0) {
continue
}
const [streamKey, entries] = messages[0]
console.log(`📬 Received ${entries.length} messages from ${streamKey}\n`)
for (const [messageId, fields] of entries) {
try {
const data = {}
for (let i = 0; i < fields.length; i += 2) {
data[fields[i]] = fields[i + 1]
}
const event = JSON.parse(data.data)
console.log(`📊 Event: ${event.eventId}`)
console.log(` API Key: ${event.apiKey.name} (${event.apiKey.id})`)
console.log(` Model: ${event.usage.model}`)
console.log(` Tokens: ${event.usage.totalTokens}`)
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
console.log(` Timestamp: ${event.timestamp}`)
console.log('')
lastId = messageId // 更新位置
} catch (parseError) {
console.error(`❌ Failed to parse message ${messageId}:`, parseError.message)
}
}
} catch (error) {
if (isRunning) {
console.error('❌ Error consuming messages:', error.message)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
console.log('👋 Consumer stopped')
}
/**
* 查看队列状态
*/
async function showQueueInfo() {
console.log('📊 Queue Information\n')
try {
// Stream 长度
const length = await redis.xlen(STREAM_KEY)
console.log(`Stream: ${STREAM_KEY}`)
console.log(`Length: ${length} messages\n`)
if (length === 0) {
console.log(' Queue is empty')
return
}
// Stream 详细信息
const info = await redis.xinfo('STREAM', STREAM_KEY)
const infoObj = {}
for (let i = 0; i < info.length; i += 2) {
infoObj[info[i]] = info[i + 1]
}
console.log('Stream Details:')
console.log(` First Entry ID: ${infoObj['first-entry'] ? infoObj['first-entry'][0] : 'N/A'}`)
console.log(` Last Entry ID: ${infoObj['last-entry'] ? infoObj['last-entry'][0] : 'N/A'}`)
console.log(` Consumer Groups: ${infoObj.groups || 0}\n`)
// 消费者组信息
if (infoObj.groups > 0) {
console.log('Consumer Groups:')
const groups = await redis.xinfo('GROUPS', STREAM_KEY)
for (let i = 0; i < groups.length; i++) {
const group = groups[i]
const groupObj = {}
for (let j = 0; j < group.length; j += 2) {
groupObj[group[j]] = group[j + 1]
}
console.log(`\n Group: ${groupObj.name}`)
console.log(` Consumers: ${groupObj.consumers}`)
console.log(` Pending: ${groupObj.pending}`)
console.log(` Last Delivered ID: ${groupObj['last-delivered-id']}`)
// 消费者详情
if (groupObj.consumers > 0) {
const consumers = await redis.xinfo('CONSUMERS', STREAM_KEY, groupObj.name)
console.log(' Consumer Details:')
for (let k = 0; k < consumers.length; k++) {
const consumer = consumers[k]
const consumerObj = {}
for (let l = 0; l < consumer.length; l += 2) {
consumerObj[consumer[l]] = consumer[l + 1]
}
console.log(` - ${consumerObj.name}`)
console.log(` Pending: ${consumerObj.pending}`)
console.log(` Idle: ${Math.round(consumerObj.idle / 1000)}s`)
}
}
}
}
// 最新 5 条消息
console.log('\n📬 Latest 5 Messages:')
const latest = await redis.xrevrange(STREAM_KEY, '+', '-', 'COUNT', 5)
if (latest.length === 0) {
console.log(' No messages')
} else {
for (const [messageId, fields] of latest) {
const data = {}
for (let i = 0; i < fields.length; i += 2) {
data[fields[i]] = fields[i + 1]
}
try {
const event = JSON.parse(data.data)
console.log(`\n ${messageId}`)
console.log(` Event ID: ${event.eventId}`)
console.log(` Model: ${event.usage.model}`)
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
console.log(` Time: ${event.timestamp}`)
} catch (e) {
console.log(`\n ${messageId} (Parse Error)`)
}
}
}
} catch (error) {
console.error('❌ Failed to get queue info:', error.message)
process.exit(1)
}
}
/**
* 清空队列(危险操作)
*/
async function clearQueue() {
console.log('⚠️ WARNING: This will delete all messages in the queue!')
console.log(` Stream: ${STREAM_KEY}`)
// 简单的确认机制
const readline = require('readline')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question('Type "yes" to confirm: ', async (answer) => {
if (answer.toLowerCase() === 'yes') {
try {
await redis.del(STREAM_KEY)
console.log('✅ Queue cleared successfully')
} catch (error) {
console.error('❌ Failed to clear queue:', error.message)
}
} else {
console.log('❌ Operation cancelled')
}
rl.close()
redis.quit()
})
}
// ========================================
// CLI 处理
// ========================================
async function main() {
const command = process.argv[2] || 'info'
console.log('🔧 Billing Events Test Tool\n')
try {
switch (command) {
case 'publish':
await publishTestEvent()
break
case 'consume':
await consumeTestEvents()
break
case 'info':
await showQueueInfo()
break
case 'clear':
await clearQueue()
return // clearQueue 会自己关闭连接
default:
console.error(`❌ Unknown command: ${command}`)
console.log('\nAvailable commands:')
console.log(' publish - Publish a test event')
console.log(' consume - Consume events (test mode)')
console.log(' info - Show queue status')
console.log(' clear - Clear the queue (dangerous)')
process.exit(1)
}
await redis.quit()
} catch (error) {
console.error('💥 Fatal error:', error)
await redis.quit()
process.exit(1)
}
}
main()

View File

@@ -2,14 +2,12 @@
/**
* 手动更新模型价格数据脚本
* 从价格镜像分支下载最新的模型价格和上下文窗口信息
* 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
const crypto = require('crypto')
const pricingSource = require('../config/pricingSource')
// 颜色输出
const colors = {
@@ -34,8 +32,8 @@ const log = {
const config = {
dataDir: path.join(process.cwd(), 'data'),
pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'),
hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'),
pricingUrl: pricingSource.pricingUrl,
pricingUrl:
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json',
fallbackFile: path.join(
process.cwd(),
'resources',
@@ -87,8 +85,8 @@ function restoreBackup() {
// 下载价格数据
function downloadPricingData() {
return new Promise((resolve, reject) => {
log.info('正在从价格镜像分支拉取最新的模型价格数据...')
log.info(`拉取地址: ${config.pricingUrl}`)
log.info('Downloading model pricing data from LiteLLM...')
log.info(`URL: ${config.pricingUrl}`)
const request = https.get(config.pricingUrl, (response) => {
if (response.statusCode !== 200) {
@@ -117,11 +115,7 @@ function downloadPricingData() {
}
// 保存到文件
const formattedJson = JSON.stringify(jsonData, null, 2)
fs.writeFileSync(config.pricingFile, formattedJson)
const hash = crypto.createHash('sha256').update(formattedJson).digest('hex')
fs.writeFileSync(config.hashFile, `${hash}\n`)
fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2))
const modelCount = Object.keys(jsonData).length
const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024)

View File

@@ -14,7 +14,6 @@ const cacheMonitor = require('./utils/cacheMonitor')
// Import routes
const apiRoutes = require('./routes/api')
const unifiedRoutes = require('./routes/unified')
const adminRoutes = require('./routes/admin')
const webRoutes = require('./routes/web')
const apiStatsRoutes = require('./routes/apiStats')
@@ -23,7 +22,6 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes')
const droidRoutes = require('./routes/droidRoutes')
const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook')
@@ -56,11 +54,6 @@ class Application {
logger.info('🔄 Initializing pricing service...')
await pricingService.initialize()
// 📋 初始化模型服务
logger.info('🔄 Initializing model service...')
const modelService = require('./services/modelService')
await modelService.initialize()
// 📊 初始化缓存监控
await this.initializeCacheMonitoring()
@@ -85,11 +78,6 @@ class Application {
const claudeAccountService = require('./services/claudeAccountService')
await claudeAccountService.initializeSessionWindows()
// 📊 初始化费用排序索引服务
logger.info('📊 Initializing cost rank service...')
const costRankService = require('./services/costRankService')
await costRankService.initialize()
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
this.app.use((req, res, next) => {
if (req.path === '/admin-next/' && req.method === 'GET') {
@@ -262,7 +250,6 @@ class Application {
// 🛣️ 路由
this.app.use('/api', apiRoutes)
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
this.app.use('/admin', adminRoutes)
this.app.use('/users', userRoutes)
@@ -274,10 +261,7 @@ class Application {
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions
this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses
// Droid 路由:支持多种 Factory.ai 端点
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
this.app.use('/openai', openaiRoutes)
this.app.use('/azure', azureOpenaiRoutes)
this.app.use('/admin/webhook', webhookRoutes)
@@ -569,62 +553,6 @@ class Application {
logger.info(
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
)
// 🔢 启动并发计数自动清理任务Phase 1 修复:解决并发泄漏问题)
// 每分钟主动清理所有过期的并发项,不依赖请求触发
setInterval(async () => {
try {
const keys = await redis.keys('concurrency:*')
if (keys.length === 0) {
return
}
const now = Date.now()
let totalCleaned = 0
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
try {
const cleaned = await redis.client.eval(
`
local key = KEYS[1]
local now = tonumber(ARGV[1])
-- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
-- 获取剩余计数
local count = redis.call('ZCARD', key)
-- 如果计数为0删除键
if count <= 0 then
redis.call('DEL', key)
return 1
end
return 0
`,
1,
key,
now
)
if (cleaned === 1) {
totalCleaned++
}
} catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
}
}
if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
}
} catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error)
}
}, 60000) // 每分钟执行一次
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
}
setupGracefulShutdown() {
@@ -643,15 +571,6 @@ class Application {
logger.error('❌ Error cleaning up pricing service:', error)
}
// 清理 model service 的文件监听器
try {
const modelService = require('./services/modelService')
modelService.cleanup()
logger.info('📋 Model service cleaned up')
} catch (error) {
logger.error('❌ Error cleaning up model service:', error)
}
// 停止限流清理服务
try {
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
@@ -661,30 +580,6 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
// 停止费用排序索引服务
try {
const costRankService = require('./services/costRankService')
costRankService.shutdown()
logger.info('📊 Cost rank service stopped')
} catch (error) {
logger.error('❌ Error stopping cost rank service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try {
logger.info('🔢 Cleaning up all concurrency counters...')
const keys = await redis.keys('concurrency:*')
if (keys.length > 0) {
await redis.client.del(...keys)
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
} else {
logger.info('✅ No concurrency keys to clean')
}
} catch (error) {
logger.error('❌ Error cleaning up concurrency counters:', error)
// 不阻止退出流程
}
try {
await redis.disconnect()
logger.info('👋 Redis disconnected')

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +1,9 @@
const { v4: uuidv4 } = require('uuid')
const config = require('../../config/config')
const apiKeyService = require('../services/apiKeyService')
const userService = require('../services/userService')
const logger = require('../utils/logger')
const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const ClientValidator = require('../validators/clientValidator')
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
renewIntervalSeconds: 30,
cleanupGraceSeconds: 30
}
const resolveConcurrencyConfig = () => {
if (typeof redis._getConcurrencyConfig === 'function') {
return redis._getConcurrencyConfig()
}
const raw = {
...FALLBACK_CONCURRENCY_CONFIG,
...(config.concurrency || {})
}
const toNumber = (value, fallback) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
return parsed
}
const leaseSeconds = Math.max(
toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds),
30
)
let renewIntervalSeconds
if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') {
renewIntervalSeconds = 0
} else {
renewIntervalSeconds = Math.max(
toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds),
0
)
}
const cleanupGraceSeconds = Math.max(
toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds),
0
)
return {
leaseSeconds,
renewIntervalSeconds,
cleanupGraceSeconds
}
}
const TOKEN_COUNT_PATHS = new Set([
'/v1/messages/count_tokens',
'/api/v1/messages/count_tokens',
'/claude/v1/messages/count_tokens'
])
function extractApiKey(req) {
const candidates = [
req.headers['x-api-key'],
req.headers['x-goog-api-key'],
req.headers['authorization'],
req.headers['api-key'],
req.query?.key
]
for (const candidate of candidates) {
let value = candidate
if (Array.isArray(value)) {
value = value.find((item) => typeof item === 'string' && item.trim())
}
if (typeof value !== 'string') {
continue
}
let trimmed = value.trim()
if (!trimmed) {
continue
}
if (/^Bearer\s+/i.test(trimmed)) {
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
if (!trimmed) {
continue
}
}
return trimmed
}
return ''
}
function normalizeRequestPath(value) {
if (!value) {
return '/'
}
const lower = value.split('?')[0].toLowerCase()
const collapsed = lower.replace(/\/{2,}/g, '/')
if (collapsed.length > 1 && collapsed.endsWith('/')) {
return collapsed.slice(0, -1)
}
return collapsed || '/'
}
function isTokenCountRequest(req) {
const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`)
if (TOKEN_COUNT_PATHS.has(combined)) {
return true
}
const original = normalizeRequestPath(req.originalUrl || '')
if (TOKEN_COUNT_PATHS.has(original)) {
return true
}
return false
}
const config = require('../../config/config')
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
@@ -132,18 +11,18 @@ const authenticateApiKey = async (req, res, next) => {
try {
// 安全提取API Key支持多种格式包括Gemini CLI支持
const apiKey = extractApiKey(req)
if (apiKey) {
req.headers['x-api-key'] = apiKey
}
const apiKey =
req.headers['x-api-key'] ||
req.headers['x-goog-api-key'] ||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.headers['api-key'] ||
req.query.key
if (!apiKey) {
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
return res.status(401).json({
error: 'Missing API key',
message:
'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header'
message: 'Please provide an API key in the x-api-key header or Authorization header'
})
}
@@ -168,70 +47,80 @@ const authenticateApiKey = async (req, res, next) => {
})
}
const skipKeyRestrictions = isTokenCountRequest(req)
// 🔒 检查客户端限制(使用新的验证器)
// 🔒 检查客户端限制
if (
!skipKeyRestrictions &&
validation.keyData.enableClientRestriction &&
validation.keyData.allowedClients?.length > 0
) {
// 使用新的 ClientValidator 进行验证
const validationResult = ClientValidator.validateRequest(
validation.keyData.allowedClients,
req
)
if (!validationResult.allowed) {
const userAgent = req.headers['user-agent'] || ''
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
// 记录客户端限制检查开始
logger.api(
`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`
)
logger.api(` User-Agent: "${userAgent}"`)
logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`)
let clientAllowed = false
let matchedClient = null
// 获取预定义客户端列表,如果配置不存在则使用默认值
const predefinedClients = config.clientRestrictions?.predefinedClients || []
const allowCustomClients = config.clientRestrictions?.allowCustomClients || false
// 遍历允许的客户端列表
for (const allowedClientId of validation.keyData.allowedClients) {
// 在预定义客户端列表中查找
const predefinedClient = predefinedClients.find((client) => client.id === allowedClientId)
if (predefinedClient) {
// 使用预定义的正则表达式匹配 User-Agent
if (
predefinedClient.userAgentPattern &&
predefinedClient.userAgentPattern.test(userAgent)
) {
clientAllowed = true
matchedClient = predefinedClient.name
break
}
} else if (allowCustomClients) {
// 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑
// 目前暂时跳过自定义客户端
continue
}
}
if (!clientAllowed) {
logger.security(
`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}`
`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`
)
return res.status(403).json({
error: 'Client not allowed',
message: 'Your client is not authorized to use this API key',
allowedClients: validation.keyData.allowedClients,
userAgent: validationResult.userAgent
allowedClients: validation.keyData.allowedClients
})
}
// 验证通过
logger.api(
`✅ Client validated: ${validationResult.clientName} (${validationResult.matchedClient}) for key: ${validation.keyData.id} (${validation.keyData.name})`
`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`
)
logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`)
}
// 检查并发限制
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
if (!skipKeyRestrictions && concurrencyLimit > 0) {
const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } =
resolveConcurrencyConfig()
const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30)
let renewIntervalSeconds = configRenewIntervalSeconds
if (renewIntervalSeconds > 0) {
const maxSafeRenew = Math.max(leaseSeconds - 5, 15)
renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew)
} else {
renewIntervalSeconds = 0
}
const requestId = uuidv4()
const currentConcurrency = await redis.incrConcurrency(
validation.keyData.id,
requestId,
leaseSeconds
)
if (concurrencyLimit > 0) {
const currentConcurrency = await redis.incrConcurrency(validation.keyData.id)
logger.api(
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
)
if (currentConcurrency > concurrencyLimit) {
// 如果超过限制,立即减少计数
await redis.decrConcurrency(validation.keyData.id, requestId)
await redis.decrConcurrency(validation.keyData.id)
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
)
return res.status(429).json({
error: 'Concurrency limit exceeded',
@@ -241,39 +130,14 @@ const authenticateApiKey = async (req, res, next) => {
})
}
const renewIntervalMs =
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0
// 使用标志位确保只减少一次
let concurrencyDecremented = false
let leaseRenewInterval = null
if (renewIntervalMs > 0) {
leaseRenewInterval = setInterval(() => {
redis
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
.catch((error) => {
logger.error(
`Failed to refresh concurrency lease for key ${validation.keyData.id}:`,
error
)
})
}, renewIntervalMs)
if (typeof leaseRenewInterval.unref === 'function') {
leaseRenewInterval.unref()
}
}
const decrementConcurrency = async () => {
if (!concurrencyDecremented) {
concurrencyDecremented = true
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
}
try {
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
const newCount = await redis.decrConcurrency(validation.keyData.id)
logger.api(
`📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
)
@@ -300,29 +164,6 @@ const authenticateApiKey = async (req, res, next) => {
decrementConcurrency()
})
req.once('aborted', () => {
logger.warn(
`⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})`
)
decrementConcurrency()
})
req.once('error', (error) => {
logger.error(
`❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`,
error
)
decrementConcurrency()
})
res.once('error', (error) => {
logger.error(
`❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`,
error
)
decrementConcurrency()
})
// res.on('finish') 处理正常完成的情况
res.once('finish', () => {
logger.api(
@@ -335,7 +176,6 @@ const authenticateApiKey = async (req, res, next) => {
req.concurrencyInfo = {
apiKeyId: validation.keyData.id,
apiKeyName: validation.keyData.name,
requestId,
decrementConcurrency
}
}
@@ -435,9 +275,7 @@ const authenticateApiKey = async (req, res, next) => {
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
logger.security(
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}`
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}`
)
return res.status(429).json({
@@ -477,9 +315,7 @@ const authenticateApiKey = async (req, res, next) => {
if (dailyCost >= dailyCostLimit) {
logger.security(
`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
)
return res.status(429).json({
@@ -493,36 +329,7 @@ const authenticateApiKey = async (req, res, next) => {
// 记录当前费用使用情况
logger.api(
`💰 Cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
)
}
// 检查总费用限制
const totalCostLimit = validation.keyData.totalCostLimit || 0
if (totalCostLimit > 0) {
const totalCost = validation.keyData.totalCost || 0
if (totalCost >= totalCostLimit) {
logger.security(
`💰 Total cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
)
return res.status(429).json({
error: 'Total cost limit exceeded',
message: `已达到总费用限制 ($${totalCostLimit})`,
currentCost: totalCost,
costLimit: totalCostLimit
})
}
logger.api(
`💰 Total cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${totalCost.toFixed(2)}/$${totalCostLimit}`
`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
)
}
@@ -539,9 +346,7 @@ const authenticateApiKey = async (req, res, next) => {
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
// 计算下周一的重置时间
@@ -563,9 +368,7 @@ const authenticateApiKey = async (req, res, next) => {
// 记录当前 Opus 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
}
}
@@ -580,7 +383,6 @@ const authenticateApiKey = async (req, res, next) => {
geminiAccountId: validation.keyData.geminiAccountId,
openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID
bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: validation.keyData.droidAccountId,
permissions: validation.keyData.permissions,
concurrencyLimit: validation.keyData.concurrencyLimit,
rateLimitWindow: validation.keyData.rateLimitWindow,
@@ -592,8 +394,6 @@ const authenticateApiKey = async (req, res, next) => {
allowedClients: validation.keyData.allowedClients,
dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost,
totalCostLimit: validation.keyData.totalCostLimit,
totalCost: validation.keyData.totalCost,
usage: validation.keyData.usage
}
req.usage = validation.keyData.usage
@@ -987,7 +787,6 @@ const corsMiddleware = (req, res, next) => {
'Accept',
'Authorization',
'x-api-key',
'x-goog-api-key',
'api-key',
'x-admin-token',
'anthropic-version',

View File

@@ -7,38 +7,12 @@ const logger = require('../utils/logger')
const browserFallbackMiddleware = (req, res, next) => {
const userAgent = req.headers['user-agent'] || ''
const origin = req.headers['origin'] || ''
const extractHeader = (value) => {
let candidate = value
if (Array.isArray(candidate)) {
candidate = candidate.find((item) => typeof item === 'string' && item.trim())
}
if (typeof candidate !== 'string') {
return ''
}
let trimmed = candidate.trim()
if (!trimmed) {
return ''
}
if (/^Bearer\s+/i.test(trimmed)) {
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
}
return trimmed
}
const apiKeyHeader =
extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key'])
const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
// 检查是否为Chrome插件或浏览器请求
const isChromeExtension = origin.startsWith('chrome-extension://')
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
// 为Chrome插件请求添加特殊标记
@@ -49,8 +23,8 @@ const browserFallbackMiddleware = (req, res, next) => {
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
// 确保设置正确的认证头
if (!req.headers['authorization'] && apiKeyHeader) {
req.headers['authorization'] = `Bearer ${apiKeyHeader}`
if (!req.headers['authorization'] && req.headers['x-api-key']) {
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
}
// 添加必要的Anthropic头

View File

@@ -20,9 +20,7 @@ function getDateInTimezone(date = new Date()) {
function getDateStringInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date)
// 使用UTC方法获取偏移后的日期部分
return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(
tzDate.getUTCDate()
).padStart(2, '0')}`
return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
}
// 获取配置时区的小时 (0-23)
@@ -166,233 +164,6 @@ class RedisClient {
return apiKeys
}
/**
* 使用 SCAN 获取所有 API Key ID避免 KEYS 命令阻塞)
* @returns {Promise<string[]>} API Key ID 列表
*/
async scanApiKeyIds() {
const keyIds = []
let cursor = '0'
do {
const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', 'apikey:*', 'COUNT', 100)
cursor = newCursor
for (const key of keys) {
if (key !== 'apikey:hash_map') {
keyIds.push(key.replace('apikey:', ''))
}
}
} while (cursor !== '0')
return keyIds
}
/**
* 批量获取 API Key 数据(使用 Pipeline 优化)
* @param {string[]} keyIds - API Key ID 列表
* @returns {Promise<Object[]>} API Key 数据列表
*/
async batchGetApiKeys(keyIds) {
if (!keyIds || keyIds.length === 0) {
return []
}
const pipeline = this.client.pipeline()
for (const keyId of keyIds) {
pipeline.hgetall(`apikey:${keyId}`)
}
const results = await pipeline.exec()
const apiKeys = []
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
if (!err && data && Object.keys(data).length > 0) {
apiKeys.push({ id: keyIds[i], ...this._parseApiKeyData(data) })
}
}
return apiKeys
}
/**
* 解析 API Key 数据,将字符串转换为正确的类型
* @param {Object} data - 原始数据
* @returns {Object} 解析后的数据
*/
_parseApiKeyData(data) {
if (!data) {
return data
}
const parsed = { ...data }
// 布尔字段
const boolFields = ['isActive', 'enableModelRestriction', 'isDeleted']
for (const field of boolFields) {
if (parsed[field] !== undefined) {
parsed[field] = parsed[field] === 'true'
}
}
// 数字字段
const numFields = [
'tokenLimit',
'dailyCostLimit',
'totalCostLimit',
'rateLimitRequests',
'rateLimitTokens',
'rateLimitWindow',
'rateLimitCost',
'maxConcurrency',
'activationDuration'
]
for (const field of numFields) {
if (parsed[field] !== undefined && parsed[field] !== '') {
parsed[field] = parseFloat(parsed[field]) || 0
}
}
// 数组字段JSON 解析)
const arrayFields = ['tags', 'restrictedModels', 'allowedClients']
for (const field of arrayFields) {
if (parsed[field]) {
try {
parsed[field] = JSON.parse(parsed[field])
} catch (e) {
parsed[field] = []
}
}
}
return parsed
}
/**
* 获取 API Keys 分页数据(不含费用,用于优化列表加载)
* @param {Object} options - 分页和筛选选项
* @returns {Promise<{items: Object[], pagination: Object, availableTags: string[]}>}
*/
async getApiKeysPaginated(options = {}) {
const {
page = 1,
pageSize = 20,
searchMode = 'apiKey',
search = '',
tag = '',
isActive = '',
sortBy = 'createdAt',
sortOrder = 'desc',
excludeDeleted = true // 默认排除已删除的 API Keys
} = options
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
const keyIds = await this.scanApiKeyIds()
// 2. 使用 Pipeline 批量获取基础数据
const apiKeys = await this.batchGetApiKeys(keyIds)
// 3. 应用筛选条件
let filteredKeys = apiKeys
// 排除已删除的 API Keys默认行为
if (excludeDeleted) {
filteredKeys = filteredKeys.filter((k) => !k.isDeleted)
}
// 状态筛选
if (isActive !== '' && isActive !== undefined && isActive !== null) {
const activeValue = isActive === 'true' || isActive === true
filteredKeys = filteredKeys.filter((k) => k.isActive === activeValue)
}
// 标签筛选
if (tag) {
filteredKeys = filteredKeys.filter((k) => {
const tags = Array.isArray(k.tags) ? k.tags : []
return tags.includes(tag)
})
}
// 搜索
if (search) {
const lowerSearch = search.toLowerCase().trim()
if (searchMode === 'apiKey') {
// apiKey 模式:搜索名称和拥有者
filteredKeys = filteredKeys.filter(
(k) =>
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
)
} else if (searchMode === 'bindingAccount') {
// bindingAccount 模式直接在Redis层处理避免路由层加载10000条
const accountNameCacheService = require('../services/accountNameCacheService')
filteredKeys = accountNameCacheService.searchByBindingAccount(filteredKeys, lowerSearch)
}
}
// 4. 排序
filteredKeys.sort((a, b) => {
// status 排序实际上使用 isActive 字段API Key 没有 status 字段)
const effectiveSortBy = sortBy === 'status' ? 'isActive' : sortBy
let aVal = a[effectiveSortBy]
let bVal = b[effectiveSortBy]
// 日期字段转时间戳
if (['createdAt', 'expiresAt', 'lastUsedAt'].includes(effectiveSortBy)) {
aVal = aVal ? new Date(aVal).getTime() : 0
bVal = bVal ? new Date(bVal).getTime() : 0
}
// 布尔字段转数字
if (effectiveSortBy === 'isActive') {
aVal = aVal ? 1 : 0
bVal = bVal ? 1 : 0
}
// 字符串字段
if (sortBy === 'name') {
aVal = (aVal || '').toLowerCase()
bVal = (bVal || '').toLowerCase()
}
if (aVal < bVal) {
return sortOrder === 'asc' ? -1 : 1
}
if (aVal > bVal) {
return sortOrder === 'asc' ? 1 : -1
}
return 0
})
// 5. 收集所有可用标签(在分页之前)
const allTags = new Set()
for (const key of apiKeys) {
const tags = Array.isArray(key.tags) ? key.tags : []
tags.forEach((t) => allTags.add(t))
}
const availableTags = [...allTags].sort()
// 6. 分页
const total = filteredKeys.length
const totalPages = Math.ceil(total / pageSize) || 1
const validPage = Math.min(Math.max(1, page), totalPages)
const start = (validPage - 1) * pageSize
const items = filteredKeys.slice(start, start + pageSize)
return {
items,
pagination: {
page: validPage,
pageSize,
total,
totalPages
},
availableTags
}
}
// 🔍 通过哈希值查找API Key性能优化
async findApiKeyByHash(hashedKey) {
// 使用反向映射表hash -> keyId
@@ -448,10 +219,7 @@ class RedisClient {
const now = new Date()
const today = getDateStringInTimezone(now)
const tzDate = getDateInTimezone(now)
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 新增小时级别
const daily = `usage:daily:${keyId}:${today}`
@@ -646,10 +414,7 @@ class RedisClient {
const now = new Date()
const today = getDateStringInTimezone(now)
const tzDate = getDateInTimezone(now)
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`
// 账户级别统计的键
@@ -786,10 +551,7 @@ class RedisClient {
const today = getDateStringInTimezone()
const dailyKey = `usage:daily:${keyId}:${today}`
const tzDate = getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`
const [total, daily, monthly] = await Promise.all([
@@ -874,48 +636,6 @@ class RedisClient {
}
}
async addUsageRecord(keyId, record, maxRecords = 200) {
const listKey = `usage:records:${keyId}`
const client = this.getClientSafe()
try {
await client
.multi()
.lpush(listKey, JSON.stringify(record))
.ltrim(listKey, 0, Math.max(0, maxRecords - 1))
.expire(listKey, 86400 * 90) // 默认保留90天
.exec()
} catch (error) {
logger.error(`❌ Failed to append usage record for key ${keyId}:`, error)
}
}
async getUsageRecords(keyId, limit = 50) {
const listKey = `usage:records:${keyId}`
const client = this.getClient()
if (!client) {
return []
}
try {
const rawRecords = await client.lrange(listKey, 0, Math.max(0, limit - 1))
return rawRecords
.map((entry) => {
try {
return JSON.parse(entry)
} catch (error) {
logger.warn('⚠️ Failed to parse usage record entry:', error)
return null
}
})
.filter(Boolean)
} catch (error) {
logger.error(`❌ Failed to load usage records for key ${keyId}:`, error)
return []
}
}
// 💰 获取当日费用
async getDailyCost(keyId) {
const today = getDateStringInTimezone()
@@ -932,16 +652,13 @@ class RedisClient {
async incrementDailyCost(keyId, amount) {
const today = getDateStringInTimezone()
const tzDate = getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`
const dailyKey = `usage:cost:daily:${keyId}:${today}`
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`
const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`
const totalKey = `usage:cost:total:${keyId}` // 总费用键 - 永不过期,持续累加
const totalKey = `usage:cost:total:${keyId}`
logger.debug(
`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`
@@ -951,8 +668,8 @@ class RedisClient {
this.client.incrbyfloat(dailyKey, amount),
this.client.incrbyfloat(monthlyKey, amount),
this.client.incrbyfloat(hourlyKey, amount),
this.client.incrbyfloat(totalKey, amount), // ✅ 累加到总费用(永不过期)
// 设置过期时间注意totalKey 不设置过期时间,保持永久累计)
this.client.incrbyfloat(totalKey, amount),
// 设置过期时间
this.client.expire(dailyKey, 86400 * 30), // 30天
this.client.expire(monthlyKey, 86400 * 90), // 90天
this.client.expire(hourlyKey, 86400 * 7) // 7天
@@ -965,10 +682,7 @@ class RedisClient {
async getCostStats(keyId) {
const today = getDateStringInTimezone()
const tzDate = getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`
const [daily, monthly, hourly, total] = await Promise.all([
@@ -1071,10 +785,7 @@ class RedisClient {
const today = getDateStringInTimezone()
const accountDailyKey = `account_usage:daily:${accountId}:${today}`
const tzDate = getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`
const [total, daily, monthly] = await Promise.all([
@@ -1085,9 +796,7 @@ class RedisClient {
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
let accountData = {}
if (accountType === 'droid') {
accountData = await this.client.hgetall(`droid:account:${accountId}`)
} else if (accountType === 'openai') {
if (accountType === 'openai') {
accountData = await this.client.hgetall(`openai:account:${accountId}`)
} else if (accountType === 'openai-responses') {
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
@@ -1103,9 +812,6 @@ class RedisClient {
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`openai_account:${accountId}`)
}
if (!accountData.createdAt) {
accountData = await this.client.hgetall(`droid:account:${accountId}`)
}
}
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
const now = new Date()
@@ -1298,35 +1004,6 @@ class RedisClient {
const key = `claude:account:${accountId}`
return await this.client.del(key)
}
// 🤖 Droid 账户相关操作
async setDroidAccount(accountId, accountData) {
const key = `droid:account:${accountId}`
await this.client.hset(key, accountData)
}
async getDroidAccount(accountId) {
const key = `droid:account:${accountId}`
return await this.client.hgetall(key)
}
async getAllDroidAccounts() {
const keys = await this.client.keys('droid:account:*')
const accounts = []
for (const key of keys) {
const accountData = await this.client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
accounts.push({ id: key.replace('droid:account:', ''), ...accountData })
}
}
return accounts
}
async deleteDroidAccount(accountId) {
const key = `droid:account:${accountId}`
return await this.client.del(key)
}
async setOpenAiAccount(accountId, accountData) {
const key = `openai:account:${accountId}`
await this.client.hset(key, accountData)
@@ -1744,18 +1421,14 @@ class RedisClient {
if (remainingTTL < renewalThreshold) {
await this.client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(
remainingTTL / 60
)}min, renewed to ${ttlHours}h)`
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
)
return true
}
// 剩余时间充足,无需续期
logger.debug(
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(
remainingTTL / 60
)}min)`
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
)
return true
} catch (error) {
@@ -1799,95 +1472,18 @@ class RedisClient {
}
}
// 获取并发配置
_getConcurrencyConfig() {
const defaults = {
leaseSeconds: 300,
renewIntervalSeconds: 30,
cleanupGraceSeconds: 30
}
const configValues = {
...defaults,
...(config.concurrency || {})
}
const normalizeNumber = (value, fallback, options = {}) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
if (options.allowZero && parsed === 0) {
return 0
}
if (options.min !== undefined && parsed < options.min) {
return options.min
}
return parsed
}
return {
leaseSeconds: normalizeNumber(configValues.leaseSeconds, defaults.leaseSeconds, {
min: 30
}),
renewIntervalSeconds: normalizeNumber(
configValues.renewIntervalSeconds,
defaults.renewIntervalSeconds,
{
allowZero: true,
min: 0
}
),
cleanupGraceSeconds: normalizeNumber(
configValues.cleanupGraceSeconds,
defaults.cleanupGraceSeconds,
{
min: 0
}
)
}
}
// 增加并发计数(基于租约的有序集合)
async incrConcurrency(apiKeyId, requestId, leaseSeconds = null) {
if (!requestId) {
throw new Error('Request ID is required for concurrency tracking')
}
// 增加并发计数
async incrConcurrency(apiKeyId) {
try {
const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } =
this._getConcurrencyConfig()
const lease = leaseSeconds || defaultLeaseSeconds
const key = `concurrency:${apiKeyId}`
const now = Date.now()
const expireAt = now + lease * 1000
const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000)
const count = await this.client.incr(key)
const luaScript = `
local key = KEYS[1]
local member = ARGV[1]
local expireAt = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
// 设置过期时间为180秒3分钟防止计数器永远不清零
// 正常情况下请求会在完成时主动减少计数,这只是一个安全保障
// 180秒足够支持较长的流式请求
await this.client.expire(key, 180)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
redis.call('ZADD', key, expireAt, member)
if ttl > 0 then
redis.call('PEXPIRE', key, ttl)
end
local count = redis.call('ZCARD', key)
return count
`
const count = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl)
logger.database(
`🔢 Incremented concurrency for key ${apiKeyId}: ${count} (request ${requestId})`
)
logger.database(`🔢 Incremented concurrency for key ${apiKeyId}: ${count}`)
return count
} catch (error) {
logger.error('❌ Failed to increment concurrency:', error)
@@ -1895,84 +1491,32 @@ class RedisClient {
}
}
// 刷新并发租约,防止长连接提前过期
async refreshConcurrencyLease(apiKeyId, requestId, leaseSeconds = null) {
if (!requestId) {
return 0
}
try {
const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } =
this._getConcurrencyConfig()
const lease = leaseSeconds || defaultLeaseSeconds
const key = `concurrency:${apiKeyId}`
const now = Date.now()
const expireAt = now + lease * 1000
const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000)
const luaScript = `
local key = KEYS[1]
local member = ARGV[1]
local expireAt = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
local exists = redis.call('ZSCORE', key, member)
if exists then
redis.call('ZADD', key, expireAt, member)
if ttl > 0 then
redis.call('PEXPIRE', key, ttl)
end
return 1
end
return 0
`
const refreshed = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl)
if (refreshed === 1) {
logger.debug(`🔄 Refreshed concurrency lease for key ${apiKeyId} (request ${requestId})`)
}
return refreshed
} catch (error) {
logger.error('❌ Failed to refresh concurrency lease:', error)
return 0
}
}
// 减少并发计数
async decrConcurrency(apiKeyId, requestId) {
async decrConcurrency(apiKeyId) {
try {
const key = `concurrency:${apiKeyId}`
const now = Date.now()
// 使用Lua脚本确保原子性操作防止计数器变成负数
const luaScript = `
local key = KEYS[1]
local member = ARGV[1]
local now = tonumber(ARGV[2])
local current = tonumber(redis.call('get', key) or "0")
if member then
redis.call('ZREM', key, member)
end
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
local count = redis.call('ZCARD', key)
if count <= 0 then
redis.call('DEL', key)
if current <= 0 then
redis.call('del', key)
return 0
else
local new_value = redis.call('decr', key)
if new_value <= 0 then
redis.call('del', key)
return 0
else
return new_value
end
end
return count
`
const count = await this.client.eval(luaScript, 1, key, requestId || '', now)
logger.database(
`🔢 Decremented concurrency for key ${apiKeyId}: ${count} (request ${requestId || 'n/a'})`
)
const count = await this.client.eval(luaScript, 1, key)
logger.database(`🔢 Decremented concurrency for key ${apiKeyId}: ${count}`)
return count
} catch (error) {
logger.error('❌ Failed to decrement concurrency:', error)
@@ -1984,17 +1528,7 @@ class RedisClient {
async getConcurrency(apiKeyId) {
try {
const key = `concurrency:${apiKeyId}`
const now = Date.now()
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
return redis.call('ZCARD', key)
`
const count = await this.client.eval(luaScript, 1, key, now)
const count = await this.client.get(key)
return parseInt(count || 0)
} catch (error) {
logger.error('❌ Failed to get concurrency:', error)
@@ -2002,38 +1536,6 @@ class RedisClient {
}
}
// 🏢 Claude Console 账户并发控制(复用现有并发机制)
// 增加 Console 账户并发计数
async incrConsoleAccountConcurrency(accountId, requestId, leaseSeconds = null) {
if (!requestId) {
throw new Error('Request ID is required for console account concurrency tracking')
}
// 使用特殊的 key 前缀区分 Console 账户并发
const compositeKey = `console_account:${accountId}`
return await this.incrConcurrency(compositeKey, requestId, leaseSeconds)
}
// 刷新 Console 账户并发租约
async refreshConsoleAccountConcurrencyLease(accountId, requestId, leaseSeconds = null) {
if (!requestId) {
return 0
}
const compositeKey = `console_account:${accountId}`
return await this.refreshConcurrencyLease(compositeKey, requestId, leaseSeconds)
}
// 减少 Console 账户并发计数
async decrConsoleAccountConcurrency(accountId, requestId) {
const compositeKey = `console_account:${accountId}`
return await this.decrConcurrency(compositeKey, requestId)
}
// 获取 Console 账户当前并发数
async getConsoleAccountConcurrency(accountId) {
const compositeKey = `console_account:${accountId}`
return await this.getConcurrency(compositeKey)
}
// 🔧 Basic Redis operations wrapper methods for convenience
async get(key) {
const client = this.getClientSafe()
@@ -2219,9 +1721,11 @@ const redisClient = new RedisClient()
// 分布式锁相关方法
redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) {
try {
// 使用SET NX PX实现原子性的锁获取
// ioredis语法: set(key, value, 'PX', milliseconds, 'NX')
const result = await this.client.set(lockKey, lockValue, 'PX', ttlMs, 'NX')
// 使用SET NX EX实现原子性的锁获取
const result = await this.client.set(lockKey, lockValue, {
NX: true, // 只在键不存在时设置
PX: ttlMs // 毫秒级过期时间
})
return result === 'OK'
} catch (error) {
logger.error(`Failed to acquire lock ${lockKey}:`, error)
@@ -2239,8 +1743,10 @@ redisClient.releaseAccountLock = async function (lockKey, lockValue) {
return 0
end
`
// ioredis语法: eval(script, numberOfKeys, key1, key2, ..., arg1, arg2, ...)
const result = await this.client.eval(script, 1, lockKey, lockValue)
const result = await this.client.eval(script, {
keys: [lockKey],
arguments: [lockValue]
})
return result === 1
} catch (error) {
logger.error(`Failed to release lock ${lockKey}:`, error)

7458
src/routes/admin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,153 +0,0 @@
const express = require('express')
const accountGroupService = require('../../services/accountGroupService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const droidAccountService = require('../../services/droidAccountService')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const router = express.Router()
// 👥 账户分组管理
// 创建账户分组
router.post('/', authenticateAdmin, async (req, res) => {
try {
const { name, platform, description } = req.body
const group = await accountGroupService.createGroup({
name,
platform,
description
})
return res.json({ success: true, data: group })
} catch (error) {
logger.error('❌ Failed to create account group:', error)
return res.status(400).json({ error: error.message })
}
})
// 获取所有分组
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform } = req.query
const groups = await accountGroupService.getAllGroups(platform)
return res.json({ success: true, data: groups })
} catch (error) {
logger.error('❌ Failed to get account groups:', error)
return res.status(500).json({ error: error.message })
}
})
// 获取分组详情
router.get('/:groupId', authenticateAdmin, async (req, res) => {
try {
const { groupId } = req.params
const group = await accountGroupService.getGroup(groupId)
if (!group) {
return res.status(404).json({ error: '分组不存在' })
}
return res.json({ success: true, data: group })
} catch (error) {
logger.error('❌ Failed to get account group:', error)
return res.status(500).json({ error: error.message })
}
})
// 更新分组
router.put('/:groupId', authenticateAdmin, async (req, res) => {
try {
const { groupId } = req.params
const updates = req.body
const updatedGroup = await accountGroupService.updateGroup(groupId, updates)
return res.json({ success: true, data: updatedGroup })
} catch (error) {
logger.error('❌ Failed to update account group:', error)
return res.status(400).json({ error: error.message })
}
})
// 删除分组
router.delete('/:groupId', authenticateAdmin, async (req, res) => {
try {
const { groupId } = req.params
await accountGroupService.deleteGroup(groupId)
return res.json({ success: true, message: '分组删除成功' })
} catch (error) {
logger.error('❌ Failed to delete account group:', error)
return res.status(400).json({ error: error.message })
}
})
// 获取分组成员
router.get('/:groupId/members', authenticateAdmin, async (req, res) => {
try {
const { groupId } = req.params
const group = await accountGroupService.getGroup(groupId)
if (!group) {
return res.status(404).json({ error: '分组不存在' })
}
const memberIds = await accountGroupService.getGroupMembers(groupId)
// 获取成员详细信息
const members = []
for (const memberId of memberIds) {
// 根据分组平台优先查找对应账户
let account = null
switch (group.platform) {
case 'droid':
account = await droidAccountService.getAccount(memberId)
break
case 'gemini':
account = await geminiAccountService.getAccount(memberId)
break
case 'openai':
account = await openaiAccountService.getAccount(memberId)
break
case 'claude':
default:
account = await claudeAccountService.getAccount(memberId)
if (!account) {
account = await claudeConsoleAccountService.getAccount(memberId)
}
break
}
// 兼容旧数据:若按平台未找到,则继续尝试其他平台
if (!account) {
account = await claudeAccountService.getAccount(memberId)
}
if (!account) {
account = await claudeConsoleAccountService.getAccount(memberId)
}
if (!account) {
account = await geminiAccountService.getAccount(memberId)
}
if (!account) {
account = await openaiAccountService.getAccount(memberId)
}
if (!account && group.platform !== 'droid') {
account = await droidAccountService.getAccount(memberId)
}
if (account) {
members.push(account)
}
}
return res.json({ success: true, data: members })
} catch (error) {
logger.error('❌ Failed to get group members:', error)
return res.status(500).json({ error: error.message })
}
})
module.exports = router

File diff suppressed because it is too large Load Diff

View File

@@ -1,417 +0,0 @@
const express = require('express')
const azureOpenaiAccountService = require('../../services/azureOpenaiAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const axios = require('axios')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// 获取所有 Azure OpenAI 账户
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await azureOpenaiAccountService.getAllAccounts()
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'azure_openai') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息和分组信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (error) {
logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error)
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
} catch (groupError) {
logger.debug(`Failed to get group info for account ${account.id}:`, groupError)
return {
...account,
groupInfos: [],
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
}
})
)
res.json({
success: true,
data: accountsWithStats
})
} catch (error) {
logger.error('Failed to fetch Azure OpenAI accounts:', error)
res.status(500).json({
success: false,
message: 'Failed to fetch accounts',
error: error.message
})
}
})
// 创建 Azure OpenAI 账户
router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
accountType,
azureEndpoint,
apiVersion,
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
groupIds,
priority,
isActive,
schedulable
} = req.body
// 验证必填字段
if (!name) {
return res.status(400).json({
success: false,
message: 'Account name is required'
})
}
if (!azureEndpoint) {
return res.status(400).json({
success: false,
message: 'Azure endpoint is required'
})
}
if (!apiKey) {
return res.status(400).json({
success: false,
message: 'API key is required'
})
}
if (!deploymentName) {
return res.status(400).json({
success: false,
message: 'Deployment name is required'
})
}
// 验证 Azure endpoint 格式
if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) {
return res.status(400).json({
success: false,
message:
'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com'
})
}
// 测试连接
try {
const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${
apiVersion || '2024-02-01'
}`
await axios.get(testUrl, {
headers: {
'api-key': apiKey
},
timeout: 5000
})
} catch (testError) {
if (testError.response?.status === 404) {
logger.warn('Azure OpenAI deployment not found, but continuing with account creation')
} else if (testError.response?.status === 401) {
return res.status(400).json({
success: false,
message: 'Invalid API key or unauthorized access'
})
}
}
const account = await azureOpenaiAccountService.createAccount({
name,
description,
accountType: accountType || 'shared',
azureEndpoint,
apiVersion: apiVersion || '2024-02-01',
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
priority: priority || 50,
isActive: isActive !== false,
schedulable: schedulable !== false
})
// 如果是分组类型,将账户添加到分组
if (accountType === 'group') {
if (groupIds && groupIds.length > 0) {
// 使用多分组设置
await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai')
} else if (groupId) {
// 兼容单分组模式
await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai')
}
}
res.json({
success: true,
data: account,
message: 'Azure OpenAI account created successfully'
})
} catch (error) {
logger.error('Failed to create Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to create account',
error: error.message
})
}
})
// 更新 Azure OpenAI 账户
router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id)
const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates)
res.json({
success: true,
data: account,
message: 'Azure OpenAI account updated successfully'
})
} catch (error) {
logger.error('Failed to update Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to update account',
error: error.message
})
}
})
// 删除 Azure OpenAI 账户
router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai')
await azureOpenaiAccountService.deleteAccount(id)
let message = 'Azure OpenAI账号已成功删除'
if (unboundCount > 0) {
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`)
res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('Failed to delete Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to delete account',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户状态
router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await azureOpenaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
const newStatus = account.isActive === 'true' ? 'false' : 'true'
await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus })
res.json({
success: true,
message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`,
isActive: newStatus === 'true'
})
} catch (error) {
logger.error('Failed to toggle Azure OpenAI account status:', error)
res.status(500).json({
success: false,
message: 'Failed to toggle account status',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户调度状态
router.put(
'/azure-openai-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用,发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await azureOpenaiAccountService.getAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Azure OpenAI Account',
platform: 'azure-openai',
status: 'disabled',
errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({
success: true,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 Azure OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
}
)
// 健康检查单个 Azure OpenAI 账户
router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const healthResult = await azureOpenaiAccountService.healthCheckAccount(id)
res.json({
success: true,
data: healthResult
})
} catch (error) {
logger.error('Failed to perform health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform health check',
error: error.message
})
}
})
// 批量健康检查所有 Azure OpenAI 账户
router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => {
try {
const healthResults = await azureOpenaiAccountService.performHealthChecks()
res.json({
success: true,
data: healthResults
})
} catch (error) {
logger.error('Failed to perform batch health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform batch health check',
error: error.message
})
}
})
// 迁移 API Keys 以支持 Azure OpenAI
router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
try {
const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport()
res.json({
success: true,
message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support`
})
} catch (error) {
logger.error('Failed to migrate API keys:', error)
res.status(500).json({
success: false,
message: 'Failed to migrate API keys',
error: error.message
})
}
})
module.exports = router

View File

@@ -1,371 +0,0 @@
/**
* Admin Routes - Bedrock Accounts Management
* AWS Bedrock 账户管理路由
*/
const express = require('express')
const router = express.Router()
const bedrockAccountService = require('../../services/bedrockAccountService')
const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
// ☁️ Bedrock 账户管理
// 获取所有Bedrock账户
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
const result = await bedrockAccountService.getAllAccounts()
if (!result.success) {
return res
.status(500)
.json({ error: 'Failed to get Bedrock accounts', message: result.error })
}
let accounts = result.data
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'bedrock') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (statsError) {
logger.warn(
`⚠️ Failed to get usage stats for Bedrock account ${account.id}:`,
statsError.message
)
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
} catch (groupError) {
logger.warn(
`⚠️ Failed to get group info for account ${account.id}:`,
groupError.message
)
return {
...account,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('❌ Failed to get Bedrock accounts:', error)
return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message })
}
})
// 创建新的Bedrock账户
router.post('/', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
region,
awsCredentials,
defaultModel,
priority,
accountType,
credentialType
} = req.body
if (!name) {
return res.status(400).json({ error: 'Name is required' })
}
// 验证priority的有效性1-100
if (priority !== undefined && (priority < 1 || priority > 100)) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
}
// 验证credentialType的有效性
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
})
}
const result = await bedrockAccountService.createAccount({
name,
description: description || '',
region: region || 'us-east-1',
awsCredentials,
defaultModel,
priority: priority || 50,
accountType: accountType || 'shared',
credentialType: credentialType || 'default'
})
if (!result.success) {
return res
.status(500)
.json({ error: 'Failed to create Bedrock account', message: result.error })
}
logger.success(`☁️ Admin created Bedrock account: ${name}`)
const formattedAccount = formatAccountExpiry(result.data)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('❌ Failed to create Bedrock account:', error)
return res
.status(500)
.json({ error: 'Failed to create Bedrock account', message: error.message })
}
})
// 更新Bedrock账户
router.put('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId)
// 验证priority的有效性1-100
if (
mappedUpdates.priority !== undefined &&
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证accountType的有效性
if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared" or "dedicated"' })
}
// 验证credentialType的有效性
if (
mappedUpdates.credentialType &&
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
})
}
const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates)
if (!result.success) {
return res
.status(500)
.json({ error: 'Failed to update Bedrock account', message: result.error })
}
logger.success(`📝 Admin updated Bedrock account: ${accountId}`)
return res.json({ success: true, message: 'Bedrock account updated successfully' })
} catch (error) {
logger.error('❌ Failed to update Bedrock account:', error)
return res
.status(500)
.json({ error: 'Failed to update Bedrock account', message: error.message })
}
})
// 删除Bedrock账户
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock')
const result = await bedrockAccountService.deleteAccount(accountId)
if (!result.success) {
return res
.status(500)
.json({ error: 'Failed to delete Bedrock account', message: result.error })
}
let message = 'Bedrock账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('❌ Failed to delete Bedrock account:', error)
return res
.status(500)
.json({ error: 'Failed to delete Bedrock account', message: error.message })
}
})
// 切换Bedrock账户状态
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const accountResult = await bedrockAccountService.getAccount(accountId)
if (!accountResult.success) {
return res.status(404).json({ error: 'Account not found' })
}
const newStatus = !accountResult.data.isActive
const updateResult = await bedrockAccountService.updateAccount(accountId, {
isActive: newStatus
})
if (!updateResult.success) {
return res
.status(500)
.json({ error: 'Failed to toggle account status', message: updateResult.error })
}
logger.success(
`🔄 Admin toggled Bedrock account status: ${accountId} -> ${
newStatus ? 'active' : 'inactive'
}`
)
return res.json({ success: true, isActive: newStatus })
} catch (error) {
logger.error('❌ Failed to toggle Bedrock account status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle account status', message: error.message })
}
})
// 切换Bedrock账户调度状态
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const accountResult = await bedrockAccountService.getAccount(accountId)
if (!accountResult.success) {
return res.status(404).json({ error: 'Account not found' })
}
const newSchedulable = !accountResult.data.schedulable
const updateResult = await bedrockAccountService.updateAccount(accountId, {
schedulable: newSchedulable
})
if (!updateResult.success) {
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: updateResult.error })
}
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: accountResult.data.id,
accountName: accountResult.data.name || 'Bedrock Account',
platform: 'bedrock',
status: 'disabled',
errorCode: 'BEDROCK_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${
newSchedulable ? 'schedulable' : 'not schedulable'
}`
)
return res.json({ success: true, schedulable: newSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle Bedrock account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
})
// 测试Bedrock账户连接
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
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) {
logger.error('❌ Failed to test Bedrock account:', error)
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
}
})
module.exports = router

View File

@@ -1,416 +0,0 @@
const express = require('express')
const ccrAccountService = require('../../services/ccrAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// 🔧 CCR 账户管理
// 获取所有CCR账户
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await ccrAccountService.getAllAccounts()
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'ccr') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id)
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (statsError) {
logger.warn(
`⚠️ Failed to get usage stats for CCR account ${account.id}:`,
statsError.message
)
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
} catch (groupError) {
logger.warn(
`⚠️ Failed to get group info for CCR account ${account.id}:`,
groupError.message
)
return {
...account,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('❌ Failed to get CCR accounts:', error)
return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message })
}
})
// 创建新的CCR账户
router.post('/', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
apiUrl,
apiKey,
priority,
supportedModels,
userAgent,
rateLimitDuration,
proxy,
accountType,
groupId,
dailyQuota,
quotaResetTime
} = req.body
if (!name || !apiUrl || !apiKey) {
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
}
// 验证priority的有效性1-100
if (priority !== undefined && (priority < 1 || priority > 100)) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果是分组类型验证groupId
if (accountType === 'group' && !groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
const newAccount = await ccrAccountService.createAccount({
name,
description,
apiUrl,
apiKey,
priority: priority || 50,
supportedModels: supportedModels || [],
userAgent,
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
proxy,
accountType: accountType || 'shared',
dailyQuota: dailyQuota || 0,
quotaResetTime: quotaResetTime || '00:00'
})
// 如果是分组类型,将账户添加到分组
if (accountType === 'group' && groupId) {
await accountGroupService.addAccountToGroup(newAccount.id, groupId)
}
logger.success(`🔧 Admin created CCR account: ${name}`)
const formattedAccount = formatAccountExpiry(newAccount)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('❌ Failed to create CCR account:', error)
return res.status(500).json({ error: 'Failed to create CCR account', message: error.message })
}
})
// 更新CCR账户
router.put('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'CCR', accountId)
// 验证priority的有效性1-100
if (
mappedUpdates.priority !== undefined &&
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证accountType的有效性
if (
mappedUpdates.accountType &&
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await ccrAccountService.getAccount(accountId)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
const oldGroups = await accountGroupService.getAccountGroups(accountId)
for (const oldGroup of oldGroups) {
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
}
}
// 如果新类型是分组,处理多分组支持
if (mappedUpdates.accountType === 'group') {
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
// 如果明确提供了 groupIds 参数(包括空数组)
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
// 设置新的多分组
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
} else {
// groupIds 为空数组,从所有分组中移除
await accountGroupService.removeAccountFromAllGroups(accountId)
}
} else if (mappedUpdates.groupId) {
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
}
}
}
await ccrAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated CCR account: ${accountId}`)
return res.json({ success: true, message: 'CCR account updated successfully' })
} catch (error) {
logger.error('❌ Failed to update CCR account:', error)
return res.status(500).json({ error: 'Failed to update CCR account', message: error.message })
}
})
// 删除CCR账户
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
// 尝试自动解绑CCR账户实际上不会绑定API Key但保持代码一致性
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr')
// 获取账户信息以检查是否在分组中
const account = await ccrAccountService.getAccount(accountId)
if (account && account.accountType === 'group') {
const groups = await accountGroupService.getAccountGroups(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
}
await ccrAccountService.deleteAccount(accountId)
let message = 'CCR账号已成功删除'
if (unboundCount > 0) {
// 理论上不会发生,但保持消息格式一致
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted CCR account: ${accountId}`)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('❌ Failed to delete CCR account:', error)
return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message })
}
})
// 切换CCR账户状态
router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const account = await ccrAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
const newStatus = !account.isActive
await ccrAccountService.updateAccount(accountId, { isActive: newStatus })
logger.success(
`🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`
)
return res.json({ success: true, isActive: newStatus })
} catch (error) {
logger.error('❌ Failed to toggle CCR account status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle account status', message: error.message })
}
})
// 切换CCR账户调度状态
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const account = await ccrAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
const newSchedulable = !account.schedulable
await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'CCR Account',
platform: 'ccr',
status: 'disabled',
errorCode: 'CCR_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${
newSchedulable ? 'schedulable' : 'not schedulable'
}`
)
return res.json({ success: true, schedulable: newSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle CCR account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
})
// 获取CCR账户的使用统计
router.get('/:accountId/usage', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const usageStats = await ccrAccountService.getAccountUsageStats(accountId)
if (!usageStats) {
return res.status(404).json({ error: 'Account not found' })
}
return res.json(usageStats)
} catch (error) {
logger.error('❌ Failed to get CCR account usage stats:', error)
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
}
})
// 手动重置CCR账户的每日使用量
router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
await ccrAccountService.resetDailyUsage(accountId)
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset CCR account daily usage:', error)
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
}
})
// 重置CCR账户状态清除所有异常状态
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await ccrAccountService.resetAccountStatus(accountId)
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset CCR account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
// 手动重置所有CCR账户的每日使用量
router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
try {
await ccrAccountService.resetAllDailyUsage()
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
return res
.status(500)
.json({ error: 'Failed to reset all daily usage', message: error.message })
}
})
module.exports = router

View File

@@ -1,906 +0,0 @@
/**
* Admin Routes - Claude 官方账户管理
* OAuth 方式授权的 Claude 账户
*/
const express = require('express')
const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const oauthHelper = require('../../utils/oauthHelper')
const CostCalculator = require('../../utils/costCalculator')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
// 生成OAuth授权URL
router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body // 接收代理配置
const oauthParams = await oauthHelper.generateOAuthParams()
// 将codeVerifier和state临时存储到Redis用于后续验证
const sessionId = require('crypto').randomUUID()
await redis.setOAuthSession(sessionId, {
codeVerifier: oauthParams.codeVerifier,
state: oauthParams.state,
codeChallenge: oauthParams.codeChallenge,
proxy: proxy || null, // 存储代理配置
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
})
logger.success('🔗 Generated OAuth authorization URL with proxy support')
return res.json({
success: true,
data: {
authUrl: oauthParams.authUrl,
sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 Anthropic 账户',
'3. 同意应用权限',
'4. 复制浏览器地址栏中的完整 URL',
'5. 在添加账户表单中粘贴完整的回调 URL 和授权码'
]
}
})
} catch (error) {
logger.error('❌ Failed to generate OAuth URL:', error)
return res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message })
}
})
// 验证授权码并获取token
router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { sessionId, authorizationCode, callbackUrl } = req.body
if (!sessionId || (!authorizationCode && !callbackUrl)) {
return res
.status(400)
.json({ error: 'Session ID and authorization code (or callback URL) are required' })
}
// 从Redis获取OAuth会话信息
const oauthSession = await redis.getOAuthSession(sessionId)
if (!oauthSession) {
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
}
// 检查会话是否过期
if (new Date() > new Date(oauthSession.expiresAt)) {
await redis.deleteOAuthSession(sessionId)
return res
.status(400)
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
}
// 统一处理授权码输入可能是直接的code或完整的回调URL
let finalAuthCode
const inputValue = callbackUrl || authorizationCode
try {
finalAuthCode = oauthHelper.parseCallbackUrl(inputValue)
} catch (parseError) {
return res
.status(400)
.json({ error: 'Failed to parse authorization input', message: parseError.message })
}
// 交换访问令牌
const tokenData = await oauthHelper.exchangeCodeForTokens(
finalAuthCode,
oauthSession.codeVerifier,
oauthSession.state,
oauthSession.proxy // 传递代理配置
)
// 清理OAuth会话
await redis.deleteOAuthSession(sessionId)
logger.success('🎉 Successfully exchanged authorization code for tokens')
return res.json({
success: true,
data: {
claudeAiOauth: tokenData
}
})
} catch (error) {
logger.error('❌ Failed to exchange authorization code:', {
error: error.message,
sessionId: req.body.sessionId,
// 不记录完整的授权码,只记录长度和前几个字符
codeLength: req.body.callbackUrl
? req.body.callbackUrl.length
: req.body.authorizationCode
? req.body.authorizationCode.length
: 0,
codePrefix: req.body.callbackUrl
? `${req.body.callbackUrl.substring(0, 10)}...`
: req.body.authorizationCode
? `${req.body.authorizationCode.substring(0, 10)}...`
: 'N/A'
})
return res
.status(500)
.json({ error: 'Failed to exchange authorization code', message: error.message })
}
})
// 生成Claude setup-token授权URL
router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body // 接收代理配置
const setupTokenParams = await oauthHelper.generateSetupTokenParams()
// 将codeVerifier和state临时存储到Redis用于后续验证
const sessionId = require('crypto').randomUUID()
await redis.setOAuthSession(sessionId, {
type: 'setup-token', // 标记为setup-token类型
codeVerifier: setupTokenParams.codeVerifier,
state: setupTokenParams.state,
codeChallenge: setupTokenParams.codeChallenge,
proxy: proxy || null, // 存储代理配置
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
})
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
return res.json({
success: true,
data: {
authUrl: setupTokenParams.authUrl,
sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 Claude 账户并授权 Claude Code',
'3. 完成授权后,从返回页面复制 Authorization Code',
'4. 在添加账户表单中粘贴 Authorization Code'
]
}
})
} catch (error) {
logger.error('❌ Failed to generate Setup Token URL:', error)
return res
.status(500)
.json({ error: 'Failed to generate Setup Token URL', message: error.message })
}
})
// 验证setup-token授权码并获取token
router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, async (req, res) => {
try {
const { sessionId, authorizationCode, callbackUrl } = req.body
if (!sessionId || (!authorizationCode && !callbackUrl)) {
return res
.status(400)
.json({ error: 'Session ID and authorization code (or callback URL) are required' })
}
// 从Redis获取OAuth会话信息
const oauthSession = await redis.getOAuthSession(sessionId)
if (!oauthSession) {
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
}
// 检查是否是setup-token类型
if (oauthSession.type !== 'setup-token') {
return res.status(400).json({ error: 'Invalid session type for setup token exchange' })
}
// 检查会话是否过期
if (new Date() > new Date(oauthSession.expiresAt)) {
await redis.deleteOAuthSession(sessionId)
return res
.status(400)
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
}
// 统一处理授权码输入可能是直接的code或完整的回调URL
let finalAuthCode
const inputValue = callbackUrl || authorizationCode
try {
finalAuthCode = oauthHelper.parseCallbackUrl(inputValue)
} catch (parseError) {
return res
.status(400)
.json({ error: 'Failed to parse authorization input', message: parseError.message })
}
// 交换Setup Token
const tokenData = await oauthHelper.exchangeSetupTokenCode(
finalAuthCode,
oauthSession.codeVerifier,
oauthSession.state,
oauthSession.proxy // 传递代理配置
)
// 清理OAuth会话
await redis.deleteOAuthSession(sessionId)
logger.success('🎉 Successfully exchanged setup token authorization code for tokens')
return res.json({
success: true,
data: {
claudeAiOauth: tokenData
}
})
} catch (error) {
logger.error('❌ Failed to exchange setup token authorization code:', {
error: error.message,
sessionId: req.body.sessionId,
// 不记录完整的授权码,只记录长度和前几个字符
codeLength: req.body.callbackUrl
? req.body.callbackUrl.length
: req.body.authorizationCode
? req.body.authorizationCode.length
: 0,
codePrefix: req.body.callbackUrl
? `${req.body.callbackUrl.substring(0, 10)}...`
: req.body.authorizationCode
? `${req.body.authorizationCode.substring(0, 10)}...`
: 'N/A'
})
return res
.status(500)
.json({ error: 'Failed to exchange setup token authorization code', message: error.message })
}
})
// =============================================================================
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
// =============================================================================
// 普通OAuth的Cookie自动授权
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based OAuth authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// Setup Token的Cookie自动授权
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程Setup Token模式
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based Setup Token authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await claudeAccountService.getAllAccounts()
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'claude') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
// 获取会话窗口使用统计(仅对有活跃窗口的账户)
let sessionWindowUsage = null
if (account.sessionWindow && account.sessionWindow.hasActiveWindow) {
const windowUsage = await redis.getAccountSessionWindowUsage(
account.id,
account.sessionWindow.windowStart,
account.sessionWindow.windowEnd
)
// 计算会话窗口的总费用
let totalCost = 0
const modelCosts = {}
for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData))
const costResult = CostCalculator.calculateCost(usageData, modelName)
logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`)
modelCosts[modelName] = {
...usage,
cost: costResult.costs.total
}
totalCost += costResult.costs.total
}
sessionWindowUsage = {
totalTokens: windowUsage.totalAllTokens,
totalRequests: windowUsage.totalRequests,
totalCost,
modelUsage: modelCosts
}
}
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages,
sessionWindow: sessionWindowUsage
}
}
} catch (statsError) {
logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message)
// 如果获取统计失败,返回空统计
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 },
sessionWindow: null
}
}
} catch (groupError) {
logger.warn(
`⚠️ Failed to get group info for account ${account.id}:`,
groupError.message
)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 },
sessionWindow: null
}
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error)
return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message })
}
})
// 批量获取 Claude 账户的 OAuth Usage 数据
router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => {
try {
const accounts = await redis.getAllClaudeAccounts()
const now = Date.now()
const usageCacheTtlMs = 300 * 1000
// 批量并发获取所有活跃 OAuth 账户的 Usage
const usagePromises = accounts.map(async (account) => {
// 检查是否为 OAuth 账户scopes 包含 OAuth 相关权限
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
// 仅为 OAuth 授权的活跃账户调用 usage API
if (
isOAuth &&
account.isActive === 'true' &&
account.accessToken &&
account.status === 'active'
) {
// 若快照在 300 秒内更新,直接使用缓存避免频繁请求
const cachedUsage = claudeAccountService.buildClaudeUsageSnapshot(account)
const lastUpdatedAt = account.claudeUsageUpdatedAt
? new Date(account.claudeUsageUpdatedAt).getTime()
: 0
const isCacheFresh = cachedUsage && lastUpdatedAt && now - lastUpdatedAt < usageCacheTtlMs
if (isCacheFresh) {
return {
accountId: account.id,
claudeUsage: cachedUsage
}
}
try {
const usageData = await claudeAccountService.fetchOAuthUsage(account.id)
if (usageData) {
await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData)
}
// 重新读取更新后的数据
const updatedAccount = await redis.getClaudeAccount(account.id)
return {
accountId: account.id,
claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount)
}
} catch (error) {
logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message)
return { accountId: account.id, claudeUsage: null }
}
}
// Setup Token 账户不调用 usage API直接返回 null
return { accountId: account.id, claudeUsage: null }
})
const results = await Promise.allSettled(usagePromises)
// 转换为 { accountId: usage } 映射
const usageMap = {}
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
usageMap[result.value.accountId] = result.value.claudeUsage
}
})
res.json({ success: true, data: usageMap })
} catch (error) {
logger.error('❌ Failed to fetch Claude accounts usage:', error)
res.status(500).json({ error: 'Failed to fetch usage data', message: error.message })
}
})
// 创建新的Claude账户
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy,
accountType,
platform = 'claude',
priority,
groupId,
groupIds,
autoStopOnWarning,
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId,
expiresAt,
extInfo
} = req.body
if (!name) {
return res.status(400).json({ error: 'Name is required' })
}
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果是分组类型验证groupId或groupIds
if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) {
return res
.status(400)
.json({ error: 'Group ID or Group IDs are required for group type accounts' })
}
// 验证priority的有效性
if (
priority !== undefined &&
(typeof priority !== 'number' || priority < 1 || priority > 100)
) {
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
}
const newAccount = await claudeAccountService.createAccount({
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy,
accountType: accountType || 'shared', // 默认为共享类型
platform,
priority: priority || 50, // 默认优先级为50
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null, // 账户订阅到期时间
extInfo: extInfo || null
})
// 如果是分组类型,将账户添加到分组
if (accountType === 'group') {
if (groupIds && groupIds.length > 0) {
// 使用多分组设置
await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform)
} else if (groupId) {
// 兼容单分组模式
await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform)
}
}
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`)
const formattedAccount = formatAccountExpiry(newAccount)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('❌ Failed to create Claude account:', error)
return res
.status(500)
.json({ error: 'Failed to create Claude account', message: error.message })
}
})
// 更新Claude账户
router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const updates = req.body
// ✅ 【修改】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt提前到参数验证之前
const mappedUpdates = mapExpiryField(updates, 'Claude', accountId)
// 验证priority的有效性
if (
mappedUpdates.priority !== undefined &&
(typeof mappedUpdates.priority !== 'number' ||
mappedUpdates.priority < 1 ||
mappedUpdates.priority > 100)
) {
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
}
// 验证accountType的有效性
if (
mappedUpdates.accountType &&
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId或groupIds
if (
mappedUpdates.accountType === 'group' &&
!mappedUpdates.groupId &&
(!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0)
) {
return res
.status(400)
.json({ error: 'Group ID or Group IDs are required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await claudeAccountService.getAccount(accountId)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
await accountGroupService.removeAccountFromAllGroups(accountId)
}
// 如果新类型是分组,添加到新分组
if (mappedUpdates.accountType === 'group') {
// 处理多分组/单分组的兼容性
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
// 使用多分组设置
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
} else {
// groupIds 为空数组,从所有分组中移除
await accountGroupService.removeAccountFromAllGroups(accountId)
}
} else if (mappedUpdates.groupId) {
// 兼容单分组模式
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
}
}
}
await claudeAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Claude account: ${accountId}`)
return res.json({ success: true, message: 'Claude account updated successfully' })
} catch (error) {
logger.error('❌ Failed to update Claude account:', error)
return res
.status(500)
.json({ error: 'Failed to update Claude account', message: error.message })
}
})
// 删除Claude账户
router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude')
// 获取账户信息以检查是否在分组中
const account = await claudeAccountService.getAccount(accountId)
if (account && account.accountType === 'group') {
const groups = await accountGroupService.getAccountGroups(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
}
await claudeAccountService.deleteAccount(accountId)
let message = 'Claude账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('❌ Failed to delete Claude account:', error)
return res
.status(500)
.json({ error: 'Failed to delete Claude account', message: error.message })
}
})
// 更新单个Claude账户的Profile信息
router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
return res.json({
success: true,
message: 'Account profile updated successfully',
data: profileInfo
})
} catch (error) {
logger.error('❌ Failed to update account profile:', error)
return res
.status(500)
.json({ error: 'Failed to update account profile', message: error.message })
}
})
// 批量更新所有Claude账户的Profile信息
router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => {
try {
const result = await claudeAccountService.updateAllAccountProfiles()
logger.success('✅ Batch profile update completed')
return res.json({
success: true,
message: 'Batch profile update completed',
data: result
})
} catch (error) {
logger.error('❌ Failed to update all account profiles:', error)
return res
.status(500)
.json({ error: 'Failed to update all account profiles', message: error.message })
}
})
// 刷新Claude账户token
router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await claudeAccountService.refreshAccountToken(accountId)
logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to refresh Claude account token:', error)
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
}
})
// 重置Claude账户状态清除所有异常状态
router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await claudeAccountService.resetAccountStatus(accountId)
logger.success(`✅ Admin reset status for Claude account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Claude account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
// 切换Claude账户调度状态
router.put(
'/claude-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const accounts = await claudeAccountService.getAllAccounts()
const account = accounts.find((acc) => acc.id === accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
const newSchedulable = !account.schedulable
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || account.claudeAiOauth?.email || 'Claude Account',
platform: 'claude-oauth',
status: 'disabled',
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${
newSchedulable ? 'schedulable' : 'not schedulable'
}`
)
return res.json({ success: true, schedulable: newSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle Claude account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
}
)
// 测试Claude OAuth账户连通性流式响应- 复用 claudeRelayService
router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
// 直接调用服务层的测试方法
await claudeRelayService.testAccountConnection(accountId, res)
} catch (error) {
logger.error(`❌ Failed to test Claude OAuth account:`, error)
// 错误已在服务层处理,这里仅做日志记录
}
})
module.exports = router

View File

@@ -1,483 +0,0 @@
/**
* Admin Routes - Claude Console 账户管理
* API Key 方式的 Claude Console 账户
*/
const express = require('express')
const router = express.Router()
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
// 获取所有Claude Console账户
router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await claudeConsoleAccountService.getAllAccounts()
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'claude-console') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (statsError) {
logger.warn(
`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`,
statsError.message
)
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
// 转换schedulable为布尔值
schedulable: account.schedulable === 'true' || account.schedulable === true,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
} catch (groupError) {
logger.warn(
`⚠️ Failed to get group info for Claude Console account ${account.id}:`,
groupError.message
)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('❌ Failed to get Claude Console accounts:', error)
return res
.status(500)
.json({ error: 'Failed to get Claude Console accounts', message: error.message })
}
})
// 创建新的Claude Console账户
router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
apiUrl,
apiKey,
priority,
supportedModels,
userAgent,
rateLimitDuration,
proxy,
accountType,
groupId,
dailyQuota,
quotaResetTime,
maxConcurrentTasks
} = req.body
if (!name || !apiUrl || !apiKey) {
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
}
// 验证priority的有效性1-100
if (priority !== undefined && (priority < 1 || priority > 100)) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证maxConcurrentTasks的有效性非负整数
if (maxConcurrentTasks !== undefined && maxConcurrentTasks !== null) {
const concurrent = Number(maxConcurrentTasks)
if (!Number.isInteger(concurrent) || concurrent < 0) {
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
}
}
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果是分组类型验证groupId
if (accountType === 'group' && !groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
const newAccount = await claudeConsoleAccountService.createAccount({
name,
description,
apiUrl,
apiKey,
priority: priority || 50,
supportedModels: supportedModels || [],
userAgent,
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
proxy,
accountType: accountType || 'shared',
dailyQuota: dailyQuota || 0,
quotaResetTime: quotaResetTime || '00:00',
maxConcurrentTasks:
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
if (accountType === 'group' && groupId) {
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude')
}
logger.success(`🎮 Admin created Claude Console account: ${name}`)
const formattedAccount = formatAccountExpiry(newAccount)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('❌ Failed to create Claude Console account:', error)
return res
.status(500)
.json({ error: 'Failed to create Claude Console account', message: error.message })
}
})
// 更新Claude Console账户
router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId)
// 验证priority的有效性1-100
if (
mappedUpdates.priority !== undefined &&
(mappedUpdates.priority < 1 || mappedUpdates.priority > 100)
) {
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
}
// 验证maxConcurrentTasks的有效性非负整数
if (
mappedUpdates.maxConcurrentTasks !== undefined &&
mappedUpdates.maxConcurrentTasks !== null
) {
const concurrent = Number(mappedUpdates.maxConcurrentTasks)
if (!Number.isInteger(concurrent) || concurrent < 0) {
return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' })
}
// 转换为数字类型
mappedUpdates.maxConcurrentTasks = concurrent
}
// 验证accountType的有效性
if (
mappedUpdates.accountType &&
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await claudeConsoleAccountService.getAccount(accountId)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
const oldGroups = await accountGroupService.getAccountGroups(accountId)
for (const oldGroup of oldGroups) {
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
}
}
// 如果新类型是分组,处理多分组支持
if (mappedUpdates.accountType === 'group') {
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
// 如果明确提供了 groupIds 参数(包括空数组)
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
// 设置新的多分组
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude')
} else {
// groupIds 为空数组,从所有分组中移除
await accountGroupService.removeAccountFromAllGroups(accountId)
}
} else if (mappedUpdates.groupId) {
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude')
}
}
}
await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Claude Console account: ${accountId}`)
return res.json({ success: true, message: 'Claude Console account updated successfully' })
} catch (error) {
logger.error('❌ Failed to update Claude Console account:', error)
return res
.status(500)
.json({ error: 'Failed to update Claude Console account', message: error.message })
}
})
// 删除Claude Console账户
router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console')
// 获取账户信息以检查是否在分组中
const account = await claudeConsoleAccountService.getAccount(accountId)
if (account && account.accountType === 'group') {
const groups = await accountGroupService.getAccountGroups(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
}
await claudeConsoleAccountService.deleteAccount(accountId)
let message = 'Claude Console账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(
`🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys`
)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('❌ Failed to delete Claude Console account:', error)
return res
.status(500)
.json({ error: 'Failed to delete Claude Console account', message: error.message })
}
})
// 切换Claude Console账户状态
router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
const newStatus = !account.isActive
await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus })
logger.success(
`🔄 Admin toggled Claude Console account status: ${accountId} -> ${
newStatus ? 'active' : 'inactive'
}`
)
return res.json({ success: true, isActive: newStatus })
} catch (error) {
logger.error('❌ Failed to toggle Claude Console account status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle account status', message: error.message })
}
})
// 切换Claude Console账户调度状态
router.put(
'/claude-console-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
const newSchedulable = !account.schedulable
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'disabled',
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${
newSchedulable ? 'schedulable' : 'not schedulable'
}`
)
return res.json({ success: true, schedulable: newSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle Claude Console account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
}
)
// 获取Claude Console账户的使用统计
router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId)
if (!usageStats) {
return res.status(404).json({ error: 'Account not found' })
}
return res.json(usageStats)
} catch (error) {
logger.error('❌ Failed to get Claude Console account usage stats:', error)
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
}
})
// 手动重置Claude Console账户的每日使用量
router.post(
'/claude-console-accounts/:accountId/reset-usage',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
await claudeConsoleAccountService.resetDailyUsage(accountId)
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
return res.json({ success: true, message: 'Daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
}
}
)
// 重置Claude Console账户状态清除所有异常状态
router.post(
'/claude-console-accounts/:accountId/reset-status',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Claude Console account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
}
)
// 手动重置所有Claude Console账户的每日使用量
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
try {
await claudeConsoleAccountService.resetAllDailyUsage()
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
return res.json({ success: true, message: 'All daily usage reset successfully' })
} catch (error) {
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
return res
.status(500)
.json({ error: 'Failed to reset all daily usage', message: error.message })
}
})
// 测试Claude Console账户连通性流式响应- 复用 claudeConsoleRelayService
router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
// 直接调用服务层的测试方法
await claudeConsoleRelayService.testAccountConnection(accountId, res)
} catch (error) {
logger.error(`❌ Failed to test Claude Console account:`, error)
// 错误已在服务层处理,这里仅做日志记录
}
})
module.exports = router

View File

@@ -1,707 +0,0 @@
const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator')
const pricingService = require('../../services/pricingService')
const config = require('../../../config/config')
const router = express.Router()
// 📊 系统统计
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
const [
,
apiKeys,
claudeAccounts,
claudeConsoleAccounts,
geminiAccounts,
bedrockAccountsResult,
openaiAccounts,
ccrAccounts,
openaiResponsesAccounts,
droidAccounts,
todayStats,
systemAverages,
realtimeMetrics
] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
redis.getAllOpenAIAccounts(),
ccrAccountService.getAllAccounts(),
openaiResponsesAccountService.getAllAccounts(true),
droidAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages(),
redis.getRealtimeSystemMetrics()
])
// 处理Bedrock账户数据
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
const normalizeBoolean = (value) => value === true || value === 'true'
const isRateLimitedFlag = (status) => {
if (!status) {
return false
}
if (typeof status === 'string') {
return status === 'limited'
}
if (typeof status === 'object') {
return status.isRateLimited === true
}
return false
}
const normalDroidAccounts = droidAccounts.filter(
(acc) =>
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
normalizeBoolean(acc.schedulable) &&
!isRateLimitedFlag(acc.rateLimitStatus)
).length
const abnormalDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedDroidAccounts = droidAccounts.filter(
(acc) =>
!normalizeBoolean(acc.schedulable) &&
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
isRateLimitedFlag(acc.rateLimitStatus)
).length
// 计算使用统计统一使用allTokens
const totalTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
0
)
const totalRequestsUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.requests || 0),
0
)
const totalInputTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
0
)
const totalOutputTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
0
)
const totalCacheCreateTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
0
)
const totalCacheReadTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
0
)
const totalAllTokensUsed = apiKeys.reduce(
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
0
)
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
// Claude账户统计 - 根据账户管理页面的判断逻辑
const normalClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalClaudeAccounts = claudeAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedClaudeAccounts = claudeAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// Claude Console账户统计
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// Gemini账户统计
const normalGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(
acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
)
).length
const abnormalGeminiAccounts = geminiAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedGeminiAccounts = geminiAccounts.filter(
(acc) =>
acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
// Bedrock账户统计
const normalBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalBedrockAccounts = bedrockAccounts.filter(
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
).length
const pausedBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.schedulable === false &&
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// OpenAI账户统计
// 注意OpenAI账户的isActive和schedulable是字符串类型默认值为'true'
const normalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== 'false' &&
acc.schedulable !== false && // 包括'true'、true和undefined
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const abnormalOpenAIAccounts = openaiAccounts.filter(
(acc) =>
acc.isActive === 'false' ||
acc.isActive === false ||
acc.status === 'blocked' ||
acc.status === 'unauthorized'
).length
const pausedOpenAIAccounts = openaiAccounts.filter(
(acc) =>
(acc.schedulable === 'false' || acc.schedulable === false) &&
(acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized'
).length
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
).length
// 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 = {
overview: {
totalApiKeys: apiKeys.length,
activeApiKeys,
// 总账户统计(所有平台)
totalAccounts:
claudeAccounts.length +
claudeConsoleAccounts.length +
geminiAccounts.length +
bedrockAccounts.length +
openaiAccounts.length +
openaiResponsesAccounts.length +
ccrAccounts.length,
normalAccounts:
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalOpenAIAccounts +
normalOpenAIResponsesAccounts +
normalCcrAccounts,
abnormalAccounts:
abnormalClaudeAccounts +
abnormalClaudeConsoleAccounts +
abnormalGeminiAccounts +
abnormalBedrockAccounts +
abnormalOpenAIAccounts +
abnormalOpenAIResponsesAccounts +
abnormalCcrAccounts +
abnormalDroidAccounts,
pausedAccounts:
pausedClaudeAccounts +
pausedClaudeConsoleAccounts +
pausedGeminiAccounts +
pausedBedrockAccounts +
pausedOpenAIAccounts +
pausedOpenAIResponsesAccounts +
pausedCcrAccounts +
pausedDroidAccounts,
rateLimitedAccounts:
rateLimitedClaudeAccounts +
rateLimitedClaudeConsoleAccounts +
rateLimitedGeminiAccounts +
rateLimitedBedrockAccounts +
rateLimitedOpenAIAccounts +
rateLimitedOpenAIResponsesAccounts +
rateLimitedCcrAccounts +
rateLimitedDroidAccounts,
// 各平台详细统计
accountsByPlatform: {
claude: {
total: claudeAccounts.length,
normal: normalClaudeAccounts,
abnormal: abnormalClaudeAccounts,
paused: pausedClaudeAccounts,
rateLimited: rateLimitedClaudeAccounts
},
'claude-console': {
total: claudeConsoleAccounts.length,
normal: normalClaudeConsoleAccounts,
abnormal: abnormalClaudeConsoleAccounts,
paused: pausedClaudeConsoleAccounts,
rateLimited: rateLimitedClaudeConsoleAccounts
},
gemini: {
total: geminiAccounts.length,
normal: normalGeminiAccounts,
abnormal: abnormalGeminiAccounts,
paused: pausedGeminiAccounts,
rateLimited: rateLimitedGeminiAccounts
},
bedrock: {
total: bedrockAccounts.length,
normal: normalBedrockAccounts,
abnormal: abnormalBedrockAccounts,
paused: pausedBedrockAccounts,
rateLimited: rateLimitedBedrockAccounts
},
openai: {
total: openaiAccounts.length,
normal: normalOpenAIAccounts,
abnormal: abnormalOpenAIAccounts,
paused: pausedOpenAIAccounts,
rateLimited: rateLimitedOpenAIAccounts
},
ccr: {
total: ccrAccounts.length,
normal: normalCcrAccounts,
abnormal: abnormalCcrAccounts,
paused: pausedCcrAccounts,
rateLimited: rateLimitedCcrAccounts
},
'openai-responses': {
total: openaiResponsesAccounts.length,
normal: normalOpenAIResponsesAccounts,
abnormal: abnormalOpenAIResponsesAccounts,
paused: pausedOpenAIResponsesAccounts,
rateLimited: rateLimitedOpenAIResponsesAccounts
},
droid: {
total: droidAccounts.length,
normal: normalDroidAccounts,
abnormal: abnormalDroidAccounts,
paused: pausedDroidAccounts,
rateLimited: rateLimitedDroidAccounts
}
},
// 保留旧字段以兼容
activeAccounts:
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalOpenAIAccounts +
normalOpenAIResponsesAccounts +
normalCcrAccounts +
normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
totalGeminiAccounts: geminiAccounts.length,
activeGeminiAccounts: normalGeminiAccounts,
rateLimitedGeminiAccounts,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
totalOutputTokensUsed,
totalCacheCreateTokensUsed,
totalCacheReadTokensUsed,
totalAllTokensUsed
},
recentActivity: {
apiKeysCreatedToday: todayStats.apiKeysCreatedToday,
requestsToday: todayStats.requestsToday,
tokensToday: todayStats.tokensToday,
inputTokensToday: todayStats.inputTokensToday,
outputTokensToday: todayStats.outputTokensToday,
cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0,
cacheReadTokensToday: todayStats.cacheReadTokensToday || 0
},
systemAverages: {
rpm: systemAverages.systemRPM,
tpm: systemAverages.systemTPM
},
realtimeMetrics: {
rpm: realtimeMetrics.realtimeRPM,
tpm: realtimeMetrics.realtimeTPM,
windowMinutes: realtimeMetrics.windowMinutes,
isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
geminiAccountsHealthy: normalGeminiAccounts > 0,
droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime()
},
systemTimezone: config.system.timezoneOffset || 8
}
return res.json({ success: true, data: dashboard })
} catch (error) {
logger.error('❌ Failed to get dashboard data:', error)
return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message })
}
})
// 获取使用统计
router.get('/usage-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily' } = req.query // daily, monthly
// 获取基础API Key统计
const apiKeys = await apiKeyService.getAllApiKeys()
const stats = apiKeys.map((key) => ({
keyId: key.id,
keyName: key.name,
usage: key.usage
}))
return res.json({ success: true, data: { period, stats } })
} catch (error) {
logger.error('❌ Failed to get usage stats:', error)
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
}
})
// 获取按模型的使用统计和费用
router.get('/model-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
logger.info(
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
)
const client = redis.getClientSafe()
// 获取所有模型的统计数据
let searchPatterns = []
if (startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式
const start = new Date(startDate)
const end = new Date(endDate)
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
}
// 限制最大范围为365天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
// 生成日期范围内所有日期的搜索模式
const currentDate = new Date(start)
while (currentDate <= end) {
const dateStr = redis.getDateStringInTimezone(currentDate)
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
currentDate.setDate(currentDate.getDate() + 1)
}
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
} else {
// 使用默认的period
const pattern =
period === 'daily'
? `usage:model:daily:*:${today}`
: `usage:model:monthly:*:${currentMonth}`
searchPatterns = [pattern]
}
logger.info('📊 Searching patterns:', searchPatterns)
// 获取所有匹配的keys
const allKeys = []
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern)
allKeys.push(...keys)
}
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
// 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => {
if (!model || model === 'unknown') {
return model
}
// 对于Bedrock模型去掉区域前缀进行统一
if (model.includes('.anthropic.') || model.includes('.claude')) {
// 匹配所有AWS区域格式region.anthropic.model-name-v1:0 -> claude-model-name
// 支持所有AWS区域格式us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等
return normalized
}
// 对于其他模型,去掉常见的版本后缀
return model.replace(/-v\d+:\d+$|:latest$/, '')
}
// 聚合相同模型的数据
const modelStatsMap = new Map()
for (const key of allKeys) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`)
continue
}
const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const stats = modelStatsMap.get(normalizedModel) || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
}
stats.requests += parseInt(data.requests) || 0
stats.inputTokens += parseInt(data.inputTokens) || 0
stats.outputTokens += parseInt(data.outputTokens) || 0
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
stats.allTokens += parseInt(data.allTokens) || 0
modelStatsMap.set(normalizedModel, stats)
}
}
// 转换为数组并计算费用
const modelStats = []
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
}
// 计算费用
const costData = CostCalculator.calculateCost(usage, model)
modelStats.push({
model,
period: startDate && endDate ? 'custom' : period,
requests: stats.requests,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: stats.allTokens,
usage: {
requests: stats.requests,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
totalTokens:
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
},
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
})
}
// 按总费用排序
modelStats.sort((a, b) => b.costs.total - a.costs.total)
logger.info(
`📊 Returning ${modelStats.length} global model stats for period ${period}:`,
modelStats
)
return res.json({ success: true, data: modelStats })
} catch (error) {
logger.error('❌ Failed to get model stats:', error)
return res.status(500).json({ error: 'Failed to get model stats', message: error.message })
}
})
// 🔧 系统管理
// 清理过期数据
router.post('/cleanup', authenticateAdmin, async (req, res) => {
try {
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
])
await redis.cleanup()
logger.success(
`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`
)
return res.json({
success: true,
message: 'Cleanup completed',
data: {
expiredKeysRemoved: expiredKeys,
errorAccountsReset: errorAccounts
}
})
} catch (error) {
logger.error('❌ Cleanup failed:', error)
return res.status(500).json({ error: 'Cleanup failed', message: error.message })
}
})
module.exports = router

View File

@@ -1,527 +0,0 @@
const express = require('express')
const crypto = require('crypto')
const droidAccountService = require('../../services/droidAccountService')
const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const {
startDeviceAuthorization,
pollDeviceAuthorization,
WorkOSDeviceAuthError
} = require('../../utils/workosOAuthHelper')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// ==================== Droid 账户管理 API ====================
// 生成 Droid 设备码授权信息
router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body || {}
const deviceAuth = await startDeviceAuthorization(proxy || null)
const sessionId = crypto.randomUUID()
const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
await redis.setOAuthSession(sessionId, {
deviceCode: deviceAuth.deviceCode,
userCode: deviceAuth.userCode,
verificationUri: deviceAuth.verificationUri,
verificationUriComplete: deviceAuth.verificationUriComplete,
interval: deviceAuth.interval,
proxy: proxy || null,
createdAt: new Date().toISOString(),
expiresAt
})
logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
return res.json({
success: true,
data: {
sessionId,
userCode: deviceAuth.userCode,
verificationUri: deviceAuth.verificationUri,
verificationUriComplete: deviceAuth.verificationUriComplete,
expiresIn: deviceAuth.expiresIn,
interval: deviceAuth.interval,
instructions: [
'1. 使用下方验证码进入授权页面并确认访问权限。',
'2. 在授权页面登录 Factory / Droid 账户并点击允许。',
'3. 回到此处点击"完成授权"完成凭证获取。'
]
}
})
} catch (error) {
const message =
error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
logger.error('❌ 生成 Droid 设备码授权失败:', message)
return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
}
})
// 交换 Droid 授权码
router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
const { sessionId, proxy } = req.body || {}
try {
if (!sessionId) {
return res.status(400).json({ error: 'Session ID is required' })
}
const oauthSession = await redis.getOAuthSession(sessionId)
if (!oauthSession) {
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
}
if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
await redis.deleteOAuthSession(sessionId)
return res
.status(400)
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
}
if (!oauthSession.deviceCode) {
await redis.deleteOAuthSession(sessionId)
return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
}
const proxyConfig = proxy || oauthSession.proxy || null
const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
await redis.deleteOAuthSession(sessionId)
logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
return res.json({ success: true, data: { tokens } })
} catch (error) {
if (error instanceof WorkOSDeviceAuthError) {
if (error.code === 'authorization_pending' || error.code === 'slow_down') {
const oauthSession = await redis.getOAuthSession(sessionId)
const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
const remainingSeconds =
expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
: null
return res.json({
success: false,
pending: true,
error: error.code,
message: error.message,
retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
expiresIn: remainingSeconds
})
}
if (error.code === 'expired_token') {
await redis.deleteOAuthSession(sessionId)
return res.status(400).json({
error: 'Device code expired',
message: '授权已过期,请重新生成设备码并再次授权'
})
}
logger.error('❌ Droid 授权失败:', error.message)
return res.status(500).json({
error: 'Failed to exchange Droid authorization code',
message: error.message,
errorCode: error.code
})
}
logger.error('❌ 交换 Droid 授权码失败:', error)
return res.status(500).json({
error: 'Failed to exchange Droid authorization code',
message: error.message
})
}
})
// 获取所有 Droid 账户
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await droidAccountService.getAllAccounts()
const allApiKeys = await redis.getAllApiKeys()
// 添加使用统计
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (groupError) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
groupInfos = []
}
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (error) {
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
boundApiKeysCount: 0,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0 },
total: { tokens: 0, requests: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('Failed to get Droid accounts:', error)
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
}
})
// 创建 Droid 账户
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
try {
const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body
const normalizedAccountType = rawAccountType || 'shared'
if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) {
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
}
const normalizedGroupIds = Array.isArray(groupIds)
? groupIds.filter((id) => typeof id === 'string' && id.trim())
: []
if (
normalizedAccountType === 'group' &&
normalizedGroupIds.length === 0 &&
(!groupId || typeof groupId !== 'string' || !groupId.trim())
) {
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
}
const accountPayload = {
...req.body,
accountType: normalizedAccountType
}
delete accountPayload.groupId
delete accountPayload.groupIds
const account = await droidAccountService.createAccount(accountPayload)
if (normalizedAccountType === 'group') {
try {
if (normalizedGroupIds.length > 0) {
await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid')
} else if (typeof groupId === 'string' && groupId.trim()) {
await accountGroupService.addAccountToGroup(account.id, groupId, 'droid')
}
} catch (groupError) {
logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError)
return res.status(500).json({
error: 'Failed to bind Droid account to groups',
message: groupError.message
})
}
}
logger.success(`Created Droid account: ${account.name} (${account.id})`)
const formattedAccount = formatAccountExpiry(account)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('Failed to create Droid account:', error)
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
}
})
// 更新 Droid 账户
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = { ...req.body }
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'Droid', id)
const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates
if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) {
return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' })
}
if (
rawAccountType === 'group' &&
(!groupId || typeof groupId !== 'string' || !groupId.trim()) &&
(!Array.isArray(groupIds) || groupIds.length === 0)
) {
return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' })
}
const currentAccount = await droidAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({ error: 'Droid account not found' })
}
const normalizedGroupIds = Array.isArray(groupIds)
? groupIds.filter((gid) => typeof gid === 'string' && gid.trim())
: []
const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')
const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId')
const targetAccountType = rawAccountType || currentAccount.accountType || 'shared'
delete mappedUpdates.groupId
delete mappedUpdates.groupIds
if (rawAccountType) {
mappedUpdates.accountType = targetAccountType
}
const account = await droidAccountService.updateAccount(id, mappedUpdates)
try {
if (currentAccount.accountType === 'group' && targetAccountType !== 'group') {
await accountGroupService.removeAccountFromAllGroups(id)
} else if (targetAccountType === 'group') {
if (hasGroupIdsField) {
if (normalizedGroupIds.length > 0) {
await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid')
} else {
await accountGroupService.removeAccountFromAllGroups(id)
}
} else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) {
await accountGroupService.setAccountGroups(id, [groupId], 'droid')
}
}
} catch (groupError) {
logger.error(`Failed to update Droid account ${id} groups:`, groupError)
return res.status(500).json({
error: 'Failed to update Droid account groups',
message: groupError.message
})
}
if (targetAccountType === 'group') {
try {
account.groupInfos = await accountGroupService.getAccountGroups(id)
} catch (groupFetchError) {
logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError)
}
}
return res.json({ success: true, data: account })
} catch (error) {
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
}
})
// 切换 Droid 账户调度状态
router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await droidAccountService.getAccount(id)
if (!account) {
return res.status(404).json({ error: 'Droid account not found' })
}
const currentSchedulable = account.schedulable === true || account.schedulable === 'true'
const newSchedulable = !currentSchedulable
await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' })
const updatedAccount = await droidAccountService.getAccount(id)
const actualSchedulable = updatedAccount
? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true'
: newSchedulable
if (!actualSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Droid Account',
platform: 'droid',
status: 'disabled',
errorCode: 'DROID_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Droid account schedulable status: ${id} -> ${
actualSchedulable ? 'schedulable' : 'not schedulable'
}`
)
return res.json({ success: true, schedulable: actualSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle Droid account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
})
// 获取单个 Droid 账户详细信息
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
// 获取账户基本信息
const account = await droidAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
error: 'Not Found',
message: 'Droid account not found'
})
}
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
} catch (error) {
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
usageStats = {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
// 获取分组信息
let groupInfos = []
try {
groupInfos = await accountGroupService.getAccountGroups(account.id)
} catch (error) {
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
groupInfos = []
}
// 获取绑定的 API Key 数量
const allApiKeys = await redis.getAllApiKeys()
const groupIds = groupInfos.map((group) => group.id)
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
const binding = key.droidAccountId
if (!binding) {
return count
}
if (binding === account.id) {
return count + 1
}
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
if (groupIds.includes(groupId)) {
return count + 1
}
}
return count
}, 0)
// 获取解密的 API Keys用于管理界面
let decryptedApiKeys = []
try {
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
} catch (error) {
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
decryptedApiKeys = []
}
// 返回完整的账户信息,包含实际的 API Keys
const accountDetails = {
...account,
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
expiresAt: account.subscriptionExpiresAt || null,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
// 包含实际的 API Keys用于管理界面
apiKeys: decryptedApiKeys.map((entry) => ({
key: entry.key,
id: entry.id,
usageCount: entry.usageCount || 0,
lastUsedAt: entry.lastUsedAt || null,
status: entry.status || 'active', // 使用实际的状态,默认为 active
errorMessage: entry.errorMessage || '', // 包含错误信息
createdAt: entry.createdAt || null
})),
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
return res.json({
success: true,
data: accountDetails
})
} catch (error) {
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
return res.status(500).json({
error: 'Failed to get Droid account',
message: error.message
})
}
})
// 删除 Droid 账户
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await droidAccountService.deleteAccount(id)
return res.json({ success: true, message: 'Droid account deleted successfully' })
} catch (error) {
logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
}
})
// 刷新 Droid 账户 token
router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await droidAccountService.refreshAccessToken(id)
return res.json({ success: true, data: result })
} catch (error) {
logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
}
})
module.exports = router

View File

@@ -1,494 +0,0 @@
const express = require('express')
const geminiAccountService = require('../../services/geminiAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// 🤖 Gemini OAuth 账户管理
// 生成 Gemini OAuth 授权 URL
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { state, proxy } = req.body // 接收代理配置
// 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode'
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
const {
authUrl,
state: authState,
codeVerifier,
redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState
await redis.setOAuthSession(sessionId, {
state: authState,
type: 'gemini',
redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
createdAt: new Date().toISOString()
})
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`)
return res.json({
success: true,
data: {
authUrl,
sessionId
}
})
} catch (error) {
logger.error('❌ Failed to generate Gemini auth URL:', error)
return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message })
}
})
// 轮询 Gemini OAuth 授权状态
router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
try {
const { sessionId } = req.body
if (!sessionId) {
return res.status(400).json({ error: 'Session ID is required' })
}
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
if (result.success) {
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
return res.json({ success: true, data: { tokens: result.tokens } })
} else {
return res.json({ success: false, error: result.error })
}
} catch (error) {
logger.error('❌ Failed to poll Gemini auth status:', error)
return res.status(500).json({ error: 'Failed to poll auth status', message: error.message })
}
})
// 交换 Gemini 授权码
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId, proxy: requestProxy } = req.body
if (!code) {
return res.status(400).json({ error: 'Authorization code is required' })
}
let redirectUri = 'https://codeassist.google.com/authcode'
let codeVerifier = null
let proxyConfig = null
// 如果提供了 sessionId从 OAuth 会话中获取信息
if (sessionId) {
const sessionData = await redis.getOAuthSession(sessionId)
if (sessionData) {
const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
} = sessionData
redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
logger.info(
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
)
}
}
// 如果请求体中直接提供了代理配置,优先使用它
if (requestProxy) {
proxyConfig = requestProxy
logger.info(
`Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}`
)
}
const tokens = await geminiAccountService.exchangeCodeForTokens(
code,
redirectUri,
codeVerifier,
proxyConfig // 传递代理配置
)
// 清理 OAuth 会话
if (sessionId) {
await redis.deleteOAuthSession(sessionId)
}
logger.success('✅ Successfully exchanged Gemini authorization code')
return res.json({ success: true, data: { tokens } })
} catch (error) {
logger.error('❌ Failed to exchange Gemini authorization code:', error)
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
}
})
// 获取所有 Gemini 账户
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await geminiAccountService.getAllAccounts()
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'gemini') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await accountGroupService.getAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息与Claude账户相同的逻辑
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages
}
}
} catch (statsError) {
logger.warn(
`⚠️ Failed to get usage stats for Gemini account ${account.id}:`,
statsError.message
)
// 如果获取统计失败,返回空统计
try {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
} catch (groupError) {
logger.warn(
`⚠️ Failed to get group info for account ${account.id}:`,
groupError.message
)
return {
...account,
groupInfos: [],
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
total: { tokens: 0, requests: 0, allTokens: 0 },
averages: { rpm: 0, tpm: 0 }
}
}
}
}
})
)
return res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('❌ Failed to get Gemini accounts:', error)
return res.status(500).json({ error: 'Failed to get accounts', message: error.message })
}
})
// 创建新的 Gemini 账户
router.post('/', authenticateAdmin, async (req, res) => {
try {
const accountData = req.body
// 输入验证
if (!accountData.name) {
return res.status(400).json({ error: 'Account name is required' })
}
// 验证accountType的有效性
if (
accountData.accountType &&
!['shared', 'dedicated', 'group'].includes(accountData.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果是分组类型验证groupId或groupIds
if (
accountData.accountType === 'group' &&
!accountData.groupId &&
(!accountData.groupIds || accountData.groupIds.length === 0)
) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
const newAccount = await geminiAccountService.createAccount(accountData)
// 如果是分组类型,处理分组绑定
if (accountData.accountType === 'group') {
if (accountData.groupIds && accountData.groupIds.length > 0) {
// 多分组模式
await accountGroupService.setAccountGroups(newAccount.id, accountData.groupIds, 'gemini')
logger.info(
`🏢 Added Gemini account ${newAccount.id} to groups: ${accountData.groupIds.join(', ')}`
)
} else if (accountData.groupId) {
// 单分组模式(向后兼容)
await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini')
}
}
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`)
const formattedAccount = formatAccountExpiry(newAccount)
return res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('❌ Failed to create Gemini account:', error)
return res.status(500).json({ error: 'Failed to create account', message: error.message })
}
})
// 更新 Gemini 账户
router.put('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const updates = req.body
// 验证accountType的有效性
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId或groupIds
if (
updates.accountType === 'group' &&
!updates.groupId &&
(!updates.groupIds || updates.groupIds.length === 0)
) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await geminiAccountService.getAccount(accountId)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId)
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
const oldGroups = await accountGroupService.getAccountGroups(accountId)
for (const oldGroup of oldGroups) {
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
}
}
// 如果新类型是分组,处理多分组支持
if (mappedUpdates.accountType === 'group') {
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
// 如果明确提供了 groupIds 参数(包括空数组)
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
// 设置新的多分组
await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini')
} else {
// groupIds 为空数组,从所有分组中移除
await accountGroupService.removeAccountFromAllGroups(accountId)
}
} else if (mappedUpdates.groupId) {
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini')
}
}
}
const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Gemini account: ${accountId}`)
return res.json({ success: true, data: updatedAccount })
} catch (error) {
logger.error('❌ Failed to update Gemini account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
}
})
// 删除 Gemini 账户
router.delete('/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini')
// 获取账户信息以检查是否在分组中
const account = await geminiAccountService.getAccount(accountId)
if (account && account.accountType === 'group') {
const groups = await accountGroupService.getAccountGroups(accountId)
for (const group of groups) {
await accountGroupService.removeAccountFromGroup(accountId, group.id)
}
}
await geminiAccountService.deleteAccount(accountId)
let message = 'Gemini账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('❌ Failed to delete Gemini account:', error)
return res.status(500).json({ error: 'Failed to delete account', message: error.message })
}
})
// 刷新 Gemini 账户 token
router.post('/:accountId/refresh', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await geminiAccountService.refreshAccountToken(accountId)
logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to refresh Gemini account token:', error)
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
}
})
// 切换 Gemini 账户调度状态
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
// 现在 account.schedulable 已经是布尔值了,直接取反即可
const newSchedulable = !account.schedulable
await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) })
// 验证更新是否成功,重新获取账户信息
const updatedAccount = await geminiAccountService.getAccount(accountId)
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
// 如果账号被禁用发送webhook通知
if (!actualSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.accountName || 'Gemini Account',
platform: 'gemini',
status: 'disabled',
errorCode: 'GEMINI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success(
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${
actualSchedulable ? 'schedulable' : 'not schedulable'
}`
)
// 返回实际的数据库值,确保前端状态与后端一致
return res.json({ success: true, schedulable: actualSchedulable })
} catch (error) {
logger.error('❌ Failed to toggle Gemini account schedulable status:', error)
return res
.status(500)
.json({ error: 'Failed to toggle schedulable status', message: error.message })
}
})
// 重置 Gemini OAuth 账户限流状态
router.post('/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await geminiAccountService.updateAccount(id, {
rateLimitedAt: '',
rateLimitStatus: '',
status: 'active',
errorMessage: ''
})
logger.info(`🔄 Admin manually reset rate limit for Gemini account ${id}`)
res.json({
success: true,
message: 'Rate limit reset successfully'
})
} catch (error) {
logger.error('Failed to reset Gemini account rate limit:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 重置 Gemini OAuth 账户状态(清除所有异常状态)
router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await geminiAccountService.resetAccountStatus(id)
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Gemini account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
module.exports = router

View File

@@ -1,400 +0,0 @@
const express = require('express')
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const router = express.Router()
// 获取所有 Gemini-API 账户
router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await geminiApiAccountService.getAllAccounts(true)
// 根据查询参数进行筛选
if (platform && platform !== 'gemini-api') {
accounts = []
}
// 根据分组ID筛选
if (groupId) {
const group = await accountGroupService.getGroup(groupId)
if (group && group.platform === 'gemini') {
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
} else {
accounts = []
}
}
// 处理使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
// 检查并清除过期的限流状态
await geminiApiAccountService.checkAndClearRateLimit(account.id)
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
} catch (error) {
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
usageStats = {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
// 计算绑定的API Key数量支持 api: 前缀)
const allKeys = await redis.getAllApiKeys()
let boundCount = 0
for (const key of allKeys) {
if (key.geminiAccountId) {
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
if (key.geminiAccountId === `api:${account.id}`) {
boundCount++
}
}
}
// 获取分组信息
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
averages: usageStats.averages || usageStats.monthly
},
boundApiKeys: boundCount
}
})
)
res.json({ success: true, data: accountsWithStats })
} catch (error) {
logger.error('Failed to get Gemini-API accounts:', error)
res.status(500).json({ success: false, message: error.message })
}
})
// 创建 Gemini-API 账户
router.post('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
try {
const { accountType, groupId, groupIds } = req.body
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res.status(400).json({
success: false,
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
})
}
// 如果是分组类型验证groupId或groupIds
if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) {
return res.status(400).json({
success: false,
error: 'Group ID or Group IDs are required for group type accounts'
})
}
const account = await geminiApiAccountService.createAccount(req.body)
// 如果是分组类型,将账户添加到分组
if (accountType === 'group') {
if (groupIds && groupIds.length > 0) {
// 使用多分组设置
await accountGroupService.setAccountGroups(account.id, groupIds, 'gemini')
} else if (groupId) {
// 兼容单分组模式
await accountGroupService.addAccountToGroup(account.id, groupId, 'gemini')
}
}
logger.success(
`🏢 Admin created new Gemini-API account: ${account.name} (${accountType || 'shared'})`
)
res.json({ success: true, data: account })
} catch (error) {
logger.error('Failed to create Gemini-API account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 获取单个 Gemini-API 账户
router.get('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await geminiApiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
// 隐藏敏感信息
account.apiKey = '***'
res.json({ success: true, data: account })
} catch (error) {
logger.error('Failed to get Gemini-API account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 更新 Gemini-API 账户
router.put('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// 验证priority的有效性1-100
if (updates.priority !== undefined) {
const priority = parseInt(updates.priority)
if (isNaN(priority) || priority < 1 || priority > 100) {
return res.status(400).json({
success: false,
message: 'Priority must be a number between 1 and 100'
})
}
}
// 验证accountType的有效性
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
return res.status(400).json({
success: false,
error: 'Invalid account type. Must be "shared", "dedicated" or "group"'
})
}
// 如果更新为分组类型验证groupId或groupIds
if (
updates.accountType === 'group' &&
!updates.groupId &&
(!updates.groupIds || updates.groupIds.length === 0)
) {
return res.status(400).json({
success: false,
error: 'Group ID or Group IDs are required for group type accounts'
})
}
// 获取账户当前信息以处理分组变更
const currentAccount = await geminiApiAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({
success: false,
error: 'Account not found'
})
}
// 处理分组的变更
if (updates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
await accountGroupService.removeAccountFromAllGroups(id)
}
// 如果新类型是分组,添加到新分组
if (updates.accountType === 'group') {
// 处理多分组/单分组的兼容性
if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) {
if (updates.groupIds && updates.groupIds.length > 0) {
// 使用多分组设置
await accountGroupService.setAccountGroups(id, updates.groupIds, 'gemini')
}
} else if (updates.groupId) {
// 兼容单分组模式
await accountGroupService.addAccountToGroup(id, updates.groupId, 'gemini')
}
}
}
const result = await geminiApiAccountService.updateAccount(id, updates)
if (!result.success) {
return res.status(400).json(result)
}
logger.success(`📝 Admin updated Gemini-API account: ${currentAccount.name}`)
res.json({ success: true, ...result })
} catch (error) {
logger.error('Failed to update Gemini-API account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 删除 Gemini-API 账户
router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await geminiApiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
// 自动解绑所有绑定的 API Keys支持 api: 前缀)
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'gemini-api')
// 从所有分组中移除此账户
if (account.accountType === 'group') {
await accountGroupService.removeAccountFromAllGroups(id)
logger.info(`Removed Gemini-API account ${id} from all groups`)
}
const result = await geminiApiAccountService.deleteAccount(id)
let message = 'Gemini-API账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`${message}`)
res.json({
success: true,
...result,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('Failed to delete Gemini-API account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 切换 Gemini-API 账户调度状态
router.put('/gemini-api-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await geminiApiAccountService.toggleSchedulable(id)
if (!result.success) {
return res.status(400).json(result)
}
// 仅在停止调度时发送通知
if (!result.schedulable) {
await webhookNotifier.sendAccountEvent('account.status_changed', {
accountId: id,
platform: 'gemini-api',
schedulable: result.schedulable,
changedBy: 'admin',
action: 'stopped_scheduling'
})
}
res.json(result)
} catch (error) {
logger.error('Failed to toggle Gemini-API account schedulable status:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 切换 Gemini-API 账户激活状态
router.put('/gemini-api-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await geminiApiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
await geminiApiAccountService.updateAccount(id, {
isActive: newActiveStatus
})
res.json({
success: true,
isActive: newActiveStatus === 'true'
})
} catch (error) {
logger.error('Failed to toggle Gemini-API account status:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 重置 Gemini-API 账户限流状态
router.post('/gemini-api-accounts/:id/reset-rate-limit', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await geminiApiAccountService.updateAccount(id, {
rateLimitedAt: '',
rateLimitStatus: '',
status: 'active',
errorMessage: ''
})
logger.info(`🔄 Admin manually reset rate limit for Gemini-API account ${id}`)
res.json({
success: true,
message: 'Rate limit reset successfully'
})
} catch (error) {
logger.error('Failed to reset Gemini-API account rate limit:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 重置 Gemini-API 账户状态(清除所有异常状态)
router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await geminiApiAccountService.resetAccountStatus(id)
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Gemini-API account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
module.exports = router

View File

@@ -1,46 +0,0 @@
/**
* Admin Routes - 主入口文件
* 导入并挂载所有子路由模块
*/
const express = require('express')
const router = express.Router()
// 导入所有子路由
const apiKeysRoutes = require('./apiKeys')
const accountGroupsRoutes = require('./accountGroups')
const claudeAccountsRoutes = require('./claudeAccounts')
const claudeConsoleAccountsRoutes = require('./claudeConsoleAccounts')
const ccrAccountsRoutes = require('./ccrAccounts')
const bedrockAccountsRoutes = require('./bedrockAccounts')
const geminiAccountsRoutes = require('./geminiAccounts')
const geminiApiAccountsRoutes = require('./geminiApiAccounts')
const openaiAccountsRoutes = require('./openaiAccounts')
const azureOpenaiAccountsRoutes = require('./azureOpenaiAccounts')
const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
const droidAccountsRoutes = require('./droidAccounts')
const dashboardRoutes = require('./dashboard')
const usageStatsRoutes = require('./usageStats')
const systemRoutes = require('./system')
// 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径)
router.use('/', apiKeysRoutes)
router.use('/', claudeAccountsRoutes)
router.use('/', claudeConsoleAccountsRoutes)
router.use('/', geminiApiAccountsRoutes)
router.use('/', azureOpenaiAccountsRoutes)
router.use('/', openaiResponsesAccountsRoutes)
router.use('/', droidAccountsRoutes)
router.use('/', dashboardRoutes)
router.use('/', usageStatsRoutes)
router.use('/', systemRoutes)
// 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes)
router.use('/ccr-accounts', ccrAccountsRoutes)
router.use('/bedrock-accounts', bedrockAccountsRoutes)
router.use('/gemini-accounts', geminiAccountsRoutes)
router.use('/openai-accounts', openaiAccountsRoutes)
module.exports = router

View File

@@ -1,805 +0,0 @@
/**
* Admin Routes - OpenAI 账户管理
* 处理 OpenAI 账户的 CRUD 操作和 OAuth 授权流程
*/
const express = require('express')
const crypto = require('crypto')
const axios = require('axios')
const openaiAccountService = require('../../services/openaiAccountService')
const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const ProxyHelper = require('../../utils/proxyHelper')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// OpenAI OAuth 配置
const OPENAI_CONFIG = {
BASE_URL: 'https://auth.openai.com',
CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
REDIRECT_URI: 'http://localhost:1455/auth/callback',
SCOPE: 'openid profile email offline_access'
}
/**
* 生成 PKCE 参数
* @returns {Object} 包含 codeVerifier 和 codeChallenge 的对象
*/
function generateOpenAIPKCE() {
const codeVerifier = crypto.randomBytes(64).toString('hex')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
return {
codeVerifier,
codeChallenge
}
}
// 生成 OpenAI OAuth 授权 URL
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body
// 生成 PKCE 参数
const pkce = generateOpenAIPKCE()
// 生成随机 state
const state = crypto.randomBytes(32).toString('hex')
// 创建会话 ID
const sessionId = crypto.randomUUID()
// 将 PKCE 参数和代理配置存储到 Redis
await redis.setOAuthSession(sessionId, {
codeVerifier: pkce.codeVerifier,
codeChallenge: pkce.codeChallenge,
state,
proxy: proxy || null,
platform: 'openai',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString()
})
// 构建授权 URL 参数
const params = new URLSearchParams({
response_type: 'code',
client_id: OPENAI_CONFIG.CLIENT_ID,
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
scope: OPENAI_CONFIG.SCOPE,
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state,
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true'
})
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
logger.success('🔗 Generated OpenAI OAuth authorization URL')
return res.json({
success: true,
data: {
authUrl,
sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 OpenAI 账户',
'3. 同意应用权限',
'4. 复制浏览器地址栏中的完整 URL包含 code 参数)',
'5. 在添加账户表单中粘贴完整的回调 URL'
]
}
})
} catch (error) {
logger.error('生成 OpenAI OAuth URL 失败:', error)
return res.status(500).json({
success: false,
message: '生成授权链接失败',
error: error.message
})
}
})
// 交换 OpenAI 授权码
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId } = req.body
if (!code || !sessionId) {
return res.status(400).json({
success: false,
message: '缺少必要参数'
})
}
// 从 Redis 获取会话数据
const sessionData = await redis.getOAuthSession(sessionId)
if (!sessionData) {
return res.status(400).json({
success: false,
message: '会话已过期或无效'
})
}
// 准备 token 交换请求
const tokenData = {
grant_type: 'authorization_code',
code: code.trim(),
redirect_uri: OPENAI_CONFIG.REDIRECT_URI,
client_id: OPENAI_CONFIG.CLIENT_ID,
code_verifier: sessionData.codeVerifier
}
logger.info('Exchanging OpenAI authorization code:', {
sessionId,
codeLength: code.length,
hasCodeVerifier: !!sessionData.codeVerifier
})
// 配置代理(如果有)
const axiosConfig = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
if (proxyAgent) {
axiosConfig.httpAgent = proxyAgent
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
}
// 交换 authorization code 获取 tokens
const tokenResponse = await axios.post(
`${OPENAI_CONFIG.BASE_URL}/oauth/token`,
new URLSearchParams(tokenData).toString(),
axiosConfig
)
const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data
// 解析 ID token 获取用户信息
const idTokenParts = id_token.split('.')
if (idTokenParts.length !== 3) {
throw new Error('Invalid ID token format')
}
// 解码 JWT payload
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString())
// 获取 OpenAI 特定的声明
const authClaims = payload['https://api.openai.com/auth'] || {}
const accountId = authClaims.chatgpt_account_id || ''
const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || ''
const planType = authClaims.chatgpt_plan_type || ''
// 获取组织信息
const organizations = authClaims.organizations || []
const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {}
const organizationId = defaultOrg.id || ''
const organizationRole = defaultOrg.role || ''
const organizationTitle = defaultOrg.title || ''
// 清理 Redis 会话
await redis.deleteOAuthSession(sessionId)
logger.success('✅ OpenAI OAuth token exchange successful')
return res.json({
success: true,
data: {
tokens: {
idToken: id_token,
accessToken: access_token,
refreshToken: refresh_token,
expires_in
},
accountInfo: {
accountId,
chatgptUserId,
organizationId,
organizationRole,
organizationTitle,
planType,
email: payload.email || '',
name: payload.name || '',
emailVerified: payload.email_verified || false,
organizations
}
}
})
} catch (error) {
logger.error('OpenAI OAuth token exchange failed:', error)
return res.status(500).json({
success: false,
message: '交换授权码失败',
error: error.message
})
}
})
// 获取所有 OpenAI 账户
router.get('/', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await openaiAccountService.getAllAccounts()
// 缓存账户所属分组,避免重复查询
const accountGroupCache = new Map()
const fetchAccountGroups = async (accountId) => {
if (!accountGroupCache.has(accountId)) {
const groups = await accountGroupService.getAccountGroups(accountId)
accountGroupCache.set(accountId, groups || [])
}
return accountGroupCache.get(accountId)
}
// 根据查询参数进行筛选
if (platform && platform !== 'all' && platform !== 'openai') {
// 如果指定了其他平台,返回空数组
accounts = []
}
// 如果指定了分组筛选
if (groupId && groupId !== 'all') {
if (groupId === 'ungrouped') {
// 筛选未分组账户
const filteredAccounts = []
for (const account of accounts) {
const groups = await fetchAccountGroups(account.id)
if (!groups || groups.length === 0) {
filteredAccounts.push(account)
}
}
accounts = filteredAccounts
} else {
// 筛选特定分组的账户
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
}
}
// 为每个账户添加使用统计信息
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: usageStats.daily,
total: usageStats.total,
monthly: usageStats.monthly
}
}
} catch (error) {
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
const groupInfos = await fetchAccountGroups(account.id)
const formattedAccount = formatAccountExpiry(account)
return {
...formattedAccount,
groupInfos,
usage: {
daily: { requests: 0, tokens: 0, allTokens: 0 },
total: { requests: 0, tokens: 0, allTokens: 0 },
monthly: { requests: 0, tokens: 0, allTokens: 0 }
}
}
}
})
)
logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`)
return res.json({
success: true,
data: accountsWithStats
})
} catch (error) {
logger.error('获取 OpenAI 账户列表失败:', error)
return res.status(500).json({
success: false,
message: '获取账户列表失败',
error: error.message
})
}
})
// 创建 OpenAI 账户
router.post('/', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
openaiOauth,
accountInfo,
proxy,
accountType,
groupId,
rateLimitDuration,
priority,
needsImmediateRefresh, // 是否需要立即刷新
requireRefreshSuccess // 是否必须刷新成功才能创建
} = req.body
if (!name) {
return res.status(400).json({
success: false,
message: '账户名称不能为空'
})
}
// 准备账户数据
const accountData = {
name,
description: description || '',
accountType: accountType || 'shared',
priority: priority || 50,
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
openaiOauth: openaiOauth || {},
accountInfo: accountInfo || {},
proxy: proxy || null,
isActive: true,
schedulable: true
}
// 如果需要立即刷新且必须成功OpenAI 手动模式)
if (needsImmediateRefresh && requireRefreshSuccess) {
// 先创建临时账户以测试刷新
const tempAccount = await openaiAccountService.createAccount(accountData)
try {
logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`)
// 尝试刷新 token会自动使用账户配置的代理
await openaiAccountService.refreshAccountToken(tempAccount.id)
// 刷新成功,获取更新后的账户信息
const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id)
// 检查是否获取到了 ID Token
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
// 没有获取到 ID Token删除账户
await openaiAccountService.deleteAccount(tempAccount.id)
throw new Error('无法获取 ID Token请检查 Refresh Token 是否有效')
}
// 如果是分组类型,添加到分组
if (accountType === 'group' && groupId) {
await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai')
}
// 清除敏感信息后返回
delete refreshedAccount.idToken
delete refreshedAccount.accessToken
delete refreshedAccount.refreshToken
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
return res.json({
success: true,
data: refreshedAccount,
message: '账户创建成功,并已获取完整 token 信息'
})
} catch (refreshError) {
// 刷新失败,删除临时创建的账户
logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`)
await openaiAccountService.deleteAccount(tempAccount.id)
// 构建详细的错误信息
const errorResponse = {
success: false,
message: '账户创建失败',
error: refreshError.message
}
// 添加更详细的错误信息
if (refreshError.status) {
errorResponse.errorCode = refreshError.status
}
if (refreshError.details) {
errorResponse.errorDetails = refreshError.details
}
if (refreshError.code) {
errorResponse.networkError = refreshError.code
}
// 提供更友好的错误提示
if (refreshError.message.includes('Refresh Token 无效')) {
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
} else if (refreshError.message.includes('代理')) {
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
} else if (refreshError.message.includes('过于频繁')) {
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
} else if (refreshError.message.includes('连接')) {
errorResponse.suggestion = '请检查网络连接和代理设置'
}
return res.status(400).json(errorResponse)
}
}
// 不需要强制刷新的情况OAuth 模式或其他平台)
const createdAccount = await openaiAccountService.createAccount(accountData)
// 如果是分组类型,添加到分组
if (accountType === 'group' && groupId) {
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
}
// 如果需要刷新但不强制成功OAuth 模式可能已有完整信息)
if (needsImmediateRefresh && !requireRefreshSuccess) {
try {
logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`)
await openaiAccountService.refreshAccountToken(createdAccount.id)
logger.info(`✅ 刷新成功`)
} catch (refreshError) {
logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`)
}
}
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
return res.json({
success: true,
data: createdAccount
})
} catch (error) {
logger.error('创建 OpenAI 账户失败:', error)
return res.status(500).json({
success: false,
message: '创建账户失败',
error: error.message
})
}
})
// 更新 OpenAI 账户
router.put('/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'OpenAI', id)
const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates
// 验证accountType的有效性
if (
mappedUpdates.accountType &&
!['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType)
) {
return res
.status(400)
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
}
// 如果更新为分组类型验证groupId
if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) {
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
}
// 获取账户当前信息以处理分组变更
const currentAccount = await openaiAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({ error: 'Account not found' })
}
// 如果更新了 Refresh Token需要验证其有效性
if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) {
// 先更新 token 信息
const tempUpdateData = {}
if (mappedUpdates.openaiOauth.refreshToken) {
tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
}
if (mappedUpdates.openaiOauth.accessToken) {
tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken
}
// 更新代理配置(如果有)
if (mappedUpdates.proxy !== undefined) {
tempUpdateData.proxy = mappedUpdates.proxy
}
// 临时更新账户以测试新的 token
await openaiAccountService.updateAccount(id, tempUpdateData)
try {
logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`)
// 尝试刷新 token会使用账户配置的代理
await openaiAccountService.refreshAccountToken(id)
// 获取刷新后的账户信息
const refreshedAccount = await openaiAccountService.getAccount(id)
// 检查是否获取到了 ID Token
if (!refreshedAccount.idToken || refreshedAccount.idToken === '') {
// 恢复原始 token
await openaiAccountService.updateAccount(id, {
refreshToken: currentAccount.refreshToken,
accessToken: currentAccount.accessToken,
idToken: currentAccount.idToken
})
return res.status(400).json({
success: false,
message: '无法获取 ID Token请检查 Refresh Token 是否有效',
error: 'Invalid refresh token'
})
}
logger.success(`✅ Token 验证成功,继续更新账户信息`)
} catch (refreshError) {
// 刷新失败,恢复原始 token
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
await openaiAccountService.updateAccount(id, {
refreshToken: currentAccount.refreshToken,
accessToken: currentAccount.accessToken,
idToken: currentAccount.idToken,
proxy: currentAccount.proxy
})
// 构建详细的错误信息
const errorResponse = {
success: false,
message: '更新失败',
error: refreshError.message
}
// 添加更详细的错误信息
if (refreshError.status) {
errorResponse.errorCode = refreshError.status
}
if (refreshError.details) {
errorResponse.errorDetails = refreshError.details
}
if (refreshError.code) {
errorResponse.networkError = refreshError.code
}
// 提供更友好的错误提示
if (refreshError.message.includes('Refresh Token 无效')) {
errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取'
} else if (refreshError.message.includes('代理')) {
errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息'
} else if (refreshError.message.includes('过于频繁')) {
errorResponse.suggestion = '请稍后再试,或更换代理 IP'
} else if (refreshError.message.includes('连接')) {
errorResponse.suggestion = '请检查网络连接和代理设置'
}
return res.status(400).json(errorResponse)
}
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从原分组中移除
if (currentAccount.accountType === 'group') {
const oldGroup = await accountGroupService.getAccountGroup(id)
if (oldGroup) {
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
}
}
// 如果新类型是分组,添加到新分组
if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) {
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
}
}
// 准备更新数据
const updateData = { ...mappedUpdates }
// 处理敏感数据加密
if (mappedUpdates.openaiOauth) {
updateData.openaiOauth = mappedUpdates.openaiOauth
// 编辑时不允许直接输入 ID Token只能通过刷新获取
if (mappedUpdates.openaiOauth.accessToken) {
updateData.accessToken = mappedUpdates.openaiOauth.accessToken
}
if (mappedUpdates.openaiOauth.refreshToken) {
updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken
}
if (mappedUpdates.openaiOauth.expires_in) {
updateData.expiresAt = new Date(
Date.now() + mappedUpdates.openaiOauth.expires_in * 1000
).toISOString()
}
}
// 更新账户信息
if (mappedUpdates.accountInfo) {
updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId
updateData.chatgptUserId =
mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
updateData.organizationId =
mappedUpdates.accountInfo.organizationId || currentAccount.organizationId
updateData.organizationRole =
mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole
updateData.organizationTitle =
mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle
updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType
updateData.email = mappedUpdates.accountInfo.email || currentAccount.email
updateData.emailVerified =
mappedUpdates.accountInfo.emailVerified !== undefined
? mappedUpdates.accountInfo.emailVerified
: currentAccount.emailVerified
}
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
// 如果需要刷新但不强制成功(非关键更新)
if (needsImmediateRefresh && !requireRefreshSuccess) {
try {
logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`)
await openaiAccountService.refreshAccountToken(id)
logger.info(`✅ 刷新成功`)
} catch (refreshError) {
logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`)
}
}
logger.success(`📝 Admin updated OpenAI account: ${id}`)
return res.json({ success: true, data: updatedAccount })
} catch (error) {
logger.error('❌ Failed to update OpenAI account:', error)
return res.status(500).json({ error: 'Failed to update account', message: error.message })
}
})
// 删除 OpenAI 账户
router.delete('/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await openaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: '账户不存在'
})
}
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai')
// 如果账户在分组中,从分组中移除
if (account.accountType === 'group') {
const group = await accountGroupService.getAccountGroup(id)
if (group) {
await accountGroupService.removeAccountFromGroup(id, group.id)
}
}
await openaiAccountService.deleteAccount(id)
let message = 'OpenAI账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(
`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys`
)
return res.json({
success: true,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('删除 OpenAI 账户失败:', error)
return res.status(500).json({
success: false,
message: '删除账户失败',
error: error.message
})
}
})
// 切换 OpenAI 账户状态
router.put('/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await redis.getOpenAiAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: '账户不存在'
})
}
// 切换启用状态
account.enabled = !account.enabled
account.updatedAt = new Date().toISOString()
// TODO: 更新方法
// await redis.updateOpenAiAccount(id, account)
logger.success(
`${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})`
)
return res.json({
success: true,
data: account
})
} catch (error) {
logger.error('切换 OpenAI 账户状态失败:', error)
return res.status(500).json({
success: false,
message: '切换账户状态失败',
error: error.message
})
}
})
// 重置 OpenAI 账户状态(清除所有异常状态)
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await openaiAccountService.resetAccountStatus(accountId)
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset OpenAI account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
// 切换 OpenAI 账户调度状态
router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await openaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await redis.getOpenAiAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'OpenAI Account',
platform: 'openai',
status: 'disabled',
errorCode: 'OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({
success: result.success,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
})
module.exports = router

View File

@@ -1,450 +0,0 @@
/**
* Admin Routes - OpenAI-Responses 账户管理
* 处理 OpenAI-Responses 账户的增删改查和状态管理
*/
const express = require('express')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// ==================== OpenAI-Responses 账户管理 API ====================
// 获取所有 OpenAI-Responses 账户
router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
try {
const { platform, groupId } = req.query
let accounts = await openaiResponsesAccountService.getAllAccounts(true)
// 根据查询参数进行筛选
if (platform && platform !== 'openai-responses') {
accounts = []
}
// 根据分组ID筛选
if (groupId) {
const group = await accountGroupService.getGroup(groupId)
if (group && group.platform === 'openai') {
const groupMembers = await accountGroupService.getGroupMembers(groupId)
accounts = accounts.filter((account) => groupMembers.includes(account.id))
} else {
accounts = []
}
}
// 处理额度信息、使用统计和绑定的 API Key 数量
const accountsWithStats = await Promise.all(
accounts.map(async (account) => {
try {
// 检查是否需要重置额度
const today = redis.getDateStringInTimezone()
if (account.lastResetDate !== today) {
// 今天还没重置过,需要重置
await openaiResponsesAccountService.updateAccount(account.id, {
dailyUsage: '0',
lastResetDate: today,
quotaStoppedAt: ''
})
account.dailyUsage = '0'
account.lastResetDate = today
account.quotaStoppedAt = ''
}
// 检查并清除过期的限流状态
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
// 获取使用统计信息
let usageStats
try {
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
} catch (error) {
logger.debug(
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
error
)
usageStats = {
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 })
} catch (error) {
logger.error('Failed to get OpenAI-Responses accounts:', error)
res.status(500).json({ success: false, message: error.message })
}
})
// 创建 OpenAI-Responses 账户
router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => {
try {
const accountData = req.body
// 验证分组类型
if (
accountData.accountType === 'group' &&
!accountData.groupId &&
(!accountData.groupIds || accountData.groupIds.length === 0)
) {
return res.status(400).json({
success: false,
error: 'Group ID is required for group type accounts'
})
}
const account = await openaiResponsesAccountService.createAccount(accountData)
// 如果是分组类型,处理分组绑定
if (accountData.accountType === 'group') {
if (accountData.groupIds && accountData.groupIds.length > 0) {
// 多分组模式
await accountGroupService.setAccountGroups(account.id, accountData.groupIds, 'openai')
logger.info(
`🏢 Added OpenAI-Responses account ${account.id} to groups: ${accountData.groupIds.join(', ')}`
)
} else if (accountData.groupId) {
// 单分组模式(向后兼容)
await accountGroupService.addAccountToGroup(account.id, accountData.groupId, 'openai')
logger.info(
`🏢 Added OpenAI-Responses account ${account.id} to group: ${accountData.groupId}`
)
}
}
const formattedAccount = formatAccountExpiry(account)
res.json({ success: true, data: formattedAccount })
} catch (error) {
logger.error('Failed to create OpenAI-Responses account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 更新 OpenAI-Responses 账户
router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
// 获取当前账户信息
const currentAccount = await openaiResponsesAccountService.getAccount(id)
if (!currentAccount) {
return res.status(404).json({
success: false,
error: 'Account not found'
})
}
// ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt
const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id)
// 验证priority的有效性1-100
if (mappedUpdates.priority !== undefined) {
const priority = parseInt(mappedUpdates.priority)
if (isNaN(priority) || priority < 1 || priority > 100) {
return res.status(400).json({
success: false,
message: 'Priority must be a number between 1 and 100'
})
}
mappedUpdates.priority = priority.toString()
}
// 处理分组变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除
if (currentAccount.accountType === 'group') {
const oldGroups = await accountGroupService.getAccountGroups(id)
for (const oldGroup of oldGroups) {
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
}
logger.info(`📤 Removed OpenAI-Responses account ${id} from all groups`)
}
// 如果新类型是分组,处理多分组支持
if (mappedUpdates.accountType === 'group') {
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) {
if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) {
// 设置新的多分组
await accountGroupService.setAccountGroups(id, mappedUpdates.groupIds, 'openai')
logger.info(
`📥 Added OpenAI-Responses account ${id} to groups: ${mappedUpdates.groupIds.join(', ')}`
)
} else {
// groupIds 为空数组,从所有分组中移除
await accountGroupService.removeAccountFromAllGroups(id)
logger.info(
`📤 Removed OpenAI-Responses account ${id} from all groups (empty groupIds)`
)
}
} else if (mappedUpdates.groupId) {
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai')
logger.info(`📥 Added OpenAI-Responses account ${id} to group: ${mappedUpdates.groupId}`)
}
}
}
const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates)
if (!result.success) {
return res.status(400).json(result)
}
logger.success(`📝 Admin updated OpenAI-Responses account: ${id}`)
res.json({ success: true, ...result })
} catch (error) {
logger.error('Failed to update OpenAI-Responses account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 删除 OpenAI-Responses 账户
router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await openaiResponsesAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
// 自动解绑所有绑定的 API Keys
const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses')
// 从所有分组中移除此账户
if (account.accountType === 'group') {
await accountGroupService.removeAccountFromAllGroups(id)
logger.info(`Removed OpenAI-Responses account ${id} from all groups`)
}
const result = await openaiResponsesAccountService.deleteAccount(id)
let message = 'OpenAI-Responses账号已成功删除'
if (unboundCount > 0) {
message += `${unboundCount} 个 API Key 已切换为共享池模式`
}
logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`)
res.json({
success: true,
...result,
message,
unboundKeys: unboundCount
})
} catch (error) {
logger.error('Failed to delete OpenAI-Responses account:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 切换 OpenAI-Responses 账户调度状态
router.put(
'/openai-responses-accounts/:id/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { id } = req.params
const result = await openaiResponsesAccountService.toggleSchedulable(id)
if (!result.success) {
return res.status(400).json(result)
}
// 仅在停止调度时发送通知
if (!result.schedulable) {
await webhookNotifier.sendAccountEvent('account.status_changed', {
accountId: id,
platform: 'openai-responses',
schedulable: result.schedulable,
changedBy: 'admin',
action: 'stopped_scheduling'
})
}
res.json(result)
} catch (error) {
logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error)
res.status(500).json({
success: false,
error: error.message
})
}
}
)
// 切换 OpenAI-Responses 账户激活状态
router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await openaiResponsesAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
const newActiveStatus = account.isActive === 'true' ? 'false' : 'true'
await openaiResponsesAccountService.updateAccount(id, {
isActive: newActiveStatus
})
res.json({
success: true,
isActive: newActiveStatus === 'true'
})
} catch (error) {
logger.error('Failed to toggle OpenAI-Responses account status:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 重置 OpenAI-Responses 账户限流状态
router.post(
'/openai-responses-accounts/:id/reset-rate-limit',
authenticateAdmin,
async (req, res) => {
try {
const { id } = req.params
await openaiResponsesAccountService.updateAccount(id, {
rateLimitedAt: '',
rateLimitStatus: '',
status: 'active',
errorMessage: ''
})
logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`)
res.json({
success: true,
message: 'Rate limit reset successfully'
})
} catch (error) {
logger.error('Failed to reset OpenAI-Responses account rate limit:', error)
res.status(500).json({
success: false,
error: error.message
})
}
}
)
// 重置 OpenAI-Responses 账户状态(清除所有异常状态)
router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const result = await openaiResponsesAccountService.resetAccountStatus(id)
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
// 手动重置 OpenAI-Responses 账户的每日使用量
router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await openaiResponsesAccountService.updateAccount(id, {
dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(),
quotaStoppedAt: ''
})
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
res.json({
success: true,
message: 'Daily usage reset successfully'
})
} catch (error) {
logger.error('Failed to reset OpenAI-Responses account usage:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
module.exports = router

View File

@@ -1,401 +0,0 @@
const express = require('express')
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
const claudeAccountService = require('../../services/claudeAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const config = require('../../../config/config')
const router = express.Router()
// ==================== Claude Code Headers 管理 ====================
// 获取所有 Claude Code headers
router.get('/claude-code-headers', authenticateAdmin, async (req, res) => {
try {
const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders()
// 获取所有 Claude 账号信息
const accounts = await claudeAccountService.getAllAccounts()
const accountMap = {}
accounts.forEach((account) => {
accountMap[account.id] = account.name
})
// 格式化输出
const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({
accountId,
accountName: accountMap[accountId] || 'Unknown',
version: data.version,
userAgent: data.headers['user-agent'],
updatedAt: data.updatedAt,
headers: data.headers
}))
return res.json({
success: true,
data: formattedData
})
} catch (error) {
logger.error('❌ Failed to get Claude Code headers:', error)
return res
.status(500)
.json({ error: 'Failed to get Claude Code headers', message: error.message })
}
})
// 🗑️ 清除指定账号的 Claude Code headers
router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
await claudeCodeHeadersService.clearAccountHeaders(accountId)
return res.json({
success: true,
message: `Claude Code headers cleared for account ${accountId}`
})
} catch (error) {
logger.error('❌ Failed to clear Claude Code headers:', error)
return res
.status(500)
.json({ error: 'Failed to clear Claude Code headers', message: error.message })
}
})
// ==================== 系统更新检查 ====================
// 版本比较函数
function compareVersions(current, latest) {
const parseVersion = (v) => {
const parts = v.split('.').map(Number)
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
}
}
const currentV = parseVersion(current)
const latestV = parseVersion(latest)
if (currentV.major !== latestV.major) {
return currentV.major - latestV.major
}
if (currentV.minor !== latestV.minor) {
return currentV.minor - latestV.minor
}
return currentV.patch - latestV.patch
}
router.get('/check-updates', authenticateAdmin, async (req, res) => {
// 读取当前版本
const versionPath = path.join(__dirname, '../../../VERSION')
let currentVersion = '1.0.0'
try {
currentVersion = fs.readFileSync(versionPath, 'utf8').trim()
} catch (err) {
logger.warn('⚠️ Could not read VERSION file:', err.message)
}
try {
// 从缓存获取
const cacheKey = 'version_check_cache'
const cached = await redis.getClient().get(cacheKey)
if (cached && !req.query.force) {
const cachedData = JSON.parse(cached)
const cacheAge = Date.now() - cachedData.timestamp
// 缓存有效期1小时
if (cacheAge < 3600000) {
// 实时计算 hasUpdate不使用缓存的值
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
return res.json({
success: true,
data: {
current: currentVersion,
latest: cachedData.latest,
hasUpdate, // 实时计算,不用缓存
releaseInfo: cachedData.releaseInfo,
cached: true
}
})
}
}
// 请求 GitHub API
const githubRepo = 'wei-shaw/claude-relay-service'
const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Claude-Relay-Service'
},
timeout: 10000
})
const release = response.data
const latestVersion = release.tag_name.replace(/^v/, '')
// 比较版本
const hasUpdate = compareVersions(currentVersion, latestVersion) < 0
const releaseInfo = {
name: release.name,
body: release.body,
publishedAt: release.published_at,
htmlUrl: release.html_url
}
// 缓存结果(不缓存 hasUpdate因为它应该实时计算
await redis.getClient().set(
cacheKey,
JSON.stringify({
latest: latestVersion,
releaseInfo,
timestamp: Date.now()
}),
'EX',
3600
) // 1小时过期
return res.json({
success: true,
data: {
current: currentVersion,
latest: latestVersion,
hasUpdate,
releaseInfo,
cached: false
}
})
} catch (error) {
// 改进错误日志记录
const errorDetails = {
message: error.message || 'Unknown error',
code: error.code,
response: error.response
? {
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
}
: null,
request: error.request ? 'Request was made but no response received' : null
}
logger.error('❌ Failed to check for updates:', errorDetails.message)
// 处理 404 错误 - 仓库或版本不存在
if (error.response && error.response.status === 404) {
return res.json({
success: true,
data: {
current: currentVersion,
latest: currentVersion,
hasUpdate: false,
releaseInfo: {
name: 'No releases found',
body: 'The GitHub repository has no releases yet.',
publishedAt: new Date().toISOString(),
htmlUrl: '#'
},
warning: 'GitHub repository has no releases'
}
})
}
// 如果是网络错误,尝试返回缓存的数据
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
const cacheKey = 'version_check_cache'
const cached = await redis.getClient().get(cacheKey)
if (cached) {
const cachedData = JSON.parse(cached)
// 实时计算 hasUpdate
const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0
return res.json({
success: true,
data: {
current: currentVersion,
latest: cachedData.latest,
hasUpdate, // 实时计算
releaseInfo: cachedData.releaseInfo,
cached: true,
warning: 'Using cached data due to network error'
}
})
}
}
// 其他错误返回当前版本信息
return res.json({
success: true,
data: {
current: currentVersion,
latest: currentVersion,
hasUpdate: false,
releaseInfo: {
name: 'Update check failed',
body: `Unable to check for updates: ${error.message || 'Unknown error'}`,
publishedAt: new Date().toISOString(),
htmlUrl: '#'
},
error: true,
warning: error.message || 'Failed to check for updates'
}
})
}
})
// ==================== OEM 设置管理 ====================
// 获取OEM设置公开接口用于显示
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
router.get('/oem-settings', async (req, res) => {
try {
const client = redis.getClient()
const oemSettings = await client.get('oem:settings')
// 默认设置
const defaultSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮
updatedAt: new Date().toISOString()
}
let settings = defaultSettings
if (oemSettings) {
try {
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
} catch (err) {
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
}
}
// 添加 LDAP 启用状态到响应中
return res.json({
success: true,
data: {
...settings,
ldapEnabled: config.ldap && config.ldap.enabled === true
}
})
} catch (error) {
logger.error('❌ Failed to get OEM settings:', error)
return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message })
}
})
// 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try {
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
// 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
return res.status(400).json({ error: 'Site name is required' })
}
if (siteName.length > 100) {
return res.status(400).json({ error: 'Site name must be less than 100 characters' })
}
// 验证图标数据大小如果是base64
if (siteIconData && siteIconData.length > 500000) {
// 约375KB
return res.status(400).json({ error: 'Icon file must be less than 350KB' })
}
// 验证图标URL如果提供
if (siteIcon && !siteIconData) {
// 简单验证URL格式
try {
new URL(siteIcon)
} catch (err) {
return res.status(400).json({ error: 'Invalid icon URL format' })
}
}
const settings = {
siteName: siteName.trim(),
siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true
updatedAt: new Date().toISOString()
}
const client = redis.getClient()
await client.set('oem:settings', JSON.stringify(settings))
logger.info(`✅ OEM settings updated: ${siteName}`)
return res.json({
success: true,
message: 'OEM settings updated successfully',
data: settings
})
} catch (error) {
logger.error('❌ Failed to update OEM settings:', error)
return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message })
}
})
// ==================== Claude Code 版本管理 ====================
router.get('/claude-code-version', authenticateAdmin, async (req, res) => {
try {
const CACHE_KEY = 'claude_code_user_agent:daily'
// 获取缓存的统一User-Agent
const unifiedUserAgent = await redis.client.get(CACHE_KEY)
const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0
res.json({
success: true,
userAgent: unifiedUserAgent,
isActive: !!unifiedUserAgent,
ttlSeconds: ttl,
lastUpdated: unifiedUserAgent ? new Date().toISOString() : null
})
} catch (error) {
logger.error('❌ Get unified Claude Code User-Agent error:', error)
res.status(500).json({
success: false,
message: 'Failed to get User-Agent information',
error: error.message
})
}
})
// 🗑️ 清除统一Claude Code User-Agent缓存
router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => {
try {
const CACHE_KEY = 'claude_code_user_agent:daily'
// 删除缓存的统一User-Agent
await redis.client.del(CACHE_KEY)
logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`)
res.json({
success: true,
message: 'Unified User-Agent cache cleared successfully'
})
} catch (error) {
logger.error('❌ Clear unified User-Agent cache error:', error)
res.status(500).json({
success: false,
message: 'Failed to clear cache',
error: error.message
})
}
})
module.exports = router

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
/**
* Admin Routes - 共享工具函数
* 供各个子路由模块导入使用
*/
const logger = require('../../utils/logger')
/**
* 处理可为空的时间字段
* @param {*} value - 输入值
* @returns {string|null} 规范化后的值
*/
function normalizeNullableDate(value) {
if (value === undefined || value === null) {
return null
}
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
return value
}
/**
* 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段
* @param {Object} updates - 更新对象
* @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等)
* @param {string} accountId - 账户 ID
* @returns {Object} 映射后的更新对象
*/
function mapExpiryField(updates, accountType, accountId) {
const mappedUpdates = { ...updates }
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
}
return mappedUpdates
}
/**
* 格式化账户数据,确保前端获取正确的过期时间字段
* 将 subscriptionExpiresAt订阅过期时间映射到 expiresAt 供前端使用
* 保留原始的 tokenExpiresAtOAuth token过期时间供内部使用
* @param {Object} account - 账户对象
* @returns {Object} 格式化后的账户对象
*/
function formatAccountExpiry(account) {
if (!account || typeof account !== 'object') {
return account
}
const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt')
? account.subscriptionExpiresAt
: null
const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt')
? account.tokenExpiresAt
: account.expiresAt
const subscriptionExpiresAt = normalizeNullableDate(rawSubscription)
const tokenExpiresAt = normalizeNullableDate(rawToken)
return {
...account,
subscriptionExpiresAt,
tokenExpiresAt,
expiresAt: subscriptionExpiresAt
}
}
module.exports = {
normalizeNullableDate,
mapExpiryField,
formatAccountExpiry
}

View File

@@ -6,61 +6,20 @@ const ccrRelayService = require('../services/ccrRelayService')
const bedrockAccountService = require('../services/bedrockAccountService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const apiKeyService = require('../services/apiKeyService')
const pricingService = require('../services/pricingService')
const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const redis = require('../models/redis')
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const router = express.Router()
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
if (!rateLimitInfo) {
return Promise.resolve({ totalTokens: 0, totalCost: 0 })
}
const label = context ? ` (${context})` : ''
return updateRateLimitCounters(rateLimitInfo, usageSummary, model)
.then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
}
if (typeof totalCost === 'number' && totalCost > 0) {
logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`)
}
return { totalTokens, totalCost }
})
.catch((error) => {
logger.error(`❌ Failed to update rate limit counters${label}:`, error)
return { totalTokens: 0, totalCost: 0 }
})
}
// 🔧 共享的消息处理函数
async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// 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 服务'
}
})
}
// 🔄 并发满额重试标志最多重试一次使用req对象存储状态
if (req._concurrencyRetryAttempted === undefined) {
req._concurrencyRetryAttempted = false
}
// 严格的输入验证
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({
@@ -103,20 +62,6 @@ async function handleMessagesRequest(req, res) {
// 检查是否为流式请求
const isStream = req.body.stream === true
// 临时修复新版本客户端删除context_management字段避免报错
// if (req.body.context_management) {
// delete req.body.context_management
// }
// 遍历tools数组删除input_examples字段
// if (req.body.tools && Array.isArray(req.body.tools)) {
// req.body.tools.forEach((tool) => {
// if (tool && typeof tool === 'object' && tool.input_examples) {
// delete tool.input_examples
// }
// })
// }
logger.api(
`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}`
)
@@ -143,32 +88,11 @@ async function handleMessagesRequest(req, res) {
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
let accountType
try {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
)
res.status(403)
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify({
error: 'upstream_rate_limited',
message: limitMessage
})
)
return
}
throw error
}
// 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') {
@@ -232,17 +156,35 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
},
model,
'claude-stream'
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
.catch((error) => {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
logger.api(
@@ -323,17 +265,35 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
},
model,
'claude-console-stream'
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
.catch((error) => {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
logger.api(
@@ -373,17 +333,33 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record Bedrock stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens,
outputTokens,
cacheCreateTokens: 0,
cacheReadTokens: 0
},
result.model,
'bedrock-stream'
)
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
.catch((error) => {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(result.usage, result.model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
}
usageDataCaptured = true
logger.api(
@@ -458,17 +434,35 @@ async function handleMessagesRequest(req, res) {
logger.error('❌ Failed to record CCR stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
},
model,
'ccr-stream'
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
redis
.getClient()
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
.catch((error) => {
logger.error('❌ Failed to update rate limit token count:', error)
})
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(usageData, model)
if (costInfo.totalCost > 0) {
redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
.catch((error) => {
logger.error('❌ Failed to update rate limit cost count:', error)
})
logger.api(
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
)
}
}
}
usageDataCaptured = true
logger.api(
@@ -505,27 +499,11 @@ async function handleMessagesRequest(req, res) {
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
let accountType
try {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
)
return res.status(403).json({
error: 'upstream_rate_limited',
message: limitMessage
})
}
throw error
}
// 根据账号类型选择对应的转发服务
let response
@@ -656,17 +634,25 @@ async function handleMessagesRequest(req, res) {
responseAccountId
)
await queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
},
model,
'claude-non-stream'
)
// 更新时间窗口内的token计数和费用
if (req.rateLimitInfo) {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 更新Token计数向后兼容
await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
// 计算并更新费用计数(新功能)
if (req.rateLimitInfo.costCountKey) {
const costInfo = pricingService.calculateCost(jsonData.usage, model)
if (costInfo.totalCost > 0) {
await redis
.getClient()
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`)
}
}
}
usageRecorded = true
logger.api(
@@ -695,75 +681,9 @@ async function handleMessagesRequest(req, res) {
logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`)
return undefined
} catch (error) {
let handledError = error
// 🔄 并发满额降级处理捕获CONSOLE_ACCOUNT_CONCURRENCY_FULL错误
if (
handledError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' &&
!req._concurrencyRetryAttempted
) {
req._concurrencyRetryAttempted = true
logger.warn(
`⚠️ Console account ${handledError.accountId} concurrency full, attempting fallback to other accounts...`
)
// 只有在响应头未发送时才能重试
if (!res.headersSent) {
try {
// 清理粘性会话映射(如果存在)
const sessionHash = sessionHelper.generateSessionHash(req.body)
await unifiedClaudeScheduler.clearSessionMapping(sessionHash)
logger.info('🔄 Session mapping cleared, retrying handleMessagesRequest...')
// 递归重试整个请求处理(会选择新账户)
return await handleMessagesRequest(req, res)
} catch (retryError) {
// 重试失败
if (retryError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
logger.error('❌ All Console accounts reached concurrency limit after retry')
return res.status(503).json({
error: 'service_unavailable',
message:
'All available Claude Console accounts have reached their concurrency limit. Please try again later.'
})
}
// 其他错误继续向下处理
handledError = retryError
}
} else {
// 响应头已发送,无法重试
logger.error('❌ Cannot retry concurrency full error - response headers already sent')
if (!res.destroyed && !res.finished) {
res.end()
}
return undefined
}
}
// 🚫 第二次并发满额错误已经重试过直接返回503
if (
handledError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' &&
req._concurrencyRetryAttempted
) {
logger.error('❌ All Console accounts reached concurrency limit (retry already attempted)')
if (!res.headersSent) {
return res.status(503).json({
error: 'service_unavailable',
message:
'All available Claude Console accounts have reached their concurrency limit. Please try again later.'
})
} else {
if (!res.destroyed && !res.finished) {
res.end()
}
return undefined
}
}
logger.error('❌ Claude relay error:', handledError.message, {
code: handledError.code,
stack: handledError.stack
logger.error('❌ Claude relay error:', error.message, {
code: error.code,
stack: error.stack
})
// 确保在任何情况下都能返回有效的JSON响应
@@ -772,29 +692,23 @@ async function handleMessagesRequest(req, res) {
let statusCode = 500
let errorType = 'Relay service error'
if (
handledError.message.includes('Connection reset') ||
handledError.message.includes('socket hang up')
) {
if (error.message.includes('Connection reset') || error.message.includes('socket hang up')) {
statusCode = 502
errorType = 'Upstream connection error'
} else if (handledError.message.includes('Connection refused')) {
} else if (error.message.includes('Connection refused')) {
statusCode = 502
errorType = 'Upstream service unavailable'
} else if (handledError.message.includes('timeout')) {
} else if (error.message.includes('timeout')) {
statusCode = 504
errorType = 'Upstream timeout'
} else if (
handledError.message.includes('resolve') ||
handledError.message.includes('ENOTFOUND')
) {
} else if (error.message.includes('resolve') || error.message.includes('ENOTFOUND')) {
statusCode = 502
errorType = 'Upstream hostname resolution failed'
}
return res.status(statusCode).json({
error: errorType,
message: handledError.message || 'An unexpected error occurred',
message: error.message || 'An unexpected error occurred',
timestamp: new Date().toISOString()
})
} else {
@@ -813,23 +727,40 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest)
// 🚀 Claude API messages 端点 - /claude/v1/messages (别名)
router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
// 📋 模型列表端点 - Claude Code 客户端需要
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
const modelService = require('../services/modelService')
// 从 modelService 获取所有支持的模型
const models = modelService.getAllModels()
// 可选:根据 API Key 的模型限制过滤
let filteredModels = models
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id))
// 返回支持的模型列表
const models = [
{
id: 'claude-3-5-sonnet-20241022',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
},
{
id: 'claude-3-5-haiku-20241022',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
},
{
id: 'claude-3-opus-20240229',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
},
{
id: 'claude-sonnet-4-20250514',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
}
]
res.json({
object: 'list',
data: filteredModels
data: models
})
} catch (error) {
logger.error('❌ Models list error:', error)
@@ -951,6 +882,7 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
// 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
try {
// 检查权限
if (
req.apiKey.permissions &&
@@ -967,100 +899,67 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 选择可用的Claude账户
const requestedModel = req.body.model
const maxAttempts = 2
let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
if (accountType === 'ccr') {
throw Object.assign(new Error('Token counting is not supported for CCR accounts'), {
httpStatus: 501,
errorPayload: {
error: {
type: 'not_supported',
message: 'Token counting is not supported for CCR accounts'
}
}
})
}
if (accountType === 'bedrock') {
throw Object.assign(new Error('Token counting is not supported for Bedrock accounts'), {
httpStatus: 501,
errorPayload: {
error: {
type: 'not_supported',
message: 'Token counting is not supported for Bedrock accounts'
}
}
})
}
// 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用
if (accountType === 'claude-console') {
const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId)
if (isUnavailable) {
logger.info(
`⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response`
)
return { fallbackResponse: true }
}
}
const relayOptions = {
skipUsageRecord: true,
customPath: '/v1/messages/count_tokens'
}
const response =
accountType === 'claude-official'
? await claudeRelayService.relayRequest(
let response
if (accountType === 'claude-official') {
// 使用官方Claude账号转发count_tokens请求
response = await claudeRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
relayOptions
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
: await claudeConsoleRelayService.relayRequest(
} else if (accountType === 'claude-console') {
// 使用Console Claude账号转发count_tokens请求
response = await claudeConsoleRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
accountId,
relayOptions
)
// 🔍 claude-console 账户:检测上游 404 响应并标记
if (accountType === 'claude-console' && response.statusCode === 404) {
logger.warn(
`⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable`
)
// 标记失败不应影响 fallback 响应
try {
await claudeConsoleAccountService.markCountTokensUnavailable(accountId)
} catch (markError) {
logger.error(
`❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`,
markError
)
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
return { fallbackResponse: true }
)
} else if (accountType === 'ccr') {
// CCR不支持count_tokens
return res.status(501).json({
error: {
type: 'not_supported',
message: 'Token counting is not supported for CCR accounts'
}
})
} else {
// Bedrock不支持count_tokens
return res.status(501).json({
error: {
type: 'not_supported',
message: 'Token counting is not supported for Bedrock accounts'
}
})
}
// 直接返回响应不记录token使用量
res.status(response.statusCode)
// 设置响应头
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
Object.keys(response.headers).forEach((key) => {
if (!skipHeaders.includes(key.toLowerCase())) {
@@ -1068,107 +967,24 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
})
// 尝试解析并返回JSON响应
try {
const jsonData = JSON.parse(response.body)
if (response.statusCode < 200 || response.statusCode >= 300) {
const sanitizedData = sanitizeUpstreamError(jsonData)
res.json(sanitizedData)
} else {
res.json(jsonData)
}
} catch (parseError) {
res.send(response.body)
}
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
return { fallbackResponse: false }
}
while (attempt < maxAttempts) {
try {
const result = await processRequest()
// 🔍 处理 fallback 响应claude-console 账户 count_tokens 不可用)
if (result && result.fallbackResponse) {
if (!res.headersSent) {
return res.status(200).json({ input_tokens: 0 })
}
return
}
return
} catch (error) {
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
logger.warn(
`⚠️ Console account concurrency full during count_tokens (attempt ${attempt + 1}/${maxAttempts})`
)
if (attempt < maxAttempts - 1) {
try {
await unifiedClaudeScheduler.clearSessionMapping(sessionHash)
} catch (clearError) {
logger.error('❌ Failed to clear session mapping for count_tokens retry:', clearError)
if (!res.headersSent) {
return res.status(500).json({
error: {
type: 'server_error',
message: 'Failed to count tokens'
}
})
}
if (!res.destroyed && !res.finished) {
res.end()
}
return
}
attempt += 1
continue
}
if (!res.headersSent) {
return res.status(503).json({
error: 'service_unavailable',
message:
'All available Claude Console accounts have reached their concurrency limit. Please try again later.'
})
}
if (!res.destroyed && !res.finished) {
res.end()
}
return
}
if (error.httpStatus) {
return res.status(error.httpStatus).json(error.errorPayload)
}
// 客户端断开连接不是错误,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info('🔌 Client disconnected during token count request')
if (!res.headersSent) {
return res.status(499).end() // 499 Client Closed Request
}
if (!res.destroyed && !res.finished) {
res.end()
}
return
}
logger.error('❌ Token count error:', error)
if (!res.headersSent) {
return res.status(500).json({
res.status(500).json({
error: {
type: 'server_error',
message: 'Failed to count tokens'
}
})
}
if (!res.destroyed && !res.finished) {
res.end()
}
return
}
}
})
module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -3,9 +3,6 @@ const redis = require('../models/redis')
const logger = require('../utils/logger')
const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const router = express.Router()
@@ -96,21 +93,17 @@ router.post('/api/user-stats', async (req, res) => {
// 检查是否激活
if (keyData.isActive !== 'true') {
const keyName = keyData.name || 'Unknown'
return res.status(403).json({
error: 'API key is disabled',
message: `API Key "${keyName}" 已被禁用`,
keyName
message: 'This API key has been disabled'
})
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
const keyName = keyData.name || 'Unknown'
return res.status(403).json({
error: 'API key has expired',
message: `API Key "${keyName}" 已过期`,
keyName
message: 'This API key has expired'
})
}
@@ -121,7 +114,6 @@ router.post('/api/user-stats', async (req, res) => {
// 获取当日费用统计
const dailyCost = await redis.getDailyCost(keyId)
const costStats = await redis.getCostStats(keyId)
// 处理数据格式,与 validateApiKey 返回的格式保持一致
// 解析限制模型数据
@@ -148,9 +140,7 @@ router.post('/api/user-stats', async (req, res) => {
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
totalCostLimit: parseFloat(keyData.totalCostLimit) || 0,
dailyCost: dailyCost || 0,
totalCost: costStats.total || 0,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
@@ -342,50 +332,6 @@ router.post('/api/user-stats', async (req, res) => {
logger.warn(`Failed to get current usage for key ${keyId}:`, error)
}
const boundAccountDetails = {}
const accountDetailTasks = []
if (fullKeyData.claudeAccountId) {
accountDetailTasks.push(
(async () => {
try {
const overview = await claudeAccountService.getAccountOverview(
fullKeyData.claudeAccountId
)
if (overview && overview.accountType === 'dedicated') {
boundAccountDetails.claude = overview
}
} catch (error) {
logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error)
}
})()
)
}
if (fullKeyData.openaiAccountId) {
accountDetailTasks.push(
(async () => {
try {
const overview = await openaiAccountService.getAccountOverview(
fullKeyData.openaiAccountId
)
if (overview && overview.accountType === 'dedicated') {
boundAccountDetails.openai = overview
}
} catch (error) {
logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error)
}
})()
)
}
if (accountDetailTasks.length > 0) {
await Promise.allSettled(accountDetailTasks)
}
// 构建响应数据只返回该API Key自己的信息确保不泄露其他信息
const responseData = {
id: keyId,
@@ -426,15 +372,11 @@ router.post('/api/user-stats', async (req, res) => {
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
totalCostLimit: fullKeyData.totalCostLimit || 0,
weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制
// 当前使用量
currentWindowRequests,
currentWindowTokens,
currentWindowCost, // 新增:当前窗口费用
currentDailyCost,
currentTotalCost: totalCost,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, // 当前 Opus 周费用
// 时间窗口信息
windowStartTime,
windowEndTime,
@@ -450,12 +392,7 @@ router.post('/api/user-stats', async (req, res) => {
geminiAccountId:
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== ''
? fullKeyData.geminiAccountId
: null,
openaiAccountId:
fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== ''
? fullKeyData.openaiAccountId
: null,
details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null
: null
},
// 模型和客户端限制信息
@@ -791,66 +728,6 @@ router.post('/api/batch-model-stats', async (req, res) => {
}
})
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
router.post('/api-key/test', async (req, res) => {
const config = require('../../config/config')
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try {
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
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
})
}
logger.api(`🧪 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}/api/v1/messages?beta=true`
await sendStreamTestRequest({
apiUrl,
authorization: apiKey,
responseStream: res,
payload: createClaudeTestPayload(model, { stream: true }),
timeout: 60000,
extraHeaders: { 'x-api-key': apiKey }
})
} catch (error) {
logger.error('❌ API Key test failed:', error)
if (!res.headersSent) {
return res.status(500).json({
error: 'Test failed',
message: error.message || 'Internal server error'
})
}
res.write(
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
)
res.end()
}
})
// 📊 用户模型统计查询接口 - 安全的自查询接口
router.post('/api/user-model-stats', async (req, res) => {
try {
@@ -884,11 +761,9 @@ router.post('/api/user-model-stats', async (req, res) => {
// 检查是否激活
if (keyData.isActive !== 'true') {
const keyName = keyData.name || 'Unknown'
return res.status(403).json({
error: 'API key is disabled',
message: `API Key "${keyName}" 已被禁用`,
keyName
message: 'This API key has been disabled'
})
}

View File

@@ -1,196 +0,0 @@
const crypto = require('crypto')
const express = require('express')
const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService')
const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger')
const router = express.Router()
function hasDroidPermission(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'droid'
}
/**
* Droid API 转发路由
*
* 支持的 Factory.ai 端点:
* - /droid/claude - Anthropic (Claude) Messages API
* - /droid/openai - OpenAI Responses API
* - /droid/comm - OpenAI Chat Completions API
*/
// Claude (Anthropic) 端点 - /v1/messages
router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
try {
const sessionHash = sessionHelper.generateSessionHash(req.body)
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'anthropic', sessionHash }
)
// 如果是流式响应,已经在 relayService 中处理了
if (result.streaming) {
return
}
// 非流式响应
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid Claude relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
// Comm 端点 - /v1/chat/completionsOpenAI Chat Completions 格式)
router.post('/comm/v1/chat/completions', authenticateApiKey, async (req, res) => {
try {
const sessionId =
req.headers['session_id'] ||
req.headers['x-session-id'] ||
req.body?.session_id ||
req.body?.conversation_id ||
null
const sessionHash = sessionId
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
: null
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'comm', sessionHash }
)
if (result.streaming) {
return
}
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid Comm relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
// OpenAI 端点 - /v1/responses
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
try {
const sessionId =
req.headers['session_id'] ||
req.headers['x-session-id'] ||
req.body?.session_id ||
req.body?.conversation_id ||
null
const sessionHash = sessionId
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
: null
if (!hasDroidPermission(req.apiKey)) {
logger.security(
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
)
return res.status(403).json({
error: 'permission_denied',
message: '此 API Key 未启用 Droid 权限'
})
}
const result = await droidRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{ endpointType: 'openai', sessionHash }
)
if (result.streaming) {
return
}
res.status(result.statusCode).set(result.headers).send(result.body)
} catch (error) {
logger.error('Droid OpenAI relay error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
// 模型列表端点(兼容性)
router.get('/*/v1/models', authenticateApiKey, async (req, res) => {
try {
// 返回可用的模型列表
const models = [
{
id: 'claude-opus-4-1-20250805',
object: 'model',
created: Date.now(),
owned_by: 'anthropic'
},
{
id: 'claude-sonnet-4-5-20250929',
object: 'model',
created: Date.now(),
owned_by: 'anthropic'
},
{
id: 'gpt-5-2025-08-07',
object: 'model',
created: Date.now(),
owned_by: 'openai'
}
]
res.json({
object: 'list',
data: models
})
} catch (error) {
logger.error('Droid models list error:', error)
res.status(500).json({
error: 'internal_server_error',
message: error.message
})
}
})
module.exports = router

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@
const express = require('express')
const router = express.Router()
const fs = require('fs')
const path = require('path')
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const claudeRelayService = require('../services/claudeRelayService')
@@ -13,8 +15,17 @@ const apiKeyService = require('../services/apiKeyService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const pricingService = require('../services/pricingService')
// 加载模型定价数据
let modelPricingData = {}
try {
const pricingPath = path.join(__dirname, '../../data/model_pricing.json')
const pricingContent = fs.readFileSync(pricingPath, 'utf8')
modelPricingData = JSON.parse(pricingContent)
logger.info('✅ Model pricing data loaded successfully')
} catch (error) {
logger.error('❌ Failed to load model pricing data:', error)
}
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
@@ -22,27 +33,6 @@ function checkPermissions(apiKeyData, requiredPermission = 'claude') {
return permissions === 'all' || permissions === requiredPermission
}
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
if (!rateLimitInfo) {
return
}
const label = context ? ` (${context})` : ''
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
.then(({ totalTokens, totalCost }) => {
if (totalTokens > 0) {
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
}
if (typeof totalCost === 'number' && totalCost > 0) {
logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`)
}
})
.catch((error) => {
logger.error(`❌ Failed to update rate limit counters${label}:`, error)
})
}
// 📋 OpenAI 兼容的模型列表端点
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
@@ -128,7 +118,7 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
}
// 从 model_pricing.json 获取模型信息
const modelData = pricingService.getModelPricing(modelId)
const modelData = modelPricingData[modelId]
// 构建标准 OpenAI 格式的模型响应
let modelInfo
@@ -216,23 +206,11 @@ async function handleChatCompletion(req, res, apiKeyData) {
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
// 选择可用的Claude账户
let accountSelection
try {
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
apiKeyData,
sessionHash,
claudeRequest.model
)
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt)
return res.status(403).json({
error: 'upstream_rate_limited',
message: limitMessage
})
}
throw error
}
const { accountId } = accountSelection
// 获取该账号存储的 Claude Code headers
@@ -273,12 +251,6 @@ async function handleChatCompletion(req, res, apiKeyData) {
// 记录使用统计
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
const model = usage.model || claudeRequest.model
const cacheCreateTokens =
(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 方法来支持详细的缓存数据
apiKeyService
@@ -291,18 +263,6 @@ async function handleChatCompletion(req, res, apiKeyData) {
.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-claude-stream'
)
}
},
// 流转换器
@@ -362,12 +322,6 @@ async function handleChatCompletion(req, res, apiKeyData) {
// 记录使用统计
if (claudeData.usage) {
const { usage } = claudeData
const cacheCreateTokens =
(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 方法来支持详细的缓存数据
apiKeyService
.recordUsageWithDetails(
@@ -379,18 +333,6 @@ async function handleChatCompletion(req, res, apiKeyData) {
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens,
cacheReadTokens
},
claudeRequest.model,
'openai-claude-non-stream'
)
}
// 返回 OpenAI 格式响应
@@ -478,4 +420,3 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => {
})
module.exports = router
module.exports.handleChatCompletion = handleChatCompletion

View File

@@ -9,10 +9,11 @@ const crypto = require('crypto')
// 生成会话哈希
function generateSessionHash(req) {
const authSource =
req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key']
const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)]
const sessionData = [
req.headers['user-agent'],
req.ip,
req.headers['authorization']?.substring(0, 20)
]
.filter(Boolean)
.join(':')
@@ -386,7 +387,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
candidatesTokenCount: 0,
totalTokenCount: 0
}
let usageReported = false // 修复:改为 let 以便后续修改
const usageReported = false
streamResponse.on('data', (chunk) => {
try {
@@ -512,9 +513,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
logger.info(
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
)
// 修复:标记 usage 已上报,避免重复上报
usageReported = true
} catch (error) {
logger.error('Failed to record Gemini usage:', error)
}
@@ -537,23 +535,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
})
} else {
// 如果已经开始发送流数据,发送错误事件
// 修复:使用 JSON.stringify 避免字符串插值导致的格式错误
if (!res.destroyed) {
try {
res.write(
`data: ${JSON.stringify({
error: {
message: error.message || 'Stream error',
type: 'stream_error',
code: error.code
}
})}\n\n`
)
res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`)
res.write('data: [DONE]\n\n')
} catch (writeError) {
logger.error('Error sending error event:', writeError)
}
}
res.end()
}
})

File diff suppressed because one or more lines are too long

View File

@@ -1,263 +1,637 @@
/**
* 标准 Gemini API 路由模块
*
* 该模块处理标准 Gemini API 格式的请求:
* - v1beta/models/:modelName:generateContent
* - v1beta/models/:modelName:streamGenerateContent
* - v1beta/models/:modelName:countTokens
* - v1beta/models/:modelName:loadCodeAssist
* - v1beta/models/:modelName:onboardUser
* - v1/models/:modelName:* (同上)
* - v1internal:* (内部格式)
* - v1beta/models, v1/models (模型列表)
* - v1beta/models/:modelName, v1/models/:modelName (模型详情)
*
* 所有处理函数都从 geminiHandlers.js 导入,以避免代码重复。
*/
const express = require('express')
const router = express.Router()
const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const apiKeyService = require('../services/apiKeyService')
const sessionHelper = require('../utils/sessionHelper')
// 从 handlers/geminiHandlers.js 导入所有处理函数
const {
ensureGeminiPermissionMiddleware,
handleLoadCodeAssist,
handleOnboardUser,
handleCountTokens,
handleGenerateContent,
handleStreamGenerateContent,
handleStandardGenerateContent,
handleStandardStreamGenerateContent,
handleModels,
handleModelDetails
} = require('../handlers/geminiHandlers')
// 导入 geminiRoutes 中导出的处理函数
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
// 标准 Gemini API 路由处理器
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
// 标准格式: /gemini/v1beta/models/{model}:generateContent
// 专门处理标准 Gemini API 格式的 generateContent
async function handleStandardGenerateContent(req, res) {
try {
// 从路径参数中获取模型名
const model = req.params.modelName || 'gemini-2.0-flash-exp'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 标准 Gemini API 请求体直接包含 contents 等字段
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
// 验证必需参数
if (!contents || !Array.isArray(contents) || contents.length === 0) {
return res.status(400).json({
error: {
message: 'Contents array is required',
type: 'invalid_request_error'
}
})
}
// 构建内部 API 需要的请求格式
const actualRequestData = {
contents,
generationConfig: generationConfig || {
temperature: 0.7,
maxOutputTokens: 4096,
topP: 0.95,
topK: 40
}
}
// 只有在 safetySettings 存在且非空时才添加
if (safetySettings && safetySettings.length > 0) {
actualRequestData.safetySettings = safetySettings
}
// 如果有 system instruction修正格式并添加到请求体
// Gemini CLI 的内部 API 需要 role: "user" 字段
if (systemInstruction) {
// 确保 systemInstruction 格式正确
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: [{ text: systemInstruction }]
}
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
// 检查是否有实际内容
const hasContent = systemInstruction.parts.some(
(part) => part.text && part.text.trim() !== ''
)
if (hasContent) {
// 添加 role 字段Gemini CLI 格式)
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: systemInstruction.parts
}
}
}
}
// 使用统一调度选择账号
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
model
)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
logger.info(`Standard Gemini API generateContent request (${version})`, {
model,
projectId: account.projectId,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 使用账户的项目ID如果有的话
const effectiveProjectId = account.projectId || null
logger.info('📋 Standard API 项目ID处理逻辑', {
accountProjectId: account.projectId,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
})
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
const userPromptId = `${require('crypto').randomUUID()}########0`
// 调用内部 APIcloudcode-pa
const response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
userPromptId, // 使用生成的 user_prompt_id
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID使用默认值
req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig
)
// 记录使用统计
if (response?.response?.usageMetadata) {
try {
const usage = response.response.usageMetadata
await apiKeyService.recordUsage(
req.apiKey.id,
usage.promptTokenCount || 0,
usage.candidatesTokenCount || 0,
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
)
logger.info(
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
)
} catch (error) {
logger.error('Failed to record Gemini usage:', error)
}
}
// 返回标准 Gemini API 格式的响应
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
if (response.response) {
// 过滤掉 thought 部分(这是内部 API 特有的)
const standardResponse = { ...response.response }
if (standardResponse.candidates) {
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
if (candidate.content && candidate.content.parts) {
// 过滤掉 thought: true 的 parts
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
return {
...candidate,
content: {
...candidate.content,
parts: filteredParts
}
}
}
return candidate
})
}
res.json(standardResponse)
} else {
res.json(response)
}
} catch (error) {
logger.error(`Error in standard generateContent endpoint`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
stack: error.stack
})
res.status(500).json({
error: {
message: error.message || 'Internal server error',
type: 'api_error'
}
})
}
}
// 专门处理标准 Gemini API 格式的 streamGenerateContent
async function handleStandardStreamGenerateContent(req, res) {
let abortController = null
try {
// 从路径参数中获取模型名
const model = req.params.modelName || 'gemini-2.0-flash-exp'
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 标准 Gemini API 请求体直接包含 contents 等字段
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
// 验证必需参数
if (!contents || !Array.isArray(contents) || contents.length === 0) {
return res.status(400).json({
error: {
message: 'Contents array is required',
type: 'invalid_request_error'
}
})
}
// 构建内部 API 需要的请求格式
const actualRequestData = {
contents,
generationConfig: generationConfig || {
temperature: 0.7,
maxOutputTokens: 4096,
topP: 0.95,
topK: 40
}
}
// 只有在 safetySettings 存在且非空时才添加
if (safetySettings && safetySettings.length > 0) {
actualRequestData.safetySettings = safetySettings
}
// 如果有 system instruction修正格式并添加到请求体
// Gemini CLI 的内部 API 需要 role: "user" 字段
if (systemInstruction) {
// 确保 systemInstruction 格式正确
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: [{ text: systemInstruction }]
}
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
// 检查是否有实际内容
const hasContent = systemInstruction.parts.some(
(part) => part.text && part.text.trim() !== ''
)
if (hasContent) {
// 添加 role 字段Gemini CLI 格式)
actualRequestData.systemInstruction = {
role: 'user', // Gemini CLI 内部 API 需要这个字段
parts: systemInstruction.parts
}
}
}
}
// 使用统一调度选择账号
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
model
)
const account = await geminiAccountService.getAccount(accountId)
const { accessToken, refreshToken } = account
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
model,
projectId: account.projectId,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 创建中止控制器
abortController = new AbortController()
// 处理客户端断开连接
req.on('close', () => {
if (abortController && !abortController.signal.aborted) {
logger.info('Client disconnected, aborting stream request')
abortController.abort()
}
})
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 使用账户的项目ID如果有的话
const effectiveProjectId = account.projectId || null
logger.info('📋 Standard API 流式项目ID处理逻辑', {
accountProjectId: account.projectId,
effectiveProjectId,
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
})
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
const userPromptId = `${require('crypto').randomUUID()}########0`
// 调用内部 APIcloudcode-pa的流式接口
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
userPromptId, // 使用生成的 user_prompt_id
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID使用默认值
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal,
proxyConfig
)
// 设置 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')
// 处理流式响应并捕获usage数据
let totalUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0
}
streamResponse.on('data', (chunk) => {
try {
if (!res.destroyed) {
const chunkStr = chunk.toString()
// 处理 SSE 格式的数据
const lines = chunkStr.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim()
if (jsonStr && jsonStr !== '[DONE]') {
try {
const data = JSON.parse(jsonStr)
// 捕获 usage 数据
if (data.response?.usageMetadata) {
totalUsage = data.response.usageMetadata
}
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
if (data.response) {
// 过滤掉 thought 部分(这是内部 API 特有的)
if (data.response.candidates) {
const filteredCandidates = data.response.candidates
.map((candidate) => {
if (candidate.content && candidate.content.parts) {
// 过滤掉 thought: true 的 parts
const filteredParts = candidate.content.parts.filter(
(part) => !part.thought
)
if (filteredParts.length > 0) {
return {
...candidate,
content: {
...candidate.content,
parts: filteredParts
}
}
}
return null
}
return candidate
})
.filter(Boolean)
// 只有当有有效内容时才发送
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
const standardResponse = {
candidates: filteredCandidates,
...(data.response.usageMetadata && {
usageMetadata: data.response.usageMetadata
}),
...(data.response.modelVersion && {
modelVersion: data.response.modelVersion
}),
...(data.response.createTime && { createTime: data.response.createTime }),
...(data.response.responseId && { responseId: data.response.responseId })
}
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
}
}
} else {
// 如果没有 response 包装,直接发送
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
} catch (e) {
// 忽略解析错误
}
} else if (jsonStr === '[DONE]') {
// 保持 [DONE] 标记
res.write(`${line}\n\n`)
}
}
}
}
} catch (error) {
logger.error('Error processing stream chunk:', error)
}
})
streamResponse.on('end', async () => {
logger.info('Stream completed successfully')
// 记录使用统计
if (totalUsage.totalTokenCount > 0) {
try {
await apiKeyService.recordUsage(
req.apiKey.id,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0, // cacheCreateTokens
0, // cacheReadTokens
model,
account.id
)
logger.info(
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
)
} catch (error) {
logger.error('Failed to record Gemini usage:', error)
}
}
res.end()
})
streamResponse.on('error', (error) => {
logger.error('Stream error:', error)
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
type: 'api_error'
}
})
} else {
res.end()
}
})
} catch (error) {
logger.error(`Error in standard streamGenerateContent endpoint`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
stack: error.stack
})
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Internal server error',
type: 'api_error'
}
})
}
} finally {
// 清理资源
if (abortController) {
abortController = null
}
}
}
// ============================================================================
// v1beta 版本的标准路由 - 支持动态模型名称
// ============================================================================
/**
* POST /v1beta/models/:modelName:loadCodeAssist
*/
router.post(
'/v1beta/models/:modelName\\:loadCodeAssist',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleLoadCodeAssist(req, res, next)
}
)
})
/**
* POST /v1beta/models/:modelName:onboardUser
*/
router.post(
'/v1beta/models/:modelName\\:onboardUser',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleOnboardUser(req, res, next)
}
)
})
/**
* POST /v1beta/models/:modelName:countTokens
*/
router.post(
'/v1beta/models/:modelName\\:countTokens',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
}
)
})
/**
* POST /v1beta/models/:modelName:generateContent
* 使用专门的标准 API 处理函数(支持 OAuth 和 API 账户)
*/
// 使用专门的处理函数处理标准 Gemini API 格式
router.post(
'/v1beta/models/:modelName\\:generateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleStandardGenerateContent
)
/**
* POST /v1beta/models/:modelName:streamGenerateContent
* 使用专门的标准 API 流式处理函数(支持 OAuth 和 API 账户)
*/
router.post(
'/v1beta/models/:modelName\\:streamGenerateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleStandardStreamGenerateContent
)
// ============================================================================
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta
// ============================================================================
/**
* POST /v1/models/:modelName:generateContent
*/
router.post(
'/v1/models/:modelName\\:generateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleStandardGenerateContent
)
/**
* POST /v1/models/:modelName:streamGenerateContent
*/
router.post(
'/v1/models/:modelName\\:streamGenerateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleStandardStreamGenerateContent
)
/**
* POST /v1/models/:modelName:countTokens
*/
router.post(
'/v1/models/:modelName\\:countTokens',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
}
)
})
// ============================================================================
// v1internal 版本的标准路由(这些使用内部格式的处理函数)
// ============================================================================
/**
* POST /v1internal:loadCodeAssist
*/
router.post(
'/v1internal\\:loadCodeAssist',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleLoadCodeAssist(req, res, next)
}
)
})
/**
* POST /v1internal:onboardUser
*/
router.post(
'/v1internal\\:onboardUser',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleOnboardUser(req, res, next)
}
)
})
/**
* POST /v1internal:countTokens
*/
router.post(
'/v1internal\\:countTokens',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
handleCountTokens(req, res, next)
}
)
})
/**
* POST /v1internal:generateContent
* v1internal 格式使用内部格式的处理函数
*/
router.post(
'/v1internal\\:generateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
// v1internal 格式不同,使用原有的处理函数
const { handleGenerateContent } = require('./geminiRoutes')
handleGenerateContent(req, res, next)
}
)
})
/**
* POST /v1internal:streamGenerateContent
* v1internal 格式使用内部格式的处理函数
*/
router.post(
'/v1internal\\:streamGenerateContent',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
(req, res, next) => {
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
// v1internal 格式不同,使用原有的处理函数
const { handleStreamGenerateContent } = require('./geminiRoutes')
handleStreamGenerateContent(req, res, next)
})
// 添加标准 Gemini API 的模型列表端点
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
try {
logger.info('Standard Gemini API models request')
// 直接调用 geminiRoutes 中的模型处理逻辑
const geminiRoutes = require('./geminiRoutes')
const modelHandler = geminiRoutes.stack.find(
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
)
if (modelHandler && modelHandler.route.stack[1]) {
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
modelHandler.route.stack[1].handle(req, res)
} else {
res.status(500).json({ error: 'Models handler not found' })
}
} catch (error) {
logger.error('Error in standard models endpoint:', error)
res.status(500).json({
error: {
message: 'Failed to retrieve models',
type: 'api_error'
}
})
}
)
// ============================================================================
// 模型列表端点
// ============================================================================
/**
* GET /v1beta/models
* 获取模型列表v1beta 版本)
*/
router.get('/v1beta/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => {
logger.info('Standard Gemini API models request (v1beta)')
handleModels(req, res)
})
/**
* GET /v1/models
* 获取模型列表v1 版本)
*/
router.get('/v1/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => {
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
logger.info('Standard Gemini API models request (v1)')
handleModels(req, res)
// 直接调用 geminiRoutes 中的模型处理逻辑
const geminiRoutes = require('./geminiRoutes')
const modelHandler = geminiRoutes.stack.find(
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
)
if (modelHandler && modelHandler.route.stack[1]) {
modelHandler.route.stack[1].handle(req, res)
} else {
res.status(500).json({ error: 'Models handler not found' })
}
} catch (error) {
logger.error('Error in standard models endpoint (v1):', error)
res.status(500).json({
error: {
message: 'Failed to retrieve models',
type: 'api_error'
}
})
}
})
// ============================================================================
// 模型详情端点
// ============================================================================
// 添加模型详情端点
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
const { modelName } = req.params
logger.info(`Standard Gemini API model details request: ${modelName}`)
/**
* GET /v1beta/models/:modelName
* 获取模型详情v1beta 版本)
*/
router.get(
'/v1beta/models/:modelName',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleModelDetails
)
res.json({
name: `models/${modelName}`,
version: '001',
displayName: modelName,
description: `Gemini model: ${modelName}`,
inputTokenLimit: 1048576,
outputTokenLimit: 8192,
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
temperature: 1.0,
topP: 0.95,
topK: 40
})
})
/**
* GET /v1/models/:modelName
* 获取模型详情v1 版本)
*/
router.get(
'/v1/models/:modelName',
authenticateApiKey,
ensureGeminiPermissionMiddleware,
handleModelDetails
)
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
const { modelName } = req.params
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
// ============================================================================
// 初始化日志
// ============================================================================
res.json({
name: `models/${modelName}`,
version: '001',
displayName: modelName,
description: `Gemini model: ${modelName}`,
inputTokenLimit: 1048576,
outputTokenLimit: 8192,
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
temperature: 1.0,
topP: 0.95,
topK: 40
})
})
logger.info('Standard Gemini API routes initialized')

View File

@@ -1,202 +0,0 @@
const express = require('express')
const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const { handleChatCompletion } = require('./openaiClaudeRoutes')
// 从 handlers/geminiHandlers.js 导入处理函数
const {
handleGenerateContent: geminiHandleGenerateContent,
handleStreamGenerateContent: geminiHandleStreamGenerateContent
} = require('../handlers/geminiHandlers')
const openaiRoutes = require('./openaiRoutes')
const router = express.Router()
// 🔍 根据模型名称检测后端类型
function detectBackendFromModel(modelName) {
if (!modelName) {
return 'claude' // 默认 Claude
}
const model = modelName.toLowerCase()
// Claude 模型
if (model.startsWith('claude-')) {
return 'claude'
}
// Gemini 模型
if (model.startsWith('gemini-')) {
return 'gemini'
}
// OpenAI 模型
if (model.startsWith('gpt-')) {
return 'openai'
}
// 默认使用 Claude
return 'claude'
}
// 🚀 智能后端路由处理器
async function routeToBackend(req, res, requestedModel) {
const backend = detectBackendFromModel(requestedModel)
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限
const permissions = req.apiKey.permissions || 'all'
if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层
if (permissions !== 'all' && permissions !== 'claude') {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Claude',
type: 'permission_denied',
code: 'permission_denied'
}
})
}
await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') {
// OpenAI 后端
if (permissions !== 'all' && permissions !== 'openai') {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access OpenAI',
type: 'permission_denied',
code: 'permission_denied'
}
})
}
return await openaiRoutes.handleResponses(req, res)
} else if (backend === 'gemini') {
// Gemini 后端
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Gemini',
type: 'permission_denied',
code: 'permission_denied'
}
})
}
// 转换为 Gemini 格式
const geminiRequest = {
model: requestedModel,
messages: req.body.messages,
temperature: req.body.temperature || 0.7,
max_tokens: req.body.max_tokens || 4096,
stream: req.body.stream || false
}
req.body = geminiRequest
if (geminiRequest.stream) {
return await geminiHandleStreamGenerateContent(req, res)
} else {
return await geminiHandleGenerateContent(req, res)
}
} else {
return res.status(500).json({
error: {
message: `Unsupported backend: ${backend}`,
type: 'server_error',
code: 'unsupported_backend'
}
})
}
}
// 🔄 OpenAI 兼容的 chat/completions 端点(智能后端路由)
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
try {
// 验证必需参数
if (!req.body.messages || !Array.isArray(req.body.messages) || req.body.messages.length === 0) {
return res.status(400).json({
error: {
message: 'Messages array is required and cannot be empty',
type: 'invalid_request_error',
code: 'invalid_request'
}
})
}
const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022'
req.body.model = requestedModel // 确保模型已设置
// 使用统一的后端路由处理器
await routeToBackend(req, res, requestedModel)
} catch (error) {
logger.error('❌ OpenAI chat/completions error:', error)
if (!res.headersSent) {
res.status(500).json({
error: {
message: 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
})
}
}
})
// 🔄 OpenAI 兼容的 completions 端点(传统格式,智能后端路由)
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
try {
// 验证必需参数
if (!req.body.prompt) {
return res.status(400).json({
error: {
message: 'Prompt is required',
type: 'invalid_request_error',
code: 'invalid_request'
}
})
}
// 将传统 completions 格式转换为 chat 格式
const originalBody = req.body
const requestedModel = originalBody.model || 'claude-3-5-sonnet-20241022'
req.body = {
model: requestedModel,
messages: [
{
role: 'user',
content: originalBody.prompt
}
],
max_tokens: originalBody.max_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
stop: originalBody.stop,
n: originalBody.n || 1,
presence_penalty: originalBody.presence_penalty,
frequency_penalty: originalBody.frequency_penalty,
logit_bias: originalBody.logit_bias,
user: originalBody.user
}
// 使用统一的后端路由处理器
await routeToBackend(req, res, requestedModel)
} catch (error) {
logger.error('❌ OpenAI completions error:', error)
if (!res.headersSent) {
res.status(500).json({
error: {
message: 'Failed to process completion request',
type: 'server_error',
code: 'internal_error'
}
})
}
}
})
module.exports = router
module.exports.detectBackendFromModel = detectBackendFromModel
module.exports.routeToBackend = routeToBackend

View File

@@ -258,8 +258,6 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
usage: flatUsage,
dailyCost: key.dailyCost,
dailyCostLimit: key.dailyCostLimit,
totalCost: key.totalCost,
totalCostLimit: key.totalCostLimit,
// 不返回实际的key值只返回前缀和后几位
keyPreview: key.key
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
@@ -289,7 +287,7 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
// 🔑 创建新的API Key
router.post('/api-keys', authenticateUser, async (req, res) => {
try {
const { name, description, tokenLimit, expiresAt, dailyCostLimit, totalCostLimit } = req.body
const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body
if (!name || !name.trim()) {
return res.status(400).json({
@@ -298,18 +296,6 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
})
}
if (
totalCostLimit !== undefined &&
totalCostLimit !== null &&
totalCostLimit !== '' &&
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
) {
return res.status(400).json({
error: 'Invalid total cost limit',
message: 'Total cost limit must be a non-negative number'
})
}
// 检查用户API Key数量限制
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) {
@@ -328,7 +314,6 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
tokenLimit: tokenLimit || null,
expiresAt: expiresAt || null,
dailyCostLimit: dailyCostLimit || null,
totalCostLimit: totalCostLimit || null,
createdBy: 'user',
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
permissions: 'all'
@@ -352,7 +337,6 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
tokenLimit: newApiKey.tokenLimit,
expiresAt: newApiKey.expiresAt,
dailyCostLimit: newApiKey.dailyCostLimit,
totalCostLimit: newApiKey.totalCostLimit,
createdAt: newApiKey.createdAt
}
})

View File

@@ -133,11 +133,7 @@ router.post('/test', authenticateAdmin, async (req, res) => {
pass,
from,
to,
ignoreTLS,
botToken,
chatId,
apiBaseUrl,
proxyUrl
ignoreTLS
} = req.body
// Bark平台特殊处理
@@ -190,56 +186,6 @@ router.post('/test', authenticateAdmin, async (req, res) => {
}
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
} else if (type === 'telegram') {
if (!botToken) {
return res.status(400).json({
error: 'Missing Telegram bot token',
message: '请提供 Telegram 机器人 Token'
})
}
if (!chatId) {
return res.status(400).json({
error: 'Missing Telegram chat id',
message: '请提供 Telegram Chat ID'
})
}
if (apiBaseUrl) {
try {
const parsed = new URL(apiBaseUrl)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({
error: 'Invalid Telegram API base url protocol',
message: 'Telegram API 基础地址仅支持 http 或 https'
})
}
} catch (urlError) {
return res.status(400).json({
error: 'Invalid Telegram API base url',
message: '请提供有效的 Telegram API 基础地址'
})
}
}
if (proxyUrl) {
try {
const parsed = new URL(proxyUrl)
const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:']
if (!supportedProtocols.includes(parsed.protocol)) {
return res.status(400).json({
error: 'Unsupported proxy protocol',
message: 'Telegram 代理仅支持 http/https/socks 协议'
})
}
} catch (urlError) {
return res.status(400).json({
error: 'Invalid proxy url',
message: '请提供有效的代理地址'
})
}
}
logger.info(`🧪 测试webhook: ${type} - Chat ID: ${chatId}`)
} else {
// 其他平台验证URL
if (!url) {
@@ -289,30 +235,12 @@ router.post('/test', authenticateAdmin, async (req, res) => {
platform.from = from
platform.to = to
platform.ignoreTLS = ignoreTLS || false
} else if (type === 'telegram') {
platform.botToken = botToken
platform.chatId = chatId
platform.apiBaseUrl = apiBaseUrl
platform.proxyUrl = proxyUrl
}
const result = await webhookService.testWebhook(platform)
const identifier = (() => {
if (type === 'bark') {
return `Device: ${deviceKey.substring(0, 8)}...`
}
if (type === 'smtp') {
const recipients = Array.isArray(to) ? to.join(', ') : to
return `${host}:${port || 587} -> ${recipients}`
}
if (type === 'telegram') {
return `Chat ID: ${chatId}`
}
return url
})()
if (result.success) {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.info(`✅ Webhook测试成功: ${identifier}`)
res.json({
success: true,
@@ -321,6 +249,7 @@ router.post('/test', authenticateAdmin, async (req, res) => {
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
})
} else {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
res.status(400).json({
success: false,

View File

@@ -27,8 +27,8 @@ class AccountGroupService {
}
// 验证平台类型
if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) {
throw new Error('平台类型必须是 claude、geminiopenai 或 droid')
if (!['claude', 'gemini', 'openai'].includes(platform)) {
throw new Error('平台类型必须是 claude、geminiopenai')
}
const client = redis.getClientSafe()
@@ -311,8 +311,7 @@ class AccountGroupService {
keyData &&
(keyData.claudeAccountId === groupKey ||
keyData.geminiAccountId === groupKey ||
keyData.openaiAccountId === groupKey ||
keyData.droidAccountId === groupKey)
keyData.openaiAccountId === groupKey)
) {
boundApiKeys.push({
id: keyId,

View File

@@ -1,286 +0,0 @@
/**
* 账户名称缓存服务
* 用于加速绑定账号搜索,避免每次搜索都查询所有账户
*/
const logger = require('../utils/logger')
class AccountNameCacheService {
constructor() {
// 账户名称缓存accountId -> { name, platform }
this.accountCache = new Map()
// 账户组名称缓存groupId -> { name, platform }
this.groupCache = new Map()
// 缓存过期时间
this.lastRefresh = 0
this.refreshInterval = 5 * 60 * 1000 // 5分钟
this.isRefreshing = false
}
/**
* 刷新缓存(如果过期)
*/
async refreshIfNeeded() {
if (Date.now() - this.lastRefresh < this.refreshInterval) {
return
}
if (this.isRefreshing) {
// 等待正在进行的刷新完成
let waitCount = 0
while (this.isRefreshing && waitCount < 50) {
await new Promise((resolve) => setTimeout(resolve, 100))
waitCount++
}
return
}
await this.refresh()
}
/**
* 强制刷新缓存
*/
async refresh() {
if (this.isRefreshing) {
return
}
this.isRefreshing = true
try {
const newAccountCache = new Map()
const newGroupCache = new Map()
// 延迟加载服务,避免循环依赖
const claudeAccountService = require('./claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const geminiAccountService = require('./geminiAccountService')
const openaiAccountService = require('./openaiAccountService')
const azureOpenaiAccountService = require('./azureOpenaiAccountService')
const bedrockAccountService = require('./bedrockAccountService')
const droidAccountService = require('./droidAccountService')
const ccrAccountService = require('./ccrAccountService')
const accountGroupService = require('./accountGroupService')
// 可选服务(可能不存在)
let geminiApiAccountService = null
let openaiResponsesAccountService = null
try {
geminiApiAccountService = require('./geminiApiAccountService')
} catch (e) {
// 服务不存在,忽略
}
try {
openaiResponsesAccountService = require('./openaiResponsesAccountService')
} catch (e) {
// 服务不存在,忽略
}
// 并行加载所有账户类型
const results = await Promise.allSettled([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
geminiApiAccountService?.getAllAccounts() || Promise.resolve([]),
openaiAccountService.getAllAccounts(),
openaiResponsesAccountService?.getAllAccounts() || Promise.resolve([]),
azureOpenaiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
droidAccountService.getAllAccounts(),
ccrAccountService.getAllAccounts(),
accountGroupService.getAllGroups()
])
// 提取结果
const claudeAccounts = results[0].status === 'fulfilled' ? results[0].value : []
const claudeConsoleAccounts = results[1].status === 'fulfilled' ? results[1].value : []
const geminiAccounts = results[2].status === 'fulfilled' ? results[2].value : []
const geminiApiAccounts = results[3].status === 'fulfilled' ? results[3].value : []
const openaiAccounts = results[4].status === 'fulfilled' ? results[4].value : []
const openaiResponsesAccounts = results[5].status === 'fulfilled' ? results[5].value : []
const azureOpenaiAccounts = results[6].status === 'fulfilled' ? results[6].value : []
const bedrockResult = results[7].status === 'fulfilled' ? results[7].value : { accounts: [] }
const droidAccounts = results[8].status === 'fulfilled' ? results[8].value : []
const ccrAccounts = results[9].status === 'fulfilled' ? results[9].value : []
const groups = results[10].status === 'fulfilled' ? results[10].value : []
// Bedrock 返回格式特殊处理
const bedrockAccounts = Array.isArray(bedrockResult)
? bedrockResult
: bedrockResult.accounts || []
// 填充账户缓存的辅助函数
const addAccounts = (accounts, platform, prefix = '') => {
if (!Array.isArray(accounts)) {
return
}
for (const acc of accounts) {
if (acc && acc.id && acc.name) {
const key = prefix ? `${prefix}${acc.id}` : acc.id
newAccountCache.set(key, { name: acc.name, platform })
// 同时存储不带前缀的版本,方便查找
if (prefix) {
newAccountCache.set(acc.id, { name: acc.name, platform })
}
}
}
}
addAccounts(claudeAccounts, 'claude')
addAccounts(claudeConsoleAccounts, 'claude-console')
addAccounts(geminiAccounts, 'gemini')
addAccounts(geminiApiAccounts, 'gemini-api', 'api:')
addAccounts(openaiAccounts, 'openai')
addAccounts(openaiResponsesAccounts, 'openai-responses', 'responses:')
addAccounts(azureOpenaiAccounts, 'azure-openai')
addAccounts(bedrockAccounts, 'bedrock')
addAccounts(droidAccounts, 'droid')
addAccounts(ccrAccounts, 'ccr')
// 填充账户组缓存
if (Array.isArray(groups)) {
for (const group of groups) {
if (group && group.id && group.name) {
newGroupCache.set(group.id, { name: group.name, platform: group.platform })
}
}
}
this.accountCache = newAccountCache
this.groupCache = newGroupCache
this.lastRefresh = Date.now()
logger.debug(
`账户名称缓存已刷新: ${newAccountCache.size} 个账户, ${newGroupCache.size} 个分组`
)
} catch (error) {
logger.error('刷新账户名称缓存失败:', error)
} finally {
this.isRefreshing = false
}
}
/**
* 获取账户显示名称
* @param {string} accountId - 账户ID可能带前缀
* @param {string} _fieldName - 字段名(如 claudeAccountId保留用于将来扩展
* @returns {string} 显示名称
*/
getAccountDisplayName(accountId, _fieldName) {
if (!accountId) {
return null
}
// 处理账户组
if (accountId.startsWith('group:')) {
const groupId = accountId.substring(6)
const group = this.groupCache.get(groupId)
if (group) {
return `分组-${group.name}`
}
return `分组-${groupId.substring(0, 8)}`
}
// 直接查找(包括带前缀的 api:xxx, responses:xxx
const cached = this.accountCache.get(accountId)
if (cached) {
return cached.name
}
// 尝试去掉前缀查找
let realId = accountId
if (accountId.startsWith('api:')) {
realId = accountId.substring(4)
} else if (accountId.startsWith('responses:')) {
realId = accountId.substring(10)
}
if (realId !== accountId) {
const cached2 = this.accountCache.get(realId)
if (cached2) {
return cached2.name
}
}
// 未找到,返回 ID 前缀
return `${accountId.substring(0, 8)}...`
}
/**
* 获取 API Key 的所有绑定账户显示名称
* @param {Object} apiKey - API Key 对象
* @returns {Array<{field: string, platform: string, name: string, accountId: string}>}
*/
getBindingDisplayNames(apiKey) {
const bindings = []
const bindingFields = [
{ field: 'claudeAccountId', platform: 'Claude' },
{ field: 'claudeConsoleAccountId', platform: 'Claude Console' },
{ field: 'geminiAccountId', platform: 'Gemini' },
{ field: 'openaiAccountId', platform: 'OpenAI' },
{ field: 'azureOpenaiAccountId', platform: 'Azure OpenAI' },
{ field: 'bedrockAccountId', platform: 'Bedrock' },
{ field: 'droidAccountId', platform: 'Droid' },
{ field: 'ccrAccountId', platform: 'CCR' }
]
for (const { field, platform } of bindingFields) {
const accountId = apiKey[field]
if (accountId) {
const name = this.getAccountDisplayName(accountId, field)
bindings.push({ field, platform, name, accountId })
}
}
return bindings
}
/**
* 搜索绑定账号
* @param {Array} apiKeys - API Key 列表
* @param {string} keyword - 搜索关键词
* @returns {Array} 匹配的 API Key 列表
*/
searchByBindingAccount(apiKeys, keyword) {
const lowerKeyword = keyword.toLowerCase().trim()
if (!lowerKeyword) {
return apiKeys
}
return apiKeys.filter((key) => {
const bindings = this.getBindingDisplayNames(key)
// 无绑定时,匹配"共享池"
if (bindings.length === 0) {
return '共享池'.includes(lowerKeyword) || 'shared'.includes(lowerKeyword)
}
// 匹配任一绑定账户
return bindings.some((binding) => {
// 匹配账户名称
if (binding.name && binding.name.toLowerCase().includes(lowerKeyword)) {
return true
}
// 匹配平台名称
if (binding.platform.toLowerCase().includes(lowerKeyword)) {
return true
}
// 匹配账户 ID
if (binding.accountId.toLowerCase().includes(lowerKeyword)) {
return true
}
return false
})
})
}
/**
* 清除缓存(用于测试或强制刷新)
*/
clearCache() {
this.accountCache.clear()
this.groupCache.clear()
this.lastRefresh = 0
}
}
// 单例导出
module.exports = new AccountNameCacheService()

View File

@@ -4,72 +4,6 @@ const config = require('../../config/config')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const ACCOUNT_TYPE_CONFIG = {
claude: { prefix: 'claude:account:' },
'claude-console': { prefix: 'claude_console_account:' },
openai: { prefix: 'openai:account:' },
'openai-responses': { prefix: 'openai_responses_account:' },
'azure-openai': { prefix: 'azure_openai:account:' },
gemini: { prefix: 'gemini_account:' },
'gemini-api': { prefix: 'gemini_api_account:' },
droid: { prefix: 'droid:account:' }
}
const ACCOUNT_TYPE_PRIORITY = [
'openai',
'openai-responses',
'azure-openai',
'claude',
'claude-console',
'gemini',
'gemini-api',
'droid'
]
const ACCOUNT_CATEGORY_MAP = {
claude: 'claude',
'claude-console': 'claude',
openai: 'openai',
'openai-responses': 'openai',
'azure-openai': 'openai',
gemini: 'gemini',
'gemini-api': 'gemini',
droid: 'droid'
}
function normalizeAccountTypeKey(type) {
if (!type) {
return null
}
const lower = String(type).toLowerCase()
if (lower === 'claude_console') {
return 'claude-console'
}
if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') {
return 'openai-responses'
}
if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') {
return 'azure-openai'
}
if (lower === 'gemini_api' || lower === 'gemini-api') {
return 'gemini-api'
}
return lower
}
function sanitizeAccountIdForType(accountId, accountType) {
if (!accountId || typeof accountId !== 'string') {
return accountId
}
if (accountType === 'openai-responses') {
return accountId.replace(/^responses:/, '')
}
if (accountType === 'gemini-api') {
return accountId.replace(/^api:/, '')
}
return accountId
}
class ApiKeyService {
constructor() {
this.prefix = config.security.apiKeyPrefix
@@ -88,8 +22,7 @@ class ApiKeyService {
openaiAccountId = null,
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
droidAccountId = null,
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true,
concurrencyLimit = 0,
rateLimitWindow = null,
@@ -100,11 +33,9 @@ class ApiKeyService {
enableClientRestriction = false,
allowedClients = [],
dailyCostLimit = 0,
totalCostLimit = 0,
weeklyOpusCostLimit = 0,
tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
icon = '' // 新增图标base64编码
} = options
@@ -131,18 +62,15 @@ class ApiKeyService {
openaiAccountId: openaiAccountId || '',
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
droidAccountId: droidAccountId || '',
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []),
dailyCostLimit: String(dailyCostLimit || 0),
totalCostLimit: String(totalCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
tags: JSON.stringify(tags || []),
activationDays: String(activationDays || 0), // 新增:激活后有效天数
activationUnit: activationUnit || 'days', // 新增:激活时间单位
expirationMode: expirationMode || 'fixed', // 新增:过期模式
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
@@ -158,14 +86,6 @@ class ApiKeyService {
// 保存API Key数据并建立哈希映射
await redis.setApiKey(keyId, keyData, hashedKey)
// 同步添加到费用排序索引
try {
const costRankService = require('./costRankService')
await costRankService.addKeyToIndexes(keyId)
} catch (err) {
logger.warn(`Failed to add key ${keyId} to cost rank indexes:`, err.message)
}
logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
return {
@@ -185,18 +105,15 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
tags: JSON.parse(keyData.tags || '[]'),
activationDays: parseInt(keyData.activationDays || 0),
activationUnit: keyData.activationUnit || 'days',
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activatedAt: keyData.activatedAt,
@@ -220,10 +137,6 @@ class ApiKeyService {
const keyData = await redis.findApiKeyByHash(hashedKey)
if (!keyData) {
// ⚠️ 警告:映射表查找失败,可能是竞态条件或映射表损坏
logger.warn(
`⚠️ API key not found in hash map: ${hashedKey.substring(0, 16)}... (possible race condition or corrupted hash map)`
)
return { valid: false, error: 'API key not found' }
}
@@ -236,18 +149,8 @@ class ApiKeyService {
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
// 首次使用,需要激活
const now = new Date()
const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30
const activationUnit = keyData.activationUnit || 'days' // 默认天
// 根据单位计算过期时间
let milliseconds
if (activationUnit === 'hours') {
milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒
} else {
milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒
}
const expiresAt = new Date(now.getTime() + milliseconds)
const activationDays = parseInt(keyData.activationDays || 30) // 默认30
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
// 更新激活状态和过期时间
keyData.isActivated = 'true'
@@ -259,9 +162,7 @@ class ApiKeyService {
await redis.setApiKey(keyData.id, keyData)
logger.success(
`🔓 API key activated: ${keyData.id} (${
keyData.name
}), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}`
`🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
)
}
@@ -287,12 +188,8 @@ class ApiKeyService {
// 获取使用统计(供返回数据使用)
const usage = await redis.getUsageStats(keyData.id)
// 获取费用统计
const [dailyCost, costStats] = await Promise.all([
redis.getDailyCost(keyData.id),
redis.getCostStats(keyData.id)
])
const totalCost = costStats?.total || 0
// 获取当日费用统计
const dailyCost = await redis.getDailyCost(keyData.id)
// 更新最后使用时间优化只在实际API调用时更新而不是验证时
// 注意lastUsedAt的更新已移至recordUsage方法中
@@ -337,7 +234,6 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -349,10 +245,8 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
totalCost,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
@@ -383,8 +277,7 @@ class ApiKeyService {
// 检查是否激活
if (keyData.isActive !== 'true') {
const keyName = keyData.name || 'Unknown'
return { valid: false, error: `API Key "${keyName}" 已被禁用`, keyName }
return { valid: false, error: 'API key is disabled' }
}
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
@@ -395,8 +288,7 @@ class ApiKeyService {
keyData.expiresAt &&
new Date() > new Date(keyData.expiresAt)
) {
const keyName = keyData.name || 'Unknown'
return { valid: false, error: `API Key "${keyName}" 已过期`, keyName }
return { valid: false, error: 'API key has expired' }
}
// 如果API Key属于某个用户检查用户是否被禁用
@@ -414,10 +306,7 @@ class ApiKeyService {
}
// 获取当日费用
const [dailyCost, costStats] = await Promise.all([
redis.getDailyCost(keyData.id),
redis.getCostStats(keyData.id)
])
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
// 获取使用统计
const usage = await redis.getUsageStats(keyData.id)
@@ -458,7 +347,6 @@ class ApiKeyService {
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
activationDays: parseInt(keyData.activationDays || 0),
activationUnit: keyData.activationUnit || 'days',
activatedAt: keyData.activatedAt || null,
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
@@ -466,7 +354,6 @@ class ApiKeyService {
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -478,10 +365,8 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0,
totalCost: costStats?.total || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags,
usage
@@ -498,7 +383,6 @@ class ApiKeyService {
try {
let apiKeys = await redis.getAllApiKeys()
const client = redis.getClientSafe()
const accountInfoCache = new Map()
// 默认过滤掉已删除的API Keys
if (!includeDeleted) {
@@ -527,12 +411,10 @@ class ApiKeyService {
key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
key.activationDays = parseInt(key.activationDays || 0)
key.activationUnit = key.activationUnit || 'days'
key.expirationMode = key.expirationMode || 'fixed'
key.isActivated = key.isActivated === 'true'
key.activatedAt = key.activatedAt || null
@@ -605,48 +487,6 @@ class ApiKeyService {
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
delete key.ccrAccountId
}
let lastUsageRecord = null
try {
const usageRecords = await redis.getUsageRecords(key.id, 1)
if (Array.isArray(usageRecords) && usageRecords.length > 0) {
lastUsageRecord = usageRecords[0]
}
} catch (error) {
logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error)
}
if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) {
const resolvedAccount = await this._resolveLastUsageAccount(
key,
lastUsageRecord,
accountInfoCache,
client
)
if (resolvedAccount) {
key.lastUsage = {
accountId: resolvedAccount.accountId,
rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId,
accountType: resolvedAccount.accountType,
accountCategory: resolvedAccount.accountCategory,
accountName: resolvedAccount.accountName,
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
}
} else {
key.lastUsage = {
accountId: null,
rawAccountId: lastUsageRecord.accountId || null,
accountType: 'deleted',
accountCategory: 'deleted',
accountName: '已删除',
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null
}
}
} else {
key.lastUsage = null
}
delete key.apiKey // 不返回哈希后的key
}
@@ -681,11 +521,9 @@ class ApiKeyService {
'openaiAccountId',
'azureOpenaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID
'droidAccountId',
'permissions',
'expiresAt',
'activationDays', // 新增:激活后有效天数
'activationUnit', // 新增:激活时间单位
'expirationMode', // 新增:过期模式
'isActivated', // 新增:是否已激活
'activatedAt', // 新增:激活时间
@@ -694,7 +532,6 @@ class ApiKeyService {
'enableClientRestriction',
'allowedClients',
'dailyCostLimit',
'totalCostLimit',
'weeklyOpusCostLimit',
'tags',
'userId', // 新增用户ID所有者变更
@@ -726,11 +563,10 @@ class ApiKeyService {
updatedData.updatedAt = new Date().toISOString()
// 传递hashedKey以确保映射表一致性
// keyData.apiKey 存储的就是 hashedKey见generateApiKey第123行
await redis.setApiKey(keyId, updatedData, keyData.apiKey)
// 更新时不需要重新建立哈希映射因为API Key本身没有变化
await redis.setApiKey(keyId, updatedData)
logger.success(`📝 Updated API key: ${keyId}, hashMap updated`)
logger.success(`📝 Updated API key: ${keyId}`)
return { success: true }
} catch (error) {
@@ -764,14 +600,6 @@ class ApiKeyService {
await redis.deleteApiKeyHash(keyData.apiKey)
}
// 从费用排序索引中移除
try {
const costRankService = require('./costRankService')
await costRankService.removeKeyFromIndexes(keyId)
} catch (err) {
logger.warn(`Failed to remove key ${keyId} from cost rank indexes:`, err.message)
}
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
return { success: true }
@@ -823,14 +651,6 @@ class ApiKeyService {
})
}
// 重新添加到费用排序索引
try {
const costRankService = require('./costRankService')
await costRankService.addKeyToIndexes(keyId)
} catch (err) {
logger.warn(`Failed to add restored key ${keyId} to cost rank indexes:`, err.message)
}
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
return { success: true, apiKey: updatedData }
@@ -1007,21 +827,6 @@ class ApiKeyService {
}
}
// 记录单次请求的使用详情
const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0
await redis.addUsageRecord(keyId, {
timestamp: new Date().toISOString(),
model,
accountId: accountId || null,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
cost: Number(usageCost.toFixed(6)),
costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined
})
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
if (cacheCreateTokens > 0) {
logParts.push(`Cache Create: ${cacheCreateTokens}`)
@@ -1057,9 +862,7 @@ class ApiKeyService {
// 记录 Opus 周费用
await redis.incrementWeeklyOpusCost(keyId, cost)
logger.database(
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
6
)}, model: ${model}, account type: ${accountType}`
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}`
)
} catch (error) {
logger.error('❌ Failed to record Opus cost:', error)
@@ -1093,46 +896,9 @@ class ApiKeyService {
await pricingService.initialize()
}
costInfo = pricingService.calculateCost(usageObject, model)
// 验证计算结果
if (!costInfo || typeof costInfo.totalCost !== 'number') {
logger.error(`❌ Invalid cost calculation result for model ${model}:`, costInfo)
// 使用 CostCalculator 作为后备
const CostCalculator = require('../utils/costCalculator')
const fallbackCost = CostCalculator.calculateCost(usageObject, model)
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) {
logger.warn(
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}`
)
costInfo = {
totalCost: fallbackCost.costs.total,
ephemeral5mCost: 0,
ephemeral1hCost: 0
}
} else {
costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 }
}
}
} catch (pricingError) {
logger.error(`❌ Failed to calculate cost for model ${model}:`, pricingError)
logger.error(` Usage object:`, JSON.stringify(usageObject))
// 使用 CostCalculator 作为后备
try {
const CostCalculator = require('../utils/costCalculator')
const fallbackCost = CostCalculator.calculateCost(usageObject, model)
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) {
logger.warn(
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}`
)
costInfo = {
totalCost: fallbackCost.costs.total,
ephemeral5mCost: 0,
ephemeral1hCost: 0
}
}
} catch (fallbackError) {
logger.error(`❌ Fallback cost calculation also failed:`, fallbackError)
}
logger.error('❌ Failed to calculate cost:', pricingError)
// 继续执行,不要因为费用计算失败而跳过统计记录
}
// 提取详细的缓存创建数据
@@ -1171,21 +937,11 @@ class ApiKeyService {
// 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
logger.database(
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(
6
)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
)
}
} else {
// 如果有 token 使用但费用为 0记录警告
if (totalTokens > 0) {
logger.warn(
`⚠️ No cost recorded for ${keyId} - zero cost for model: ${model} (tokens: ${totalTokens})`
)
logger.warn(` This may indicate a pricing issue or model not found in pricing data`)
} else {
logger.debug(`💰 No cost recorded for ${keyId} - zero tokens for model: ${model}`)
}
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
}
// 获取API Key数据以确定关联的账户
@@ -1217,32 +973,6 @@ class ApiKeyService {
}
}
const usageRecord = {
timestamp: new Date().toISOString(),
model,
accountId: accountId || null,
accountType: accountType || null,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
ephemeral5mTokens,
ephemeral1hTokens,
totalTokens,
cost: Number((costInfo.totalCost || 0).toFixed(6)),
costBreakdown: {
input: costInfo.inputCost || 0,
output: costInfo.outputCost || 0,
cacheCreate: costInfo.cacheCreateCost || 0,
cacheRead: costInfo.cacheReadCost || 0,
ephemeral5m: costInfo.ephemeral5mCost || 0,
ephemeral1h: costInfo.ephemeral1hCost || 0
},
isLongContext: costInfo.isLongContextRequest || false
}
await redis.addUsageRecord(keyId, usageRecord)
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
if (cacheCreateTokens > 0) {
logParts.push(`Cache Create: ${cacheCreateTokens}`)
@@ -1265,180 +995,11 @@ class ApiKeyService {
logParts.push(`Total: ${totalTokens} tokens`)
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
// 🔔 发布计费事件到消息队列(异步非阻塞)
this._publishBillingEvent({
keyId,
keyName: keyData?.name,
userId: keyData?.userId,
model,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
ephemeral5mTokens,
ephemeral1hTokens,
totalTokens,
cost: costInfo.totalCost || 0,
costBreakdown: {
input: costInfo.inputCost || 0,
output: costInfo.outputCost || 0,
cacheCreate: costInfo.cacheCreateCost || 0,
cacheRead: costInfo.cacheReadCost || 0,
ephemeral5m: costInfo.ephemeral5mCost || 0,
ephemeral1h: costInfo.ephemeral1hCost || 0
},
accountId,
accountType,
isLongContext: costInfo.isLongContextRequest || false,
requestTimestamp: usageRecord.timestamp
}).catch((err) => {
// 发布失败不影响主流程,只记录错误
logger.warn('⚠️ Failed to publish billing event:', err.message)
})
} catch (error) {
logger.error('❌ Failed to record usage:', error)
}
}
async _fetchAccountInfo(accountId, accountType, cache, client) {
if (!client || !accountId || !accountType) {
return null
}
const cacheKey = `${accountType}:${accountId}`
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
}
const accountConfig = ACCOUNT_TYPE_CONFIG[accountType]
if (!accountConfig) {
cache.set(cacheKey, null)
return null
}
const redisKey = `${accountConfig.prefix}${accountId}`
let accountData = null
try {
accountData = await client.hgetall(redisKey)
} catch (error) {
logger.debug(`加载账号信息失败 ${redisKey}:`, error)
}
if (accountData && Object.keys(accountData).length > 0) {
const displayName =
accountData.name ||
accountData.displayName ||
accountData.email ||
accountData.username ||
accountData.description ||
accountId
const info = { id: accountId, name: displayName }
cache.set(cacheKey, info)
return info
}
cache.set(cacheKey, null)
return null
}
async _resolveAccountByUsageRecord(usageRecord, cache, client) {
if (!usageRecord || !client) {
return null
}
const rawAccountId = usageRecord.accountId || null
const rawAccountType = normalizeAccountTypeKey(usageRecord.accountType)
const modelName = usageRecord.model || usageRecord.actualModel || usageRecord.service || null
if (!rawAccountId && !rawAccountType) {
return null
}
const candidateIds = new Set()
if (rawAccountId) {
candidateIds.add(rawAccountId)
if (typeof rawAccountId === 'string' && rawAccountId.startsWith('responses:')) {
candidateIds.add(rawAccountId.replace(/^responses:/, ''))
}
if (typeof rawAccountId === 'string' && rawAccountId.startsWith('api:')) {
candidateIds.add(rawAccountId.replace(/^api:/, ''))
}
}
if (candidateIds.size === 0) {
return null
}
const typeCandidates = []
const pushType = (type) => {
const normalized = normalizeAccountTypeKey(type)
if (normalized && ACCOUNT_TYPE_CONFIG[normalized] && !typeCandidates.includes(normalized)) {
typeCandidates.push(normalized)
}
}
pushType(rawAccountType)
if (modelName) {
const lowerModel = modelName.toLowerCase()
if (lowerModel.includes('gpt') || lowerModel.includes('openai')) {
pushType('openai')
pushType('openai-responses')
pushType('azure-openai')
} else if (lowerModel.includes('gemini')) {
pushType('gemini')
pushType('gemini-api')
} else if (lowerModel.includes('claude') || lowerModel.includes('anthropic')) {
pushType('claude')
pushType('claude-console')
} else if (lowerModel.includes('droid')) {
pushType('droid')
}
}
ACCOUNT_TYPE_PRIORITY.forEach(pushType)
for (const type of typeCandidates) {
const accountConfig = ACCOUNT_TYPE_CONFIG[type]
if (!accountConfig) {
continue
}
for (const candidateId of candidateIds) {
const normalizedId = sanitizeAccountIdForType(candidateId, type)
const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client)
if (accountInfo) {
return {
accountId: normalizedId,
accountName: accountInfo.name,
accountType: type,
accountCategory: ACCOUNT_CATEGORY_MAP[type] || 'other',
rawAccountId: rawAccountId || normalizedId
}
}
}
}
return null
}
async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) {
return await this._resolveAccountByUsageRecord(usageRecord, cache, client)
}
// 🔔 发布计费事件(内部方法)
async _publishBillingEvent(eventData) {
try {
const billingEventPublisher = require('./billingEventPublisher')
await billingEventPublisher.publishBillingEvent(eventData)
} catch (error) {
// 静默失败,不影响主流程
logger.debug('Failed to publish billing event:', error.message)
}
}
// 🔐 生成密钥
_generateSecretKey() {
return crypto.randomBytes(32).toString('hex')
@@ -1453,24 +1014,8 @@ class ApiKeyService {
}
// 📈 获取使用统计
async getUsageStats(keyId, options = {}) {
const usageStats = await redis.getUsageStats(keyId)
// options 可能是字符串(兼容旧接口),仅当为对象时才解析
const optionObject =
options && typeof options === 'object' && !Array.isArray(options) ? options : {}
if (optionObject.includeRecords === false) {
return usageStats
}
const recordLimit = optionObject.recordLimit || 20
const recentRecords = await redis.getUsageRecords(keyId, recordLimit)
return {
...usageStats,
recentRecords
}
async getUsageStats(keyId) {
return await redis.getUsageStats(keyId)
}
// 📊 获取账户使用统计
@@ -1522,11 +1067,9 @@ class ApiKeyService {
dailyCost,
totalCost: costStats.total,
dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
totalCostLimit: parseFloat(key.totalCostLimit || 0),
userId: key.userId,
userUsername: key.userUsername,
createdBy: key.createdBy,
droidAccountId: key.droidAccountId,
// Include deletion fields for deleted keys
isDeleted: key.isDeleted,
deletedAt: key.deletedAt,
@@ -1569,17 +1112,7 @@ class ApiKeyService {
userUsername: keyData.userUsername,
createdBy: keyData.createdBy,
permissions: keyData.permissions,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
// 所有平台账户绑定字段
claudeAccountId: keyData.claudeAccountId,
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
droidAccountId: keyData.droidAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
ccrAccountId: keyData.ccrAccountId
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0)
}
} catch (error) {
logger.error('❌ Failed to get API key by ID:', error)
@@ -1714,77 +1247,6 @@ class ApiKeyService {
}
}
// 🔓 解绑账号从所有API Keys
async unbindAccountFromAllKeys(accountId, accountType) {
try {
// 账号类型与字段的映射关系
const fieldMap = {
claude: 'claudeAccountId',
'claude-console': 'claudeConsoleAccountId',
gemini: 'geminiAccountId',
'gemini-api': 'geminiAccountId', // 特殊处理,带 api: 前缀
openai: 'openaiAccountId',
'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀
azure_openai: 'azureOpenaiAccountId',
bedrock: 'bedrockAccountId',
droid: 'droidAccountId',
ccr: null // CCR 账号没有对应的 API Key 字段
}
const field = fieldMap[accountType]
if (!field) {
logger.info(`账号类型 ${accountType} 不需要解绑 API Key`)
return 0
}
// 获取所有API Keys
const allKeys = await this.getAllApiKeys()
// 筛选绑定到此账号的 API Keys
let boundKeys = []
if (accountType === 'openai-responses') {
// OpenAI-Responses 特殊处理:查找 openaiAccountId 字段中带 responses: 前缀的
boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`)
} else if (accountType === 'gemini-api') {
// Gemini-API 特殊处理:查找 geminiAccountId 字段中带 api: 前缀的
boundKeys = allKeys.filter((key) => key.geminiAccountId === `api:${accountId}`)
} else {
// 其他账号类型正常匹配
boundKeys = allKeys.filter((key) => key[field] === accountId)
}
// 批量解绑
for (const key of boundKeys) {
const updates = {}
if (accountType === 'openai-responses') {
updates.openaiAccountId = null
} else if (accountType === 'gemini-api') {
updates.geminiAccountId = null
} else if (accountType === 'claude-console') {
updates.claudeConsoleAccountId = null
} else {
updates[field] = null
}
await this.updateApiKey(key.id, updates)
logger.info(
`✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}`
)
}
if (boundKeys.length > 0) {
logger.success(
`🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}`
)
}
return boundKeys.length
} catch (error) {
logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error)
return 0
}
}
// 🧹 清理过期的API Keys
async cleanupExpiredKeys() {
try {

View File

@@ -129,11 +129,6 @@ async function createAccount(accountData) {
supportedModels: JSON.stringify(
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
),
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Azure OpenAI 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
@@ -223,12 +218,6 @@ async function updateAccount(accountId, updates) {
: JSON.stringify(updates.supportedModels)
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Azure OpenAI 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -314,11 +303,7 @@ async function getAllAccounts() {
accounts.push({
...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false',
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
platform: 'azure-openai'
schedulable: accountData.schedulable !== 'false'
})
}
}
@@ -346,19 +331,6 @@ async function getSharedAccounts() {
return accounts
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 选择可用账户
async function selectAvailableAccount(sessionId = null) {
// 如果有会话ID尝试获取之前分配的账户
@@ -380,17 +352,9 @@ async function selectAvailableAccount(sessionId = null) {
const sharedAccounts = await getSharedAccounts()
// 过滤出可用的账户
const availableAccounts = sharedAccounts.filter((acc) => {
// ✅ 检查账户订阅是否过期
if (isSubscriptionExpired(acc)) {
logger.debug(
`⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}`
const availableAccounts = sharedAccounts.filter(
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
)
return false
}
return acc.isActive === 'true' && acc.schedulable === 'true'
})
if (availableAccounts.length === 0) {
throw new Error('No available Azure OpenAI accounts')

View File

@@ -82,9 +82,7 @@ async function handleAzureOpenAIRequest({
// 如果有代理,添加代理配置
if (proxyAgent) {
axiosConfig.httpAgent = proxyAgent
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
// 为代理添加额外的keep-alive设置
if (proxyAgent.options) {
proxyAgent.options.keepAlive = true

View File

@@ -56,11 +56,6 @@ class BedrockAccountService {
priority,
schedulable,
credentialType,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Bedrock 使用 AWS 凭证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: 'bedrock' // 标识这是Bedrock账户
@@ -147,14 +142,9 @@ class BedrockAccountService {
priority: account.priority,
schedulable: account.schedulable,
credentialType: account.credentialType,
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: account.subscriptionExpiresAt || null,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
type: 'bedrock',
platform: 'bedrock',
hasCredentials: !!account.awsCredentials
})
}
@@ -235,12 +225,6 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
account.subscriptionExpiresAt = updates.subscriptionExpiresAt
}
account.updatedAt = new Date().toISOString()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
@@ -298,17 +282,9 @@ class BedrockAccountService {
return { success: false, error: 'Failed to get accounts' }
}
const availableAccounts = accountsResult.data.filter((account) => {
// ✅ 检查账户订阅是否过期
if (this.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}`
const availableAccounts = accountsResult.data.filter(
(account) => account.isActive && account.schedulable
)
return false
}
return account.isActive && account.schedulable
})
if (availableAccounts.length === 0) {
return { success: false, error: 'No available Bedrock accounts' }
@@ -376,19 +352,6 @@ class BedrockAccountService {
}
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 🔑 生成加密密钥(缓存优化)
_generateEncryptionKey() {
if (!this._encryptionKeyCache) {

View File

@@ -1,224 +0,0 @@
const redis = require('../models/redis')
const logger = require('../utils/logger')
/**
* 计费事件发布器 - 使用 Redis Stream 解耦计费系统
*
* 设计原则:
* 1. 异步非阻塞: 发布失败不影响主流程
* 2. 结构化数据: 使用标准化的事件格式
* 3. 可追溯性: 每个事件包含完整上下文
*/
class BillingEventPublisher {
constructor() {
this.streamKey = 'billing:events'
this.maxLength = 100000 // 保留最近 10 万条事件
this.enabled = process.env.BILLING_EVENTS_ENABLED !== 'false' // 默认开启
}
/**
* 发布计费事件
* @param {Object} eventData - 事件数据
* @returns {Promise<string|null>} - 事件ID 或 null
*/
async publishBillingEvent(eventData) {
if (!this.enabled) {
logger.debug('📭 Billing events disabled, skipping publish')
return null
}
try {
const client = redis.getClientSafe()
// 构建标准化事件
const event = {
// 事件元数据
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
eventType: 'usage.recorded',
timestamp: new Date().toISOString(),
version: '1.0',
// 核心计费数据
apiKey: {
id: eventData.keyId,
name: eventData.keyName || null,
userId: eventData.userId || null
},
// 使用量详情
usage: {
model: eventData.model,
inputTokens: eventData.inputTokens || 0,
outputTokens: eventData.outputTokens || 0,
cacheCreateTokens: eventData.cacheCreateTokens || 0,
cacheReadTokens: eventData.cacheReadTokens || 0,
ephemeral5mTokens: eventData.ephemeral5mTokens || 0,
ephemeral1hTokens: eventData.ephemeral1hTokens || 0,
totalTokens: eventData.totalTokens || 0
},
// 费用详情
cost: {
total: eventData.cost || 0,
currency: 'USD',
breakdown: {
input: eventData.costBreakdown?.input || 0,
output: eventData.costBreakdown?.output || 0,
cacheCreate: eventData.costBreakdown?.cacheCreate || 0,
cacheRead: eventData.costBreakdown?.cacheRead || 0,
ephemeral5m: eventData.costBreakdown?.ephemeral5m || 0,
ephemeral1h: eventData.costBreakdown?.ephemeral1h || 0
}
},
// 账户信息
account: {
id: eventData.accountId || null,
type: eventData.accountType || null
},
// 请求上下文
context: {
isLongContext: eventData.isLongContext || false,
requestTimestamp: eventData.requestTimestamp || new Date().toISOString()
}
}
// 使用 XADD 发布事件到 Stream
// MAXLEN ~ 10000: 近似截断,保持性能
const messageId = await client.xadd(
this.streamKey,
'MAXLEN',
'~',
this.maxLength,
'*', // 自动生成消息ID
'data',
JSON.stringify(event)
)
logger.debug(
`📤 Published billing event: ${messageId} | Key: ${eventData.keyId} | Cost: $${event.cost.total.toFixed(6)}`
)
return messageId
} catch (error) {
// ⚠️ 发布失败不影响主流程,只记录错误
logger.error('❌ Failed to publish billing event:', error)
return null
}
}
/**
* 批量发布计费事件(优化性能)
* @param {Array<Object>} events - 事件数组
* @returns {Promise<number>} - 成功发布的事件数
*/
async publishBatchBillingEvents(events) {
if (!this.enabled || !events || events.length === 0) {
return 0
}
try {
const client = redis.getClientSafe()
const pipeline = client.pipeline()
events.forEach((eventData) => {
const event = {
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
eventType: 'usage.recorded',
timestamp: new Date().toISOString(),
version: '1.0',
apiKey: {
id: eventData.keyId,
name: eventData.keyName || null
},
usage: {
model: eventData.model,
inputTokens: eventData.inputTokens || 0,
outputTokens: eventData.outputTokens || 0,
totalTokens: eventData.totalTokens || 0
},
cost: {
total: eventData.cost || 0,
currency: 'USD'
}
}
pipeline.xadd(
this.streamKey,
'MAXLEN',
'~',
this.maxLength,
'*',
'data',
JSON.stringify(event)
)
})
const results = await pipeline.exec()
const successCount = results.filter((r) => r[0] === null).length
logger.info(`📤 Batch published ${successCount}/${events.length} billing events`)
return successCount
} catch (error) {
logger.error('❌ Failed to batch publish billing events:', error)
return 0
}
}
/**
* 获取 Stream 信息(用于监控)
* @returns {Promise<Object>}
*/
async getStreamInfo() {
try {
const client = redis.getClientSafe()
const info = await client.xinfo('STREAM', this.streamKey)
// 解析 Redis XINFO 返回的数组格式
const result = {}
for (let i = 0; i < info.length; i += 2) {
result[info[i]] = info[i + 1]
}
return {
length: result.length || 0,
firstEntry: result['first-entry'] || null,
lastEntry: result['last-entry'] || null,
groups: result.groups || 0
}
} catch (error) {
if (error.message.includes('no such key')) {
return { length: 0, groups: 0 }
}
logger.error('❌ Failed to get stream info:', error)
return null
}
}
/**
* 创建消费者组(供外部计费系统使用)
* @param {string} groupName - 消费者组名称
* @returns {Promise<boolean>}
*/
async createConsumerGroup(groupName = 'billing-system') {
try {
const client = redis.getClientSafe()
// MKSTREAM: 如果 stream 不存在则创建
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
logger.success(`✅ Created consumer group: ${groupName}`)
return true
} catch (error) {
if (error.message.includes('BUSYGROUP')) {
logger.debug(`Consumer group ${groupName} already exists`)
return true
}
logger.error(`❌ Failed to create consumer group ${groupName}:`, error)
return false
}
}
}
module.exports = new BillingEventPublisher()

View File

@@ -76,11 +76,6 @@ class CcrAccountService {
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意CCR 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(),
lastUsedAt: '',
status: 'active',
@@ -170,10 +165,6 @@ class CcrAccountService {
errorMessage: accountData.errorMessage,
rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
// 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
@@ -297,12 +288,6 @@ class CcrAccountService {
updatedData.quotaResetTime = updates.quotaResetTime
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// CCR 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
// 处理共享账户集合变更
@@ -563,21 +548,8 @@ class CcrAccountService {
if (!modelMapping || Object.keys(modelMapping).length === 0) {
return true
}
// 检查请求的模型是否在映射表的键中(精确匹配)
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
return true
}
// 尝试大小写不敏感匹配
const requestedModelLower = requestedModel.toLowerCase()
for (const key of Object.keys(modelMapping)) {
if (key.toLowerCase() === requestedModelLower) {
return true
}
}
return false
// 检查请求的模型是否在映射表的键中
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
}
// 🔄 获取映射后的模型名称
@@ -587,21 +559,8 @@ class CcrAccountService {
return requestedModel
}
// 精确匹配
if (modelMapping[requestedModel]) {
return modelMapping[requestedModel]
}
// 大小写不敏感匹配
const requestedModelLower = requestedModel.toLowerCase()
for (const [key, value] of Object.entries(modelMapping)) {
if (key.toLowerCase() === requestedModelLower) {
return value
}
}
// 如果不存在映射则返回原模型名
return requestedModel
// 返回映射后的模型名,如果不存在映射则返回原模型名
return modelMapping[requestedModel] || requestedModel
}
// 🔐 加密敏感数据
@@ -939,19 +898,6 @@ class CcrAccountService {
throw error
}
}
/**
* ⏰ 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
}
module.exports = new CcrAccountService()

View File

@@ -121,17 +121,12 @@ class CcrRelayService {
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
signal: abortController.signal,
validateStatus: () => true // 接受所有状态码
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key
@@ -350,17 +345,12 @@ class CcrRelayService {
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
responseType: 'stream',
validateStatus: () => true // 接受所有状态码
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key

File diff suppressed because it is too large Load Diff

View File

@@ -36,20 +36,6 @@ class ClaudeConsoleAccountService {
)
}
_getBlockedHandlingMinutes() {
const raw = process.env.CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES
if (raw === undefined || raw === null || raw === '') {
return 0
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 0
}
return parsed
}
// 🏢 创建Claude Console账户
async createAccount(options = {}) {
const {
@@ -66,8 +52,7 @@ class ClaudeConsoleAccountService {
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0 // 最大并发任务数0表示无限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式
} = options
// 验证必填字段
@@ -98,11 +83,6 @@ class ClaudeConsoleAccountService {
lastUsedAt: '',
status: 'active',
errorMessage: '',
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意Claude Console 没有 OAuth token因此没有 expiresAttoken过期
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
// 限流相关
rateLimitedAt: '',
rateLimitStatus: '',
@@ -114,8 +94,7 @@ class ClaudeConsoleAccountService {
// 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数0表示无限制
quotaStoppedAt: '' // 因额度停用的时间
}
const client = redis.getClientSafe()
@@ -151,9 +130,7 @@ class ClaudeConsoleAccountService {
dailyUsage: 0,
lastResetDate: accountData.lastResetDate,
quotaResetTime,
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
activeTaskCount: 0 // 新增新建账户当前并发数为0
quotaStoppedAt: null
}
}
@@ -167,18 +144,9 @@ class ClaudeConsoleAccountService {
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
if (!accountData.id) {
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据执行清理: ${key}`)
await client.del(key)
continue
}
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData)
// 获取实时并发计数
const activeTaskCount = await redis.getConsoleAccountConcurrency(accountData.id)
accounts.push({
id: accountData.id,
platform: accountData.platform,
@@ -200,20 +168,12 @@ class ClaudeConsoleAccountService {
errorMessage: accountData.errorMessage,
rateLimitInfo,
schedulable: accountData.schedulable !== 'false', // 默认为true只有明确设置为false才不可调度
// ✅ 前端显示订阅过期时间(业务字段)
expiresAt: accountData.subscriptionExpiresAt || null,
// 额度管理相关
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null,
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount
quotaStoppedAt: accountData.quotaStoppedAt || null
})
}
}
@@ -264,11 +224,6 @@ class ClaudeConsoleAccountService {
accountData.proxy = JSON.parse(accountData.proxy)
}
// 解析并发控制字段
accountData.maxConcurrentTasks = parseInt(accountData.maxConcurrentTasks) || 0
// 获取实时并发计数
accountData.activeTaskCount = await redis.getConsoleAccountConcurrency(accountId)
logger.debug(
`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
)
@@ -363,17 +318,6 @@ class ClaudeConsoleAccountService {
updatedData.quotaStoppedAt = updates.quotaStoppedAt
}
// 并发控制相关字段
if (updates.maxConcurrentTasks !== undefined) {
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
}
// 处理账户类型变更
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
updatedData.accountType = updates.accountType
@@ -546,29 +490,20 @@ class ClaudeConsoleAccountService {
errorMessage: ''
}
const hadAutoStop = accountData.rateLimitAutoStopped === 'true'
// 只恢复因限流而自动停止的账户
if (hadAutoStop && accountData.schedulable === 'false') {
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
updateData.schedulable = 'true' // 恢复调度
// 删除限流自动停止标记
await client.hdel(accountKey, 'rateLimitAutoStopped')
logger.info(
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared`
)
}
if (hadAutoStop) {
await client.hdel(accountKey, 'rateLimitAutoStopped')
}
await client.hset(accountKey, updateData)
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
}
} else {
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
logger.info(
` Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
)
}
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
}
@@ -725,183 +660,6 @@ class ClaudeConsoleAccountService {
}
}
// 🚫 标记账号为临时封禁状态400错误 - 账户临时禁用)
async markConsoleAccountBlocked(accountId, errorDetails = '') {
try {
const client = redis.getClientSafe()
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const blockedMinutes = this._getBlockedHandlingMinutes()
if (blockedMinutes <= 0) {
logger.info(
` CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 未设置或为0跳过账户封禁${account.name} (${accountId})`
)
if (account.blockedStatus === 'blocked') {
try {
await this.removeAccountBlocked(accountId)
} catch (cleanupError) {
logger.warn(`⚠️ 尝试移除账户封禁状态失败:${accountId}`, cleanupError)
}
}
return { success: false, skipped: true }
}
const updates = {
blockedAt: new Date().toISOString(),
blockedStatus: 'blocked',
isActive: 'false', // 禁用账户与429保持一致
schedulable: 'false', // 停止调度与429保持一致
status: 'account_blocked', // 设置状态与429保持一致
errorMessage: '账户临时被禁用400错误',
// 使用独立的封禁自动停止标记
blockedAutoStopped: 'true'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知包含完整错误详情
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_BLOCKED',
reason: `账户临时被禁用400错误。账户将在 ${blockedMinutes} 分钟后自动恢复。`,
errorDetails: errorDetails || '无错误详情',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send blocked webhook notification:', webhookError)
}
logger.warn(`🚫 Claude Console account temporarily blocked: ${account.name} (${accountId})`)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark Claude Console account as blocked: ${accountId}`, error)
throw error
}
}
// ✅ 移除账号的临时封禁状态
async removeAccountBlocked(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 获取账户当前状态和额度信息
const [currentStatus, quotaStoppedAt] = await client.hmget(
accountKey,
'status',
'quotaStoppedAt'
)
// 删除封禁相关字段
await client.hdel(accountKey, 'blockedAt', 'blockedStatus')
// 根据不同情况决定是否恢复账户
if (currentStatus === 'account_blocked') {
if (quotaStoppedAt) {
// 还有额度限制改为quota_exceeded状态
await client.hset(accountKey, {
status: 'quota_exceeded'
// isActive保持false
})
logger.info(
`⚠️ Blocked status removed but quota exceeded remains for account: ${accountId}`
)
} else {
// 没有额度限制,完全恢复
const accountData = await client.hgetall(accountKey)
const updateData = {
isActive: 'true',
status: 'active',
errorMessage: ''
}
const hadAutoStop = accountData.blockedAutoStopped === 'true'
// 只恢复因封禁而自动停止的账户
if (hadAutoStop && accountData.schedulable === 'false') {
updateData.schedulable = 'true' // 恢复调度
logger.info(
`✅ Auto-resuming scheduling for Claude Console account ${accountId} after blocked status cleared`
)
}
if (hadAutoStop) {
await client.hdel(accountKey, 'blockedAutoStopped')
}
await client.hset(accountKey, updateData)
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
}
} else {
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
logger.info(
` Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
)
}
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
}
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove blocked status for Claude Console account: ${accountId}`,
error
)
throw error
}
}
// 🔍 检查账号是否处于临时封禁状态
async isAccountBlocked(accountId) {
try {
const account = await this.getAccount(accountId)
if (!account) {
return false
}
if (account.blockedStatus === 'blocked' && account.blockedAt) {
const blockedDuration = this._getBlockedHandlingMinutes()
if (blockedDuration <= 0) {
await this.removeAccountBlocked(accountId)
return false
}
const blockedAt = new Date(account.blockedAt)
const now = new Date()
const minutesSinceBlocked = (now - blockedAt) / (1000 * 60)
// 禁用时长过后自动恢复
if (minutesSinceBlocked >= blockedDuration) {
await this.removeAccountBlocked(accountId)
return false
}
return true
}
return false
} catch (error) {
logger.error(
`❌ Failed to check blocked status for Claude Console account: ${accountId}`,
error
)
return false
}
}
// 🚫 标记账号为过载状态529错误
async markAccountOverloaded(accountId) {
try {
@@ -1208,20 +966,8 @@ class ClaudeConsoleAccountService {
return true
}
// 检查请求的模型是否在映射表的键中(精确匹配)
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
return true
}
// 尝试大小写不敏感匹配
const requestedModelLower = requestedModel.toLowerCase()
for (const key of Object.keys(modelMapping)) {
if (key.toLowerCase() === requestedModelLower) {
return true
}
}
return false
// 检查请求的模型是否在映射表的键中
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
}
// 🔄 获取映射后的模型名称
@@ -1231,21 +977,8 @@ class ClaudeConsoleAccountService {
return requestedModel
}
// 精确匹配
if (modelMapping[requestedModel]) {
return modelMapping[requestedModel]
}
// 大小写不敏感匹配
const requestedModelLower = requestedModel.toLowerCase()
for (const [key, value] of Object.entries(modelMapping)) {
if (key.toLowerCase() === requestedModelLower) {
return value
}
}
// 如果不存在则返回原模型
return requestedModel
// 返回映射后的模型,如果不存在则返回原模型
return modelMapping[requestedModel] || requestedModel
}
// 💰 检查账户使用额度(基于实时统计数据)
@@ -1497,84 +1230,6 @@ class ClaudeConsoleAccountService {
throw error
}
}
/**
* ⏰ 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 🚫 标记账户的 count_tokens 端点不可用
async markCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 检查账户是否存在
const exists = await client.exists(accountKey)
if (!exists) {
logger.warn(
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
)
return { success: false, reason: 'Account not found' }
}
await client.hset(accountKey, {
countTokensUnavailable: 'true',
countTokensUnavailableAt: new Date().toISOString()
})
logger.info(
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
throw error
}
}
// ✅ 移除账户的 count_tokens 不可用标记
async removeCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
logger.info(
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
)
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
error
)
throw error
}
}
// 🔍 检查账户的 count_tokens 端点是否不可用
async isCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const value = await client.hget(accountKey, 'countTokensUnavailable')
return value === 'true'
} catch (error) {
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
return false // 出错时默认返回可用,避免误阻断
}
}
}
module.exports = new ClaudeConsoleAccountService()

View File

@@ -1,18 +1,11 @@
const axios = require('axios')
const { v4: uuidv4 } = require('uuid')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const {
sanitizeUpstreamError,
sanitizeErrorMessage,
isAccountDisabledError
} = require('../utils/errorSanitizer')
class ClaudeConsoleRelayService {
constructor() {
this.defaultUserAgent = 'claude-cli/2.0.52 (external, cli)'
this.defaultUserAgent = 'claude-cli/1.0.69 (external, cli)'
}
// 🚀 转发请求到Claude Console API
@@ -27,8 +20,6 @@ class ClaudeConsoleRelayService {
) {
let abortController = null
let account = null
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
try {
// 获取账户信息
@@ -38,37 +29,8 @@ class ClaudeConsoleRelayService {
}
logger.info(
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
)
// 🔒 并发控制:原子性抢占槽位
if (account.maxConcurrentTasks > 0) {
// 先抢占,再检查 - 避免竞态条件
const newConcurrency = Number(
await redis.incrConsoleAccountConcurrency(accountId, requestId, 600)
)
concurrencyAcquired = true
// 检查是否超过限制
if (newConcurrency > account.maxConcurrentTasks) {
// 超限,立即回滚
await redis.decrConsoleAccountConcurrency(accountId, requestId)
concurrencyAcquired = false
logger.warn(
`⚠️ Console account ${account.name} (${accountId}) concurrency limit exceeded: ${newConcurrency}/${account.maxConcurrentTasks} (request: ${requestId}, rolled back)`
)
const error = new Error('Console account concurrency limit reached')
error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL'
error.accountId = accountId
throw error
}
logger.debug(
`🔓 Acquired concurrency slot for account ${account.name} (${accountId}), current: ${newConcurrency}/${account.maxConcurrentTasks}, request: ${requestId}`
)
}
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`)
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`)
@@ -160,17 +122,12 @@ class ClaudeConsoleRelayService {
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
signal: abortController.signal,
validateStatus: () => true // 接受所有状态码
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key
@@ -215,49 +172,14 @@ class ClaudeConsoleRelayService {
logger.debug(
`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
)
// 对于错误响应,记录原始错误和清理后的预览
if (response.status < 200 || response.status >= 300) {
// 记录原始错误响应(包含供应商信息,用于调试)
const rawData =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
logger.error(
`📝 Upstream error response from ${account?.name || accountId}: ${rawData.substring(0, 500)}`
)
// 记录清理后的数据到error
try {
const responseData =
typeof response.data === 'string' ? JSON.parse(response.data) : response.data
const sanitizedData = sanitizeUpstreamError(responseData)
logger.error(`🧹 [SANITIZED] Error response to client: ${JSON.stringify(sanitizedData)}`)
} catch (e) {
const rawText =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
const sanitizedText = sanitizeErrorMessage(rawText)
logger.error(`🧹 [SANITIZED] Error response to client: ${sanitizedText}`)
}
} else {
logger.debug(
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
)
}
// 检查是否为账户禁用/不可用的 400 错误
const accountDisabledError = isAccountDisabledError(response.status, response.data)
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (accountDisabledError) {
logger.error(
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
)
// 传入完整的错误详情到 webhook
const errorDetails =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
// 收到429先检查是否因为超过了手动配置的每日额度
@@ -284,30 +206,9 @@ class ClaudeConsoleRelayService {
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
// 准备响应体并清理错误信息(如果是错误响应)
let responseBody
if (response.status < 200 || response.status >= 300) {
// 错误响应,清理供应商信息
try {
const responseData =
typeof response.data === 'string' ? JSON.parse(response.data) : response.data
const sanitizedData = sanitizeUpstreamError(responseData)
responseBody = JSON.stringify(sanitizedData)
logger.debug(`🧹 Sanitized error response`)
} catch (parseError) {
// 如果无法解析为JSON尝试清理文本
const rawText =
const responseBody =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
responseBody = sanitizeErrorMessage(rawText)
logger.debug(`🧹 Sanitized error text`)
}
} else {
// 成功响应,不需要清理
responseBody =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
}
logger.debug(`[DEBUG] Final response body to return: ${responseBody.substring(0, 200)}...`)
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
return {
statusCode: response.status,
@@ -317,12 +218,7 @@ class ClaudeConsoleRelayService {
}
} catch (error) {
// 处理特定错误
if (
error.name === 'AbortError' ||
error.name === 'CanceledError' ||
error.code === 'ECONNABORTED' ||
error.code === 'ERR_CANCELED'
) {
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
logger.info('Request aborted due to client disconnect')
throw new Error('Client disconnected')
}
@@ -335,21 +231,6 @@ class ClaudeConsoleRelayService {
// 不再因为模型不支持而block账号
throw error
} finally {
// 🔓 并发控制:释放并发槽位
if (concurrencyAcquired) {
try {
await redis.decrConsoleAccountConcurrency(accountId, requestId)
logger.debug(
`🔓 Released concurrency slot for account ${account?.name || accountId}, request: ${requestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release concurrency slot for account ${accountId}, request: ${requestId}:`,
releaseError.message
)
}
}
}
}
@@ -365,10 +246,6 @@ class ClaudeConsoleRelayService {
options = {}
) {
let account = null
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
let leaseRefreshInterval = null // 租约刷新定时器
try {
// 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId)
@@ -377,56 +254,8 @@ class ClaudeConsoleRelayService {
}
logger.info(
`📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
`📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
)
// 🔒 并发控制:原子性抢占槽位
if (account.maxConcurrentTasks > 0) {
// 先抢占,再检查 - 避免竞态条件
const newConcurrency = Number(
await redis.incrConsoleAccountConcurrency(accountId, requestId, 600)
)
concurrencyAcquired = true
// 检查是否超过限制
if (newConcurrency > account.maxConcurrentTasks) {
// 超限,立即回滚
await redis.decrConsoleAccountConcurrency(accountId, requestId)
concurrencyAcquired = false
logger.warn(
`⚠️ Console account ${account.name} (${accountId}) concurrency limit exceeded: ${newConcurrency}/${account.maxConcurrentTasks} (stream request: ${requestId}, rolled back)`
)
const error = new Error('Console account concurrency limit reached')
error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL'
error.accountId = accountId
throw error
}
logger.debug(
`🔓 Acquired concurrency slot for stream account ${account.name} (${accountId}), current: ${newConcurrency}/${account.maxConcurrentTasks}, request: ${requestId}`
)
// 🔄 启动租约刷新定时器每5分钟刷新一次防止长连接租约过期
leaseRefreshInterval = setInterval(
async () => {
try {
await redis.refreshConsoleAccountConcurrencyLease(accountId, requestId, 600)
logger.debug(
`🔄 Refreshed concurrency lease for stream account ${account.name} (${accountId}), request: ${requestId}`
)
} catch (refreshError) {
logger.error(
`❌ Failed to refresh concurrency lease for account ${accountId}, request: ${requestId}:`,
refreshError.message
)
}
},
5 * 60 * 1000
) // 5分钟刷新一次
}
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
// 处理模型映射
@@ -478,29 +307,6 @@ class ClaudeConsoleRelayService {
error
)
throw error
} finally {
// 🛑 清理租约刷新定时器
if (leaseRefreshInterval) {
clearInterval(leaseRefreshInterval)
logger.debug(
`🛑 Cleared lease refresh interval for stream account ${account?.name || accountId}, request: ${requestId}`
)
}
// 🔓 并发控制:释放并发槽位
if (concurrencyAcquired) {
try {
await redis.decrConsoleAccountConcurrency(accountId, requestId)
logger.debug(
`🔓 Released concurrency slot for stream account ${account?.name || accountId}, request: ${requestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release concurrency slot for stream account ${accountId}, request: ${requestId}:`,
releaseError.message
)
}
}
}
}
@@ -547,17 +353,12 @@ class ClaudeConsoleRelayService {
'User-Agent': userAgent,
...filteredHeaders
},
httpsAgent: proxyAgent,
timeout: config.requestTimeout || 600000,
responseType: 'stream',
validateStatus: () => true // 接受所有状态码
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
// 根据 API Key 格式选择认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
// Anthropic 官方 API Key 使用 x-api-key
@@ -587,83 +388,44 @@ class ClaudeConsoleRelayService {
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
)
// 收集错误数据用于检测
let errorDataForCheck = ''
const errorChunks = []
response.data.on('data', (chunk) => {
errorChunks.push(chunk)
errorDataForCheck += chunk.toString()
})
response.data.on('end', async () => {
// 记录原始错误消息到日志(方便调试,包含供应商信息)
logger.error(
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
)
// 检查是否为账户禁用错误
const accountDisabledError = isAccountDisabledError(
response.status,
errorDataForCheck
)
if (response.status === 401) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (accountDisabledError) {
logger.error(
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
)
// 传入完整的错误详情到 webhook
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
claudeConsoleAccountService.markAccountRateLimited(accountId)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
} else if (response.status === 529) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
// 设置响应头
// 设置错误响应的状态码和响应头
if (!responseStream.headersSent) {
responseStream.writeHead(response.status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
const errorHeaders = {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
delete errorHeaders['Transfer-Encoding']
delete errorHeaders['Content-Length']
responseStream.writeHead(response.status, errorHeaders)
}
// 直接透传错误数据,不进行包装
response.data.on('data', (chunk) => {
if (!responseStream.destroyed) {
responseStream.write(chunk)
}
})
}
// 清理并发送错误响应
try {
const fullErrorData = Buffer.concat(errorChunks).toString()
const errorJson = JSON.parse(fullErrorData)
const sanitizedError = sanitizeUpstreamError(errorJson)
// 记录清理后的错误消息(发送给客户端的,完整记录)
logger.error(
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
)
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.write(JSON.stringify(sanitizedError))
responseStream.end()
}
} catch (parseError) {
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
if (!responseStream.destroyed) {
responseStream.write(sanitizedText)
responseStream.end()
}
}
resolve() // 不抛出异常,正常完成流处理
})
return
}
@@ -691,9 +453,7 @@ class ClaudeConsoleRelayService {
let buffer = ''
let finalUsageReported = false
const collectedUsageData = {
model: body.model || account?.defaultModel || null
}
const collectedUsageData = {}
// 处理流数据
response.data.on('data', (chunk) => {
@@ -725,12 +485,9 @@ class ClaudeConsoleRelayService {
// 解析SSE数据寻找usage信息
for (const line of lines) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
if (line.startsWith('data: ') && line.length > 6) {
try {
const jsonStr = line.slice(6)
const data = JSON.parse(jsonStr)
// 收集usage数据
@@ -760,61 +517,15 @@ class ClaudeConsoleRelayService {
}
}
if (data.type === 'message_delta' && data.usage) {
// 提取所有usage字段message_delta可能包含完整的usage信息
if (data.usage.output_tokens !== undefined) {
if (
data.type === 'message_delta' &&
data.usage &&
data.usage.output_tokens !== undefined
) {
collectedUsageData.output_tokens = data.usage.output_tokens || 0
}
// 提取input_tokens(如果存在)
if (data.usage.input_tokens !== undefined) {
collectedUsageData.input_tokens = data.usage.input_tokens || 0
}
// 提取cache相关的tokens
if (data.usage.cache_creation_input_tokens !== undefined) {
collectedUsageData.cache_creation_input_tokens =
data.usage.cache_creation_input_tokens || 0
}
if (data.usage.cache_read_input_tokens !== undefined) {
collectedUsageData.cache_read_input_tokens =
data.usage.cache_read_input_tokens || 0
}
// 检查是否有详细的 cache_creation 对象
if (
data.usage.cache_creation &&
typeof data.usage.cache_creation === 'object'
) {
collectedUsageData.cache_creation = {
ephemeral_5m_input_tokens:
data.usage.cache_creation.ephemeral_5m_input_tokens || 0,
ephemeral_1h_input_tokens:
data.usage.cache_creation.ephemeral_1h_input_tokens || 0
}
}
logger.info(
'📊 [Console] Collected usage data from message_delta:',
JSON.stringify(collectedUsageData)
)
// 如果已经收集到了完整数据,触发回调
if (
collectedUsageData.input_tokens !== undefined &&
collectedUsageData.output_tokens !== undefined &&
!finalUsageReported
) {
if (!collectedUsageData.model) {
collectedUsageData.model = body.model || account?.defaultModel || null
}
logger.info(
'🎯 [Console] Complete usage data collected:',
JSON.stringify(collectedUsageData)
)
if (usageCallback && typeof usageCallback === 'function') {
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
usageCallback({ ...collectedUsageData, accountId })
}
finalUsageReported = true
}
}
@@ -832,12 +543,6 @@ class ClaudeConsoleRelayService {
error
)
if (!responseStream.destroyed) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
)
} else {
responseStream.write('event: error\n')
responseStream.write(
`data: ${JSON.stringify({
@@ -848,7 +553,6 @@ class ClaudeConsoleRelayService {
)
}
}
}
})
response.data.on('end', () => {
@@ -865,43 +569,6 @@ class ClaudeConsoleRelayService {
}
}
// 🔧 兜底逻辑确保所有未保存的usage数据都不会丢失
if (!finalUsageReported) {
if (
collectedUsageData.input_tokens !== undefined ||
collectedUsageData.output_tokens !== undefined
) {
// 补全缺失的字段
if (collectedUsageData.input_tokens === undefined) {
collectedUsageData.input_tokens = 0
logger.warn(
'⚠️ [Console] message_delta missing input_tokens, setting to 0. This may indicate incomplete usage data.'
)
}
if (collectedUsageData.output_tokens === undefined) {
collectedUsageData.output_tokens = 0
logger.warn(
'⚠️ [Console] message_delta missing output_tokens, setting to 0. This may indicate incomplete usage data.'
)
}
// 确保有 model 字段
if (!collectedUsageData.model) {
collectedUsageData.model = body.model || account?.defaultModel || null
}
logger.info(
`📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}`
)
if (usageCallback && typeof usageCallback === 'function') {
usageCallback({ ...collectedUsageData, accountId })
}
finalUsageReported = true
} else {
logger.warn(
'⚠️ [Console] Stream completed but no usage data was captured! This indicates a problem with SSE parsing or API response format.'
)
}
}
// 确保流正确结束
if (!responseStream.destroyed) {
responseStream.end()
@@ -921,12 +588,6 @@ class ClaudeConsoleRelayService {
error
)
if (!responseStream.destroyed) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
)
} else {
responseStream.write('event: error\n')
responseStream.write(
`data: ${JSON.stringify({
@@ -935,7 +596,6 @@ class ClaudeConsoleRelayService {
timestamp: new Date().toISOString()
})}\n\n`
)
}
responseStream.end()
}
reject(error)
@@ -976,12 +636,6 @@ class ClaudeConsoleRelayService {
}
if (!responseStream.destroyed) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
)
} else {
responseStream.write('event: error\n')
responseStream.write(
`data: ${JSON.stringify({
@@ -990,7 +644,6 @@ class ClaudeConsoleRelayService {
timestamp: new Date().toISOString()
})}\n\n`
)
}
responseStream.end()
}
@@ -1037,15 +690,11 @@ class ClaudeConsoleRelayService {
async _updateLastUsedTime(accountId) {
try {
const client = require('../models/redis').getClientSafe()
const accountKey = `claude_console_account:${accountId}`
const exists = await client.exists(accountKey)
if (!exists) {
logger.debug(`🔎 跳过更新已删除的Claude Console账号最近使用时间: ${accountId}`)
return
}
await client.hset(accountKey, 'lastUsedAt', new Date().toISOString())
await client.hset(
`claude_console_account:${accountId}`,
'lastUsedAt',
new Date().toISOString()
)
} catch (error) {
logger.warn(
`⚠️ Failed to update last used time for Claude Console account ${accountId}:`,
@@ -1054,106 +703,6 @@ class ClaudeConsoleRelayService {
}
}
// 🧪 创建测试用的流转换器,将 Claude API SSE 格式转换为前端期望的格式
_createTestStreamTransformer() {
let testStartSent = false
return (rawData) => {
const lines = rawData.split('\n')
const outputLines = []
for (const line of lines) {
if (!line.startsWith('data: ')) {
// 保留空行用于 SSE 分隔
if (line.trim() === '') {
outputLines.push('')
}
continue
}
const jsonStr = line.substring(6).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
// 发送 test_start 事件(只在第一次 message_start 时发送)
if (data.type === 'message_start' && !testStartSent) {
testStartSent = true
outputLines.push(`data: ${JSON.stringify({ type: 'test_start' })}`)
outputLines.push('')
}
// 转换 content_block_delta 为 content
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
outputLines.push(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}`)
outputLines.push('')
}
// 转换 message_stop 为 test_complete
if (data.type === 'message_stop') {
outputLines.push(`data: ${JSON.stringify({ type: 'test_complete', success: true })}`)
outputLines.push('')
}
// 处理错误事件
if (data.type === 'error') {
const errorMsg = data.error?.message || data.message || '未知错误'
outputLines.push(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}`)
outputLines.push('')
}
} catch {
// 忽略解析错误
}
}
return outputLines.length > 0 ? outputLines.join('\n') : null
}
}
// 🧪 测试账号连接供Admin API使用
async testAccountConnection(accountId, responseStream) {
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try {
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
const cleanUrl = account.apiUrl.replace(/\/$/, '')
const apiUrl = cleanUrl.endsWith('/v1/messages')
? cleanUrl
: `${cleanUrl}/v1/messages?beta=true`
await sendStreamTestRequest({
apiUrl,
authorization: `Bearer ${account.apiKey}`,
responseStream,
proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
})
} catch (error) {
logger.error(`❌ Test account connection failed:`, error)
if (!responseStream.headersSent) {
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
})
}
if (!responseStream.destroyed && !responseStream.writableEnded) {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
)
responseStream.end()
}
}
}
// 🎯 健康检查
async healthCheck() {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -133,34 +133,10 @@ class CostInitService {
totalCost += cost
}
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
// 写入总费用
if (totalCost > 0) {
const totalKey = `usage:cost:total:${apiKeyId}`
// 先检查总费用是否已存在
const existingTotal = await client.get(totalKey)
if (!existingTotal || parseFloat(existingTotal) === 0) {
// 仅在总费用不存在或为0时才初始化
promises.push(client.set(totalKey, totalCost.toString()))
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
} else {
// 如果总费用已存在,保持不变,避免覆盖累计值
// 注意这个逻辑防止因每日费用键过期30天导致的错误覆盖
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
const existing = parseFloat(existingTotal)
const calculated = totalCost
if (calculated > existing * 1.1) {
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
logger.warn(
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
)
} else {
logger.debug(
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
)
}
}
}
await Promise.all(promises)

View File

@@ -1,591 +0,0 @@
/**
* 费用排序索引服务
*
* 为 API Keys 提供按费用排序的功能,使用 Redis Sorted Set 预计算排序索引
* 支持 today/7days/30days/all 四种固定时间范围的预计算索引
* 支持 custom 时间范围的实时计算
*
* 设计原则:
* - 只计算未删除的 API Key
* - 使用原子操作避免竞态条件
* - 提供增量更新接口供 API Key 创建/删除时调用
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
// ============================================================================
// 常量配置
// ============================================================================
/** 时间范围更新间隔配置(省资源模式) */
const UPDATE_INTERVALS = {
today: 10 * 60 * 1000, // 10分钟
'7days': 30 * 60 * 1000, // 30分钟
'30days': 60 * 60 * 1000, // 1小时
all: 2 * 60 * 60 * 1000 // 2小时
}
/** 支持的时间范围列表 */
const VALID_TIME_RANGES = ['today', '7days', '30days', 'all']
/** 分布式锁超时时间(秒) */
const LOCK_TTL = 300
/** 批处理大小 */
const BATCH_SIZE = 100
// ============================================================================
// Redis Key 生成器(集中管理 key 格式)
// ============================================================================
const RedisKeys = {
/** 费用排序索引 Sorted Set */
rankKey: (timeRange) => `cost_rank:${timeRange}`,
/** 临时索引 key用于原子替换 */
tempRankKey: (timeRange) => `cost_rank:${timeRange}:temp:${Date.now()}`,
/** 索引元数据 Hash */
metaKey: (timeRange) => `cost_rank_meta:${timeRange}`,
/** 更新锁 */
lockKey: (timeRange) => `cost_rank_lock:${timeRange}`,
/** 每日费用 */
dailyCost: (keyId, date) => `usage:cost:daily:${keyId}:${date}`,
/** 总费用 */
totalCost: (keyId) => `usage:cost:total:${keyId}`
}
// ============================================================================
// CostRankService 类
// ============================================================================
class CostRankService {
constructor() {
this.timers = {}
this.isInitialized = false
}
// --------------------------------------------------------------------------
// 生命周期管理
// --------------------------------------------------------------------------
/**
* 初始化服务:启动定时任务
* 幂等设计:多次调用只会初始化一次
*/
async initialize() {
// 先清理可能存在的旧定时器(支持热重载)
this._clearAllTimers()
if (this.isInitialized) {
logger.warn('CostRankService already initialized, re-initializing...')
}
logger.info('🔄 Initializing CostRankService...')
try {
// 启动时立即更新所有索引(异步,不阻塞启动)
this.updateAllRanks().catch((err) => {
logger.error('Failed to initialize cost ranks:', err)
})
// 设置定时更新
for (const [timeRange, interval] of Object.entries(UPDATE_INTERVALS)) {
this.timers[timeRange] = setInterval(() => {
this.updateRank(timeRange).catch((err) => {
logger.error(`Failed to update cost rank for ${timeRange}:`, err)
})
}, interval)
}
this.isInitialized = true
logger.success('✅ CostRankService initialized')
} catch (error) {
logger.error('❌ Failed to initialize CostRankService:', error)
throw error
}
}
/**
* 关闭服务:清理定时器
*/
shutdown() {
this._clearAllTimers()
this.isInitialized = false
logger.info('CostRankService shutdown')
}
/**
* 清理所有定时器
* @private
*/
_clearAllTimers() {
for (const timer of Object.values(this.timers)) {
clearInterval(timer)
}
this.timers = {}
}
// --------------------------------------------------------------------------
// 索引更新(全量)
// --------------------------------------------------------------------------
/**
* 更新所有时间范围的索引
*/
async updateAllRanks() {
for (const timeRange of VALID_TIME_RANGES) {
try {
await this.updateRank(timeRange)
} catch (error) {
logger.error(`Failed to update rank for ${timeRange}:`, error)
}
}
}
/**
* 更新指定时间范围的排序索引
* @param {string} timeRange - 时间范围
*/
async updateRank(timeRange) {
const client = redis.getClient()
if (!client) {
logger.warn('Redis client not available, skipping cost rank update')
return
}
const lockKey = RedisKeys.lockKey(timeRange)
const rankKey = RedisKeys.rankKey(timeRange)
const metaKey = RedisKeys.metaKey(timeRange)
// 获取分布式锁
const acquired = await client.set(lockKey, '1', 'NX', 'EX', LOCK_TTL)
if (!acquired) {
logger.debug(`Skipping ${timeRange} rank update - another update in progress`)
return
}
const startTime = Date.now()
try {
// 标记为更新中
await client.hset(metaKey, 'status', 'updating')
// 1. 获取所有未删除的 API Key IDs
const keyIds = await this._getActiveApiKeyIds()
if (keyIds.length === 0) {
// 无数据时清空索引
await client.del(rankKey)
await this._updateMeta(client, metaKey, startTime, 0)
return
}
// 2. 计算日期范围
const dateRange = this._getDateRange(timeRange)
// 3. 分批计算费用
const costs = await this._calculateCostsInBatches(keyIds, dateRange)
// 4. 原子更新索引(使用临时 key + RENAME 避免竞态条件)
await this._atomicUpdateIndex(client, rankKey, costs)
// 5. 更新元数据
await this._updateMeta(client, metaKey, startTime, keyIds.length)
logger.info(
`📊 Updated cost rank for ${timeRange}: ${keyIds.length} keys in ${Date.now() - startTime}ms`
)
} catch (error) {
await client.hset(metaKey, 'status', 'failed')
logger.error(`Failed to update cost rank for ${timeRange}:`, error)
throw error
} finally {
await client.del(lockKey)
}
}
/**
* 原子更新索引(避免竞态条件)
* @private
*/
async _atomicUpdateIndex(client, rankKey, costs) {
if (costs.size === 0) {
await client.del(rankKey)
return
}
// 使用临时 key 构建新索引
const tempKey = `${rankKey}:temp:${Date.now()}`
try {
// 构建 ZADD 参数
const members = []
costs.forEach((cost, keyId) => {
members.push(cost, keyId)
})
// 写入临时 key
await client.zadd(tempKey, ...members)
// 原子替换RENAME 是原子操作)
await client.rename(tempKey, rankKey)
} catch (error) {
// 清理临时 key
await client.del(tempKey).catch(() => {})
throw error
}
}
/**
* 更新元数据
* @private
*/
async _updateMeta(client, metaKey, startTime, keyCount) {
await client.hmset(metaKey, {
lastUpdate: new Date().toISOString(),
keyCount: keyCount.toString(),
status: 'ready',
updateDuration: (Date.now() - startTime).toString()
})
}
// --------------------------------------------------------------------------
// 索引增量更新(供外部调用)
// --------------------------------------------------------------------------
/**
* 添加 API Key 到所有索引(创建 API Key 时调用)
* @param {string} keyId - API Key ID
*/
async addKeyToIndexes(keyId) {
const client = redis.getClient()
if (!client) {
return
}
try {
const pipeline = client.pipeline()
// 将新 Key 添加到所有索引,初始分数为 0
for (const timeRange of VALID_TIME_RANGES) {
pipeline.zadd(RedisKeys.rankKey(timeRange), 0, keyId)
}
await pipeline.exec()
logger.debug(`Added key ${keyId} to cost rank indexes`)
} catch (error) {
logger.error(`Failed to add key ${keyId} to cost rank indexes:`, error)
}
}
/**
* 从所有索引中移除 API Key删除 API Key 时调用)
* @param {string} keyId - API Key ID
*/
async removeKeyFromIndexes(keyId) {
const client = redis.getClient()
if (!client) {
return
}
try {
const pipeline = client.pipeline()
// 从所有索引中移除
for (const timeRange of VALID_TIME_RANGES) {
pipeline.zrem(RedisKeys.rankKey(timeRange), keyId)
}
await pipeline.exec()
logger.debug(`Removed key ${keyId} from cost rank indexes`)
} catch (error) {
logger.error(`Failed to remove key ${keyId} from cost rank indexes:`, error)
}
}
// --------------------------------------------------------------------------
// 查询接口
// --------------------------------------------------------------------------
/**
* 获取排序后的 keyId 列表
* @param {string} timeRange - 时间范围
* @param {string} sortOrder - 排序方向 'asc' | 'desc'
* @param {number} offset - 偏移量
* @param {number} limit - 限制数量,-1 表示全部
* @returns {Promise<string[]>} keyId 列表
*/
async getSortedKeyIds(timeRange, sortOrder = 'desc', offset = 0, limit = -1) {
const client = redis.getClient()
if (!client) {
throw new Error('Redis client not available')
}
const rankKey = RedisKeys.rankKey(timeRange)
const end = limit === -1 ? -1 : offset + limit - 1
if (sortOrder === 'desc') {
return await client.zrevrange(rankKey, offset, end)
} else {
return await client.zrange(rankKey, offset, end)
}
}
/**
* 获取 Key 的费用分数
* @param {string} timeRange - 时间范围
* @param {string} keyId - API Key ID
* @returns {Promise<number>} 费用
*/
async getKeyCost(timeRange, keyId) {
const client = redis.getClient()
if (!client) {
return 0
}
const score = await client.zscore(RedisKeys.rankKey(timeRange), keyId)
return score ? parseFloat(score) : 0
}
/**
* 批量获取多个 Key 的费用分数
* @param {string} timeRange - 时间范围
* @param {string[]} keyIds - API Key ID 列表
* @returns {Promise<Map<string, number>>} keyId -> cost
*/
async getBatchKeyCosts(timeRange, keyIds) {
const client = redis.getClient()
if (!client || keyIds.length === 0) {
return new Map()
}
const rankKey = RedisKeys.rankKey(timeRange)
const costs = new Map()
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
pipeline.zscore(rankKey, keyId)
})
const results = await pipeline.exec()
keyIds.forEach((keyId, index) => {
const [err, score] = results[index]
costs.set(keyId, err || !score ? 0 : parseFloat(score))
})
return costs
}
/**
* 获取所有排序索引的状态
* @returns {Promise<Object>} 各时间范围的状态
*/
async getRankStatus() {
const client = redis.getClient()
if (!client) {
return {}
}
const status = {}
for (const timeRange of VALID_TIME_RANGES) {
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
status[timeRange] = {
lastUpdate: meta.lastUpdate || null,
keyCount: parseInt(meta.keyCount || 0),
status: meta.status || 'unknown',
updateDuration: parseInt(meta.updateDuration || 0)
}
}
return status
}
/**
* 强制刷新指定时间范围的索引
* @param {string} timeRange - 时间范围,不传则刷新全部
*/
async forceRefresh(timeRange = null) {
if (timeRange) {
await this.updateRank(timeRange)
} else {
await this.updateAllRanks()
}
}
// --------------------------------------------------------------------------
// Custom 时间范围实时计算
// --------------------------------------------------------------------------
/**
* 计算 custom 时间范围的费用(实时计算,排除已删除的 Key
* @param {string} startDate - 开始日期 YYYY-MM-DD
* @param {string} endDate - 结束日期 YYYY-MM-DD
* @returns {Promise<Map<string, number>>} keyId -> cost
*/
async calculateCustomRangeCosts(startDate, endDate) {
const client = redis.getClient()
if (!client) {
throw new Error('Redis client not available')
}
logger.info(`📊 Calculating custom range costs: ${startDate} to ${endDate}`)
const startTime = Date.now()
// 1. 获取所有未删除的 API Key IDs
const keyIds = await this._getActiveApiKeyIds()
if (keyIds.length === 0) {
return new Map()
}
// 2. 分批计算费用
const costs = await this._calculateCostsInBatches(keyIds, { startDate, endDate })
const duration = Date.now() - startTime
logger.info(`📊 Custom range costs calculated: ${keyIds.length} keys in ${duration}ms`)
return costs
}
// --------------------------------------------------------------------------
// 私有辅助方法
// --------------------------------------------------------------------------
/**
* 获取所有未删除的 API Key IDs
* @private
* @returns {Promise<string[]>}
*/
async _getActiveApiKeyIds() {
// 使用现有的 scanApiKeyIds 获取所有 ID
const allKeyIds = await redis.scanApiKeyIds()
if (allKeyIds.length === 0) {
return []
}
// 批量获取 API Key 数据,过滤已删除的
const allKeys = await redis.batchGetApiKeys(allKeyIds)
return allKeys.filter((k) => !k.isDeleted).map((k) => k.id)
}
/**
* 分批计算费用
* @private
*/
async _calculateCostsInBatches(keyIds, dateRange) {
const costs = new Map()
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
const batch = keyIds.slice(i, i + BATCH_SIZE)
const batchCosts = await this._calculateBatchCosts(batch, dateRange)
batchCosts.forEach((cost, keyId) => costs.set(keyId, cost))
}
return costs
}
/**
* 批量计算费用
* @private
*/
async _calculateBatchCosts(keyIds, dateRange) {
const client = redis.getClient()
const costs = new Map()
if (dateRange.useTotal) {
// 'all' 时间范围:直接读取 total cost
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
pipeline.get(RedisKeys.totalCost(keyId))
})
const results = await pipeline.exec()
keyIds.forEach((keyId, index) => {
const [err, value] = results[index]
costs.set(keyId, err ? 0 : parseFloat(value || 0))
})
} else {
// 特定日期范围:汇总每日费用
const dates = this._getDatesBetween(dateRange.startDate, dateRange.endDate)
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
dates.forEach((date) => {
pipeline.get(RedisKeys.dailyCost(keyId, date))
})
})
const results = await pipeline.exec()
let resultIndex = 0
keyIds.forEach((keyId) => {
let totalCost = 0
dates.forEach(() => {
const [err, value] = results[resultIndex++]
if (!err && value) {
totalCost += parseFloat(value)
}
})
costs.set(keyId, totalCost)
})
}
return costs
}
/**
* 获取日期范围配置
* @private
*/
_getDateRange(timeRange) {
const now = new Date()
const today = redis.getDateStringInTimezone(now)
switch (timeRange) {
case 'today':
return { startDate: today, endDate: today }
case '7days': {
const d7 = new Date(now)
d7.setDate(d7.getDate() - 6)
return { startDate: redis.getDateStringInTimezone(d7), endDate: today }
}
case '30days': {
const d30 = new Date(now)
d30.setDate(d30.getDate() - 29)
return { startDate: redis.getDateStringInTimezone(d30), endDate: today }
}
case 'all':
return { useTotal: true }
default:
throw new Error(`Invalid time range: ${timeRange}`)
}
}
/**
* 获取两个日期之间的所有日期
* @private
*/
_getDatesBetween(startDate, endDate) {
const dates = []
const current = new Date(startDate)
const end = new Date(endDate)
while (current <= end) {
dates.push(
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')}`
)
current.setDate(current.getDate() + 1)
}
return dates
}
}
module.exports = new CostRankService()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +0,0 @@
const droidAccountService = require('./droidAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class DroidScheduler {
constructor() {
this.STICKY_PREFIX = 'droid'
}
_normalizeEndpointType(endpointType) {
if (!endpointType) {
return 'anthropic'
}
const normalized = String(endpointType).toLowerCase()
if (normalized === 'openai') {
return 'openai'
}
if (normalized === 'comm') {
return 'comm'
}
if (normalized === 'anthropic') {
return 'anthropic'
}
return 'anthropic'
}
_isTruthy(value) {
if (value === undefined || value === null) {
return false
}
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
return value.toLowerCase() === 'true'
}
return Boolean(value)
}
_isAccountActive(account) {
if (!account) {
return false
}
const isActive = this._isTruthy(account.isActive)
if (!isActive) {
return false
}
const status = (account.status || 'active').toLowerCase()
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
return !unhealthyStatuses.has(status)
}
_isAccountSchedulable(account) {
return this._isTruthy(account?.schedulable ?? true)
}
_matchesEndpoint(account, endpointType) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
if (normalizedEndpoint === accountEndpoint) {
return true
}
// comm 端点可以使用任何类型的账户
if (normalizedEndpoint === 'comm') {
return true
}
const sharedEndpoints = new Set(['anthropic', 'openai'])
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
}
_sortCandidates(candidates) {
return [...candidates].sort((a, b) => {
const priorityA = parseInt(a.priority, 10) || 50
const priorityB = parseInt(b.priority, 10) || 50
if (priorityA !== priorityB) {
return priorityA - priorityB
}
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
if (lastUsedA !== lastUsedB) {
return lastUsedA - lastUsedB
}
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return createdA - createdB
})
}
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
if (!sessionHash) {
return null
}
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const apiKeyPart = apiKeyId || 'default'
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
}
async _loadGroupAccounts(groupId) {
const memberIds = await accountGroupService.getGroupMembers(groupId)
if (!memberIds || memberIds.length === 0) {
return []
}
const accounts = await Promise.all(
memberIds.map(async (memberId) => {
try {
return await droidAccountService.getAccount(memberId)
} catch (error) {
logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error)
return null
}
})
)
return accounts.filter(
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
)
}
async _ensureLastUsedUpdated(accountId) {
try {
await droidAccountService.touchLastUsedAt(accountId)
} catch (error) {
logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error)
}
}
async _cleanupStickyMapping(stickyKey) {
if (!stickyKey) {
return
}
try {
await redis.deleteSessionAccountMapping(stickyKey)
} catch (error) {
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error)
}
}
async selectAccount(apiKeyData, endpointType, sessionHash) {
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
let candidates = []
let isDedicatedBinding = false
if (apiKeyData?.droidAccountId) {
const binding = apiKeyData.droidAccountId
if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length)
logger.info(
`🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度`
)
candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint)
} else {
const account = await droidAccountService.getAccount(binding)
if (account) {
candidates = [account]
isDedicatedBinding = true
}
}
}
if (!candidates || candidates.length === 0) {
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
}
const filtered = candidates.filter(
(account) =>
account &&
this._isAccountActive(account) &&
this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint)
)
if (filtered.length === 0) {
throw new Error(
`No available accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
)
}
if (stickyKey && !isDedicatedBinding) {
const mappedAccountId = await redis.getSessionAccountMapping(stickyKey)
if (mappedAccountId) {
const mappedAccount = filtered.find((account) => account.id === mappedAccountId)
if (mappedAccount) {
await redis.extendSessionAccountMappingTTL(stickyKey)
logger.info(
`🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}`
)
await this._ensureLastUsedUpdated(mappedAccount.id)
return mappedAccount
}
await this._cleanupStickyMapping(stickyKey)
}
}
const sorted = this._sortCandidates(filtered)
const selected = sorted[0]
if (!selected) {
throw new Error(`No schedulable account available after sorting (${normalizedEndpoint})`)
}
if (stickyKey && !isDedicatedBinding) {
await redis.setSessionAccountMapping(stickyKey, selected.id)
}
await this._ensureLastUsedUpdated(selected.id)
logger.info(
`🤖 选择 Droid 账号 ${selected.name || selected.id}endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50}`
)
return selected
}
}
module.exports = new DroidScheduler()

View File

@@ -1,7 +1,6 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const https = require('https')
const config = require('../../config/config')
const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library')
@@ -22,18 +21,6 @@ const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.goog
const OAUTH_CLIENT_SECRET = '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'
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
// 🌐 TCP Keep-Alive Agent 配置
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
const keepAliveAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30000, // 每30秒发送一次 keep-alive 探测
timeout: 120000, // 120秒连接超时
maxSockets: 100, // 最大并发连接数
maxFreeSockets: 10 // 保持的空闲连接数
})
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'gemini-account-salt'
@@ -397,22 +384,16 @@ async function createAccount(accountData) {
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
accessToken: accessToken ? encrypt(accessToken) : '',
refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
expiresAt,
// 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
// 代理设置
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
// 项目 IDGoogle Cloud/Workspace 账号需要)
projectId: accountData.projectId || '',
// 临时项目 ID从 loadCodeAssist 接口自动获取)
tempProjectId: accountData.tempProjectId || '',
// 支持的模型列表(可选)
supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型
@@ -537,23 +518,15 @@ async function updateAccount(accountId, updates) {
}
}
// ✅ 关键:如果新增了 refresh token更新 token 过期时间
// 不要覆盖 subscriptionExpiresAt
// 如果新增了 refresh token更新过期时间为10分钟
if (needUpdateExpiry) {
const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间
// ⚠️ 重要:不要修改 subscriptionExpiresAt
updates.expiresAt = newExpiry
logger.info(
`🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes`
`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`
)
}
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt直接保存
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
if (updates.geminiOauth && !oldRefreshToken) {
const oauthData =
@@ -667,25 +640,12 @@ async function getAllAccounts() {
// 转换 schedulable 字符串为布尔值(与 getAccount 保持一致)
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true只有明确设置为'false'才为false
const tokenExpiresAt = accountData.expiresAt || null
const subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
// 不解密敏感字段,只返回基本信息
accounts.push({
...accountData,
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
// ✅ 前端显示订阅过期时间(业务字段)
// 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt
tokenExpiresAt,
subscriptionExpiresAt,
expiresAt: subscriptionExpiresAt,
// 添加 scopes 字段用于判断认证方式
// 处理空字符串和默认值的情况
scopes:
@@ -764,17 +724,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account)
) {
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account)
} else if (account && isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
}
}
@@ -829,19 +780,6 @@ function isTokenExpired(account) {
return now >= expiryTime - buffer
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 检查账户是否被限流
function isRateLimited(account) {
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
@@ -1061,7 +999,6 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
// 验证凭据本地有效性
const { token } = await client.getAccessToken()
if (!token) {
return false
}
@@ -1073,45 +1010,6 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
return client
}
// 通用的 Code Assist API 转发函数(用于简单的请求/响应端点)
// 适用于loadCodeAssist, onboardUser, countTokens, listExperiments 等不需要特殊处理的端点
async function forwardToCodeAssist(client, apiMethod, requestBody, proxyConfig = null) {
const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
const CODE_ASSIST_API_VERSION = 'v1internal'
const { token } = await client.getAccessToken()
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
logger.info(`📡 ${apiMethod} API调用开始`)
const axiosConfig = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${apiMethod}`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: requestBody,
timeout: 30000
}
// 添加代理配置
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(`🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}`)
} else {
logger.debug(`🌐 No proxy configured for ${apiMethod}`)
}
const response = await axios(axiosConfig)
logger.info(`${apiMethod} API调用成功`)
return response.data
}
// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理)
async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
const axios = require('axios')
@@ -1119,57 +1017,6 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
const CODE_ASSIST_API_VERSION = 'v1internal'
const { token } = await client.getAccessToken()
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
// 🔍 只有个人账户(无 projectId才需要调用 tokeninfo/userinfo
// 这些调用有助于 Google 获取临时 projectId
if (!projectId) {
const tokenInfoConfig = {
url: 'https://oauth2.googleapis.com/tokeninfo',
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
data: new URLSearchParams({ access_token: token }).toString(),
timeout: 15000
}
if (proxyAgent) {
tokenInfoConfig.httpAgent = proxyAgent
tokenInfoConfig.httpsAgent = proxyAgent
tokenInfoConfig.proxy = false
}
try {
await axios(tokenInfoConfig)
logger.info('📋 tokeninfo 接口验证成功')
} catch (error) {
logger.warn('⚠️ tokeninfo 接口调用失败:', error.message)
}
const userInfoConfig = {
url: 'https://www.googleapis.com/oauth2/v2/userinfo',
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: '*/*'
},
timeout: 15000
}
if (proxyAgent) {
userInfoConfig.httpAgent = proxyAgent
userInfoConfig.httpsAgent = proxyAgent
userInfoConfig.proxy = false
}
try {
await axios(userInfoConfig)
logger.info('📋 userinfo 接口获取成功')
} catch (error) {
logger.warn('⚠️ userinfo 接口调用失败:', error.message)
}
}
// 创建ClientMetadata
const clientMetadata = {
@@ -1204,10 +1051,9 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) {
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
@@ -1281,9 +1127,7 @@ async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfi
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
baseAxiosConfig.httpAgent = proxyAgent
baseAxiosConfig.httpsAgent = proxyAgent
baseAxiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
@@ -1414,9 +1258,7 @@ async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', pro
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
@@ -1485,22 +1327,18 @@ async function generateContent(
'Content-Type': 'application/json'
},
data: request,
timeout: 600000 // 生成内容可能需要更长时间
timeout: 60000 // 生成内容可能需要更长时间
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
// 没有代理时,使用 keepAlive agent 防止长时间请求被中断
axiosConfig.httpsAgent = keepAliveAgent
logger.debug('🌐 Using keepAlive agent for Gemini generateContent')
logger.debug('🌐 No proxy configured for Gemini generateContent')
}
const response = await axios(axiosConfig)
@@ -1563,23 +1401,18 @@ async function generateContentStream(
},
data: request,
responseType: 'stream',
timeout: 0 // 流式请求不设置超时限制,由 keepAlive 和 AbortSignal 控制
timeout: 60000
}
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com)
// 同时设置 httpAgent 和 httpsAgent 可能导致 axios/follow-redirects 选择错误的协议
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
// 没有代理时,使用 keepAlive agent 防止长时间流式请求被中断
axiosConfig.httpsAgent = keepAliveAgent
logger.debug('🌐 Using keepAlive agent for Gemini streamGenerateContent')
logger.debug('🌐 No proxy configured for Gemini streamGenerateContent')
}
// 如果提供了中止信号,添加到配置中
@@ -1593,73 +1426,6 @@ async function generateContentStream(
return response.data // 返回流对象
}
// 更新账户的临时项目 ID
async function updateTempProjectId(accountId, tempProjectId) {
if (!tempProjectId) {
return
}
try {
const account = await getAccount(accountId)
if (!account) {
logger.warn(`Account ${accountId} not found when updating tempProjectId`)
return
}
// 只有在没有固定项目 ID 的情况下才更新临时项目 ID
if (!account.projectId && tempProjectId !== account.tempProjectId) {
await updateAccount(accountId, { tempProjectId })
logger.info(`Updated tempProjectId for account ${accountId}: ${tempProjectId}`)
}
} catch (error) {
logger.error(`Failed to update tempProjectId for account ${accountId}:`, error)
}
}
// 重置账户状态(清除所有异常状态)
async function resetAccountStatus(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 refreshToken 来设置 status
status: account.refreshToken ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: '',
rateLimitedAt: '',
rateLimitStatus: ''
}
await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for Gemini account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'gemini',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(`📢 Webhook notification sent for Gemini account ${account.name} status reset`)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return {
success: true,
message: 'Account status reset successfully'
}
}
module.exports = {
generateAuthUrl,
pollAuthorizationStatus,
@@ -1677,7 +1443,6 @@ module.exports = {
getAccountRateLimitInfo,
isTokenExpired,
getOauthClient,
forwardToCodeAssist, // 通用转发函数
loadCodeAssist,
getOnboardTier,
onboardUser,
@@ -1689,8 +1454,6 @@ module.exports = {
countTokens,
generateContent,
generateContentStream,
updateTempProjectId,
resetAccountStatus,
OAUTH_CLIENT_ID,
OAUTH_SCOPES
}

View File

@@ -1,586 +0,0 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
class GeminiApiAccountService {
constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'gemini-api-salt'
// Redis 键前缀
this.ACCOUNT_KEY_PREFIX = 'gemini_api_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_gemini_api_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 Gemini-API decrypt cache cleanup completed', this._decryptCache.getStats())
},
10 * 60 * 1000
)
}
// 创建账户
async createAccount(options = {}) {
const {
name = 'Gemini API Account',
description = '',
apiKey = '', // 必填Google AI Studio API Key
baseUrl = 'https://generativelanguage.googleapis.com', // 默认 Gemini API 基础 URL
proxy = null,
priority = 50, // 调度优先级 (1-100)
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度
supportedModels = [], // 支持的模型列表
rateLimitDuration = 60 // 限流时间(分钟)
} = options
// 验证必填字段
if (!apiKey) {
throw new Error('API Key is required for Gemini-API account')
}
// 规范化 baseUrl确保不以 / 结尾)
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
const accountId = uuidv4()
const accountData = {
id: accountId,
platform: 'gemini-api',
name,
description,
baseUrl: normalizedBaseUrl,
apiKey: this._encryptSensitiveData(apiKey),
priority: priority.toString(),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType,
schedulable: schedulable.toString(),
supportedModels: JSON.stringify(supportedModels),
createdAt: new Date().toISOString(),
lastUsedAt: '',
status: 'active',
errorMessage: '',
// 限流相关
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitDuration: rateLimitDuration.toString()
}
// 保存到 Redis
await this._saveAccount(accountId, accountData)
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
return {
...accountData,
apiKey: '***' // 返回时隐藏敏感信息
}
}
// 获取账户
async getAccount(accountId) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const accountData = await client.hgetall(key)
if (!accountData || !accountData.id) {
return null
}
// 解密敏感数据
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey)
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
if (accountData.supportedModels) {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = []
}
}
return accountData
}
// 更新账户
async updateAccount(accountId, updates) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 处理敏感字段加密
if (updates.apiKey) {
updates.apiKey = this._encryptSensitiveData(updates.apiKey)
}
// 处理 JSON 字段
if (updates.proxy !== undefined) {
updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
}
if (updates.supportedModels !== undefined) {
updates.supportedModels = JSON.stringify(updates.supportedModels)
}
// 规范化 baseUrl
if (updates.baseUrl) {
updates.baseUrl = updates.baseUrl.endsWith('/')
? updates.baseUrl.slice(0, -1)
: updates.baseUrl
}
// 更新 Redis
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hset(key, updates)
logger.info(`📝 Updated Gemini-API account: ${account.name}`)
return { success: true }
}
// 删除账户
async deleteAccount(accountId) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 从共享账户列表中移除
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
// 删除账户数据
await client.del(key)
logger.info(`🗑️ Deleted Gemini-API account: ${accountId}`)
return { success: true }
}
// 获取所有账户
async getAllAccounts(includeInactive = false) {
const client = redis.getClientSafe()
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
const accounts = []
for (const accountId of accountIds) {
const account = await this.getAccount(accountId)
if (account) {
// 过滤非活跃账户
if (includeInactive || account.isActive === 'true') {
// 隐藏敏感信息
account.apiKey = '***'
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(account)
// 格式化 rateLimitStatus 为对象
account.rateLimitStatus = rateLimitInfo.isRateLimited
? {
isRateLimited: true,
rateLimitedAt: account.rateLimitedAt || null,
minutesRemaining: rateLimitInfo.remainingMinutes || 0
}
: {
isRateLimited: false,
rateLimitedAt: null,
minutesRemaining: 0
}
// 转换 schedulable 字段为布尔值
account.schedulable = account.schedulable !== 'false'
// 转换 isActive 字段为布尔值
account.isActive = account.isActive === 'true'
account.platform = account.platform || 'gemini-api'
accounts.push(account)
}
}
}
// 直接从 Redis 获取所有账户(包括非共享账户)
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
for (const key of keys) {
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
if (!accountIds.includes(accountId)) {
const accountData = await client.hgetall(key)
if (accountData && accountData.id) {
// 过滤非活跃账户
if (includeInactive || accountData.isActive === 'true') {
// 隐藏敏感信息
accountData.apiKey = '***'
// 解析 JSON 字段
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
if (accountData.supportedModels) {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = []
}
}
// 获取限流状态信息
const rateLimitInfo = this._getRateLimitInfo(accountData)
// 格式化 rateLimitStatus 为对象
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.platform = accountData.platform || 'gemini-api'
accounts.push(accountData)
}
}
}
}
return accounts
}
// 标记账户已使用
async markAccountUsed(accountId) {
await this.updateAccount(accountId, {
lastUsedAt: new Date().toISOString()
})
}
// 标记账户限流
async setAccountRateLimited(accountId, isLimited, duration = null) {
const account = await this.getAccount(accountId)
if (!account) {
return
}
if (isLimited) {
const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60
const now = new Date()
const resetAt = new Date(now.getTime() + rateLimitDuration * 60000)
await this.updateAccount(accountId, {
rateLimitedAt: now.toISOString(),
rateLimitStatus: 'limited',
rateLimitResetAt: resetAt.toISOString(),
rateLimitDuration: rateLimitDuration.toString(),
status: 'rateLimited',
schedulable: 'false', // 防止被调度
errorMessage: `Rate limited until ${resetAt.toISOString()}`
})
logger.warn(
`⏳ Gemini-API account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})`
)
} else {
// 清除限流状态
await this.updateAccount(accountId, {
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitResetAt: '',
status: 'active',
schedulable: 'true',
errorMessage: ''
})
logger.info(`✅ Rate limit cleared for Gemini-API account ${account.name}`)
}
}
// 🚫 标记账户为未授权状态401错误
async markAccountUnauthorized(accountId, reason = 'Gemini API账号认证失败401错误') {
const account = await this.getAccount(accountId)
if (!account) {
return
}
const now = new Date().toISOString()
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
await this.updateAccount(accountId, {
status: 'unauthorized',
schedulable: 'false',
errorMessage: reason,
unauthorizedAt: now,
unauthorizedCount: unauthorizedCount.toString()
})
logger.warn(
`🚫 Gemini-API account ${account.name || accountId} marked as unauthorized due to 401 error`
)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'gemini-api',
status: 'unauthorized',
errorCode: 'GEMINI_API_UNAUTHORIZED',
reason,
timestamp: now
})
logger.info(
`📢 Webhook notification sent for Gemini-API account ${account.name || accountId} unauthorized state`
)
} catch (webhookError) {
logger.error('Failed to send unauthorized webhook notification:', webhookError)
}
}
// 检查并清除过期的限流状态
async checkAndClearRateLimit(accountId) {
const account = await this.getAccount(accountId)
if (!account || account.rateLimitStatus !== 'limited') {
return false
}
const now = new Date()
let shouldClear = false
// 优先使用 rateLimitResetAt 字段
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt)
shouldClear = now >= resetAt
} else {
// 如果没有 rateLimitResetAt使用旧的逻辑
const rateLimitedAt = new Date(account.rateLimitedAt)
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60
shouldClear = now - rateLimitedAt > rateLimitDuration * 60000
}
if (shouldClear) {
// 限流已过期,清除状态
await this.setAccountRateLimited(accountId, false)
return true
}
return false
}
// 切换调度状态
async toggleSchedulable(accountId) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true'
await this.updateAccount(accountId, {
schedulable: newSchedulableStatus
})
logger.info(
`🔄 Toggled schedulable status for Gemini-API account ${account.name}: ${newSchedulableStatus}`
)
return {
success: true,
schedulable: newSchedulableStatus === 'true'
}
}
// 重置账户状态(清除所有异常状态)
async resetAccountStatus(accountId) {
const account = await this.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const updates = {
// 根据是否有有效的 apiKey 来设置 status
status: account.apiKey ? 'active' : 'created',
// 恢复可调度状态
schedulable: 'true',
// 清除错误相关字段
errorMessage: '',
rateLimitedAt: '',
rateLimitStatus: '',
rateLimitResetAt: '',
rateLimitDuration: ''
}
await this.updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`)
// 发送 Webhook 通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'gemini-api',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
logger.info(
`📢 Webhook notification sent for Gemini-API account ${account.name} status reset`
)
} catch (webhookError) {
logger.error('Failed to send status reset webhook notification:', webhookError)
}
return { success: true, message: 'Account status reset successfully' }
}
// API Key 不会过期
isTokenExpired(_account) {
return false
}
// 获取限流信息
_getRateLimitInfo(accountData) {
if (accountData.rateLimitStatus !== 'limited') {
return { isRateLimited: false }
}
const now = new Date()
let willBeAvailableAt
let remainingMinutes
// 优先使用 rateLimitResetAt 字段
if (accountData.rateLimitResetAt) {
willBeAvailableAt = new Date(accountData.rateLimitResetAt)
remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000))
} else {
// 如果没有 rateLimitResetAt使用旧的逻辑
const rateLimitedAt = new Date(accountData.rateLimitedAt)
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000)
remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes)
willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000)
}
return {
isRateLimited: remainingMinutes > 0,
remainingMinutes,
willBeAvailableAt
}
}
// 加密敏感数据
_encryptSensitiveData(text) {
if (!text) {
return ''
}
const key = this._getEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密敏感数据
_decryptSensitiveData(text) {
if (!text || text === '') {
return ''
}
// 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = this._getEncryptionKey()
const [ivHex, encryptedHex] = text.split(':')
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
const result = decrypted.toString()
// 存入缓存5分钟过期
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 获取加密密钥
_getEncryptionKey() {
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
}
return this._encryptionKeyCache
}
// 保存账户到 Redis
async _saveAccount(accountId, accountData) {
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 保存账户数据
await client.hset(key, accountData)
// 添加到共享账户列表
if (accountData.accountType === 'shared') {
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
}
}
}
module.exports = new GeminiApiAccountService()

View File

@@ -279,9 +279,7 @@ async function sendGeminiRequest({
// 添加代理配置
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
} else {
logger.debug('🌐 No proxy configured for Gemini API request')
@@ -389,9 +387,7 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
)
@@ -492,9 +488,7 @@ async function countTokens({
// 添加代理配置
const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) {
// 只设置 httpsAgent因为目标 URL 是 HTTPS (cloudcode.googleapis.com)
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
logger.info(
`🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
)

View File

@@ -1,145 +0,0 @@
const logger = require('../utils/logger')
/**
* 模型服务
* 管理系统支持的 AI 模型列表
* 与 pricingService 独立,专注于"支持哪些模型"而不是"如何计费"
*/
class ModelService {
constructor() {
this.supportedModels = this.getDefaultModels()
}
/**
* 初始化模型服务
*/
async initialize() {
const totalModels = Object.values(this.supportedModels).reduce(
(sum, config) => sum + config.models.length,
0
)
logger.success(`✅ Model service initialized with ${totalModels} models`)
}
/**
* 获取支持的模型配置
*/
getDefaultModels() {
return {
claude: {
provider: 'anthropic',
description: 'Claude models from Anthropic',
models: [
'claude-opus-4-5-20251101',
'claude-haiku-4-5-20251001',
'claude-sonnet-4-5-20250929',
'claude-opus-4-1-20250805',
'claude-sonnet-4-20250514',
'claude-opus-4-20250514',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-haiku-20240307'
]
},
openai: {
provider: 'openai',
description: 'OpenAI GPT models',
models: [
'gpt-5.1-2025-11-13',
'gpt-5.1-codex-mini',
'gpt-5.1-codex',
'gpt-5.1-codex-max',
'gpt-5-2025-08-07',
'gpt-5-codex'
]
},
gemini: {
provider: 'google',
description: 'Google Gemini models',
models: ['gemini-2.5-pro', 'gemini-3-pro-preview', 'gemini-2.5-flash']
}
}
}
/**
* 获取所有支持的模型OpenAI API 格式)
*/
getAllModels() {
const models = []
const now = Math.floor(Date.now() / 1000)
for (const [_service, config] of Object.entries(this.supportedModels)) {
for (const modelId of config.models) {
models.push({
id: modelId,
object: 'model',
created: now,
owned_by: config.provider
})
}
}
return models.sort((a, b) => {
// 先按 provider 排序,再按 model id 排序
if (a.owned_by !== b.owned_by) {
return a.owned_by.localeCompare(b.owned_by)
}
return a.id.localeCompare(b.id)
})
}
/**
* 按 provider 获取模型
* @param {string} provider - 'anthropic', 'openai', 'google' 等
*/
getModelsByProvider(provider) {
return this.getAllModels().filter((m) => m.owned_by === provider)
}
/**
* 检查模型是否被支持
* @param {string} modelId - 模型 ID
*/
isModelSupported(modelId) {
if (!modelId) {
return false
}
return this.getAllModels().some((m) => m.id === modelId)
}
/**
* 获取模型的 provider
* @param {string} modelId - 模型 ID
*/
getModelProvider(modelId) {
const model = this.getAllModels().find((m) => m.id === modelId)
return model ? model.owned_by : null
}
/**
* 获取服务状态
*/
getStatus() {
const totalModels = Object.values(this.supportedModels).reduce(
(sum, config) => sum + config.models.length,
0
)
return {
initialized: true,
totalModels,
providers: Object.keys(this.supportedModels)
}
}
/**
* 清理资源(保留接口兼容性)
*/
cleanup() {
logger.debug('📋 Model service cleanup (no-op)')
}
}
module.exports = new ModelService()

View File

@@ -115,85 +115,6 @@ setInterval(
10 * 60 * 1000
)
function toNumberOrNull(value) {
if (value === undefined || value === null || value === '') {
return null
}
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function computeResetMeta(updatedAt, resetAfterSeconds) {
if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) {
return {
resetAt: null,
remainingSeconds: null
}
}
const updatedMs = Date.parse(updatedAt)
if (Number.isNaN(updatedMs)) {
return {
resetAt: null,
remainingSeconds: null
}
}
const resetMs = updatedMs + resetAfterSeconds * 1000
return {
resetAt: new Date(resetMs).toISOString(),
remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000))
}
}
function buildCodexUsageSnapshot(accountData) {
const updatedAt = accountData.codexUsageUpdatedAt
const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent)
const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds)
const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes)
const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent)
const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds)
const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes)
const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent)
const hasPrimaryData =
primaryUsedPercent !== null ||
primaryResetAfterSeconds !== null ||
primaryWindowMinutes !== null
const hasSecondaryData =
secondaryUsedPercent !== null ||
secondaryResetAfterSeconds !== null ||
secondaryWindowMinutes !== null
if (!updatedAt && !hasPrimaryData && !hasSecondaryData) {
return null
}
const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds)
const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds)
return {
updatedAt,
primary: {
usedPercent: primaryUsedPercent,
resetAfterSeconds: primaryResetAfterSeconds,
windowMinutes: primaryWindowMinutes,
resetAt: primaryMeta.resetAt,
remainingSeconds: primaryMeta.remainingSeconds
},
secondary: {
usedPercent: secondaryUsedPercent,
resetAfterSeconds: secondaryResetAfterSeconds,
windowMinutes: secondaryWindowMinutes,
resetAt: secondaryMeta.resetAt,
remainingSeconds: secondaryMeta.remainingSeconds
},
primaryOverSecondaryPercent: overSecondaryPercent
}
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken, proxy = null) {
try {
@@ -223,9 +144,8 @@ async function refreshAccessToken(refreshToken, proxy = null) {
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxyAgent) {
requestOptions.httpAgent = proxyAgent
requestOptions.httpsAgent = proxyAgent
requestOptions.proxy = false
requestOptions.proxy = false // 重要:禁用 axios 的默认代理,强制使用我们的 httpsAgent
logger.info(
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
)
@@ -335,19 +255,6 @@ function isTokenExpired(account) {
return new Date(account.expiresAt) <= new Date()
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象
* @returns {boolean} - true: 已过期, false: 未过期
*/
function isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 刷新账户的 access token带分布式锁
async function refreshAccountToken(accountId) {
let lockAcquired = false
@@ -418,15 +325,15 @@ async function refreshAccountToken(accountId) {
throw new Error('Failed to refresh token')
}
// 准备更新数据 - 不要在这里加密,让 updateAccount 统一处理
// 准备更新数据
const updates = {
accessToken: newTokens.access_token, // 不加密,让 updateAccount 处理
accessToken: encrypt(newTokens.access_token),
expiresAt: new Date(newTokens.expiry_date).toISOString()
}
// 如果有新的 ID token也更新它这对于首次未提供 ID Token 的账户特别重要)
if (newTokens.id_token) {
updates.idToken = newTokens.id_token // 不加密,让 updateAccount 处理
updates.idToken = encrypt(newTokens.id_token)
// 如果之前没有 ID Token尝试解析并更新用户信息
if (!account.idToken || account.idToken === '') {
@@ -457,7 +364,7 @@ async function refreshAccountToken(accountId) {
updates.organizationTitle = authClaims.organizations[0].title
}
if (payload.email) {
updates.email = payload.email // 不加密,让 updateAccount 处理
updates.email = encrypt(payload.email)
}
if (payload.email_verified !== undefined) {
updates.emailVerified = payload.email_verified
@@ -473,14 +380,14 @@ async function refreshAccountToken(accountId) {
// 如果返回了新的 refresh token更新它
if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) {
updates.refreshToken = newTokens.refresh_token // 不加密,让 updateAccount 处理
updates.refreshToken = encrypt(newTokens.refresh_token)
logger.info(`Updated refresh token for account ${accountId}`)
}
// 更新账户信息
await updateAccount(accountId, updates)
logRefreshSuccess(accountId, accountName, 'openai', newTokens) // 传入完整的 newTokens 对象
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens
} catch (error) {
logRefreshError(accountId, account?.name || accountName, 'openai', error.message)
@@ -569,11 +476,7 @@ async function createAccount(accountData) {
// 过期时间
expiresAt: oauthData.expires_in
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段)
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 默认1年
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
@@ -680,12 +583,6 @@ async function updateAccount(accountId, updates) {
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
}
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt直接保存
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -753,8 +650,6 @@ async function getAllAccounts() {
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
const codexUsage = buildCodexUsageSnapshot(accountData)
// 解密敏感数据(但不返回给前端)
if (accountData.email) {
accountData.email = decrypt(accountData.email)
@@ -762,24 +657,12 @@ async function getAllAccounts() {
// 先保存 refreshToken 是否存在的标记
const hasRefreshTokenFlag = !!accountData.refreshToken
const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : ''
const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : ''
const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : ''
// 屏蔽敏感信息token等不应该返回给前端
delete accountData.idToken
delete accountData.accessToken
delete accountData.refreshToken
delete accountData.openaiOauth
delete accountData.codexPrimaryUsedPercent
delete accountData.codexPrimaryResetAfterSeconds
delete accountData.codexPrimaryWindowMinutes
delete accountData.codexSecondaryUsedPercent
delete accountData.codexSecondaryResetAfterSeconds
delete accountData.codexSecondaryWindowMinutes
delete accountData.codexPrimaryOverSecondaryLimitPercent
// 时间戳改由 codexUsage.updatedAt 暴露
delete accountData.codexUsageUpdatedAt
// 获取限流状态信息
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
@@ -788,32 +671,24 @@ async function getAllAccounts() {
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
// 屏蔽代理密码
if (accountData.proxy && accountData.proxy.password) {
accountData.proxy.password = '******'
}
} catch (e) {
// 如果解析失败设置为null
accountData.proxy = null
}
}
const tokenExpiresAt = accountData.expiresAt || null
const subscriptionExpiresAt =
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== ''
? accountData.subscriptionExpiresAt
: null
// 不解密敏感字段,只返回基本信息
accounts.push({
...accountData,
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false',
openaiOauth: maskedOauth,
accessToken: maskedAccessToken,
refreshToken: maskedRefreshToken,
// ✅ 前端显示订阅过期时间(业务字段)
tokenExpiresAt,
subscriptionExpiresAt,
expiresAt: subscriptionExpiresAt,
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
// 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况
scopes:
@@ -823,20 +698,15 @@ async function getAllAccounts() {
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
status: rateLimitInfo.status,
isRateLimited: rateLimitInfo.isRateLimited,
rateLimitedAt: rateLimitInfo.rateLimitedAt,
rateLimitResetAt: rateLimitInfo.rateLimitResetAt,
minutesRemaining: rateLimitInfo.minutesRemaining
}
: {
status: 'normal',
isRateLimited: false,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
},
codexUsage
}
})
}
}
@@ -844,47 +714,6 @@ async function getAllAccounts() {
return accounts
}
// 获取单个账户的概要信息(用于外部展示基本状态)
async function getAccountOverview(accountId) {
const client = redisClient.getClientSafe()
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
return null
}
const codexUsage = buildCodexUsageSnapshot(accountData)
const rateLimitInfo = await getAccountRateLimitInfo(accountId)
if (accountData.proxy) {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (error) {
accountData.proxy = null
}
}
const scopes =
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : []
return {
id: accountData.id,
accountType: accountData.accountType || 'shared',
platform: accountData.platform || 'openai',
isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false',
rateLimitStatus: rateLimitInfo || {
status: 'normal',
isRateLimited: false,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
},
codexUsage,
scopes
}
}
// 选择可用账户(支持专属和共享账户)
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
// 首先检查是否有粘性会话
@@ -938,17 +767,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId)
if (
account &&
account.isActive === 'true' &&
!isRateLimited(account) &&
!isSubscriptionExpired(account)
) {
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account)
} else if (account && isSubscriptionExpired(account)) {
logger.debug(
`⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}`
)
}
}
@@ -1049,49 +869,6 @@ async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = nul
}
}
// 🚫 标记账户为未授权状态401错误
async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败401错误') {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const now = new Date().toISOString()
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
const updates = {
status: 'unauthorized',
schedulable: 'false',
errorMessage: reason,
unauthorizedAt: now,
unauthorizedCount: unauthorizedCount.toString()
}
await updateAccount(accountId, updates)
logger.warn(
`🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error`
)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'unauthorized',
errorCode: 'OPENAI_UNAUTHORIZED',
reason,
timestamp: now
})
logger.info(
`📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state`
)
} catch (webhookError) {
logger.error('Failed to send unauthorized webhook notification:', webhookError)
}
}
// 🔄 重置账户所有异常状态
async function resetAccountStatus(accountId) {
const account = await getAccount(accountId)
@@ -1163,39 +940,34 @@ async function getAccountRateLimitInfo(accountId) {
return null
}
const status = account.rateLimitStatus || 'normal'
const rateLimitedAt = account.rateLimitedAt || null
const rateLimitResetAt = account.rateLimitResetAt || null
if (status === 'limited') {
if (account.rateLimitStatus === 'limited') {
const now = Date.now()
let remainingTime = 0
if (rateLimitResetAt) {
const resetAt = new Date(rateLimitResetAt).getTime()
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
if (account.rateLimitResetAt) {
const resetAt = new Date(account.rateLimitResetAt).getTime()
remainingTime = Math.max(0, resetAt - now)
} else if (rateLimitedAt) {
const limitedAt = new Date(rateLimitedAt).getTime()
}
// 回退到使用 rateLimitedAt + 默认1小时
else if (account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const limitDuration = 60 * 60 * 1000 // 默认1小时
remainingTime = Math.max(0, limitedAt + limitDuration - now)
}
const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0
return {
status,
isRateLimited: minutesRemaining > 0,
rateLimitedAt,
rateLimitResetAt,
minutesRemaining
isRateLimited: remainingTime > 0,
rateLimitedAt: account.rateLimitedAt,
rateLimitResetAt: account.rateLimitResetAt,
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
}
}
return {
status,
isRateLimited: false,
rateLimitedAt,
rateLimitResetAt,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
}
}
@@ -1223,45 +995,9 @@ async function updateAccountUsage(accountId, tokens = 0) {
// 为了兼容性保留recordUsage作为updateAccountUsage的别名
const recordUsage = updateAccountUsage
async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
if (!usageSnapshot || typeof usageSnapshot !== 'object') {
return
}
const fieldMap = {
primaryUsedPercent: 'codexPrimaryUsedPercent',
primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds',
primaryWindowMinutes: 'codexPrimaryWindowMinutes',
secondaryUsedPercent: 'codexSecondaryUsedPercent',
secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds',
secondaryWindowMinutes: 'codexSecondaryWindowMinutes',
primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent'
}
const updates = {}
let hasPayload = false
for (const [key, field] of Object.entries(fieldMap)) {
if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) {
updates[field] = String(usageSnapshot[key])
hasPayload = true
}
}
if (!hasPayload) {
return
}
updates.codexUsageUpdatedAt = new Date().toISOString()
const client = redisClient.getClientSafe()
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
}
module.exports = {
createAccount,
getAccount,
getAccountOverview,
updateAccount,
deleteAccount,
getAllAccounts,
@@ -1269,13 +1005,11 @@ module.exports = {
refreshAccountToken,
isTokenExpired,
setAccountRateLimited,
markAccountUnauthorized,
resetAccountStatus,
toggleSchedulable,
getAccountRateLimitInfo,
updateAccountUsage,
recordUsage, // 别名指向updateAccountUsage
updateCodexUsageSnapshot,
encrypt,
decrypt,
generateEncryptionKey,

View File

@@ -75,11 +75,6 @@ class OpenAIResponsesAccountService {
isActive: isActive.toString(),
accountType,
schedulable: schedulable.toString(),
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
// 注意OpenAI-Responses 使用 API Key 认证,没有 OAuth token因此没有 expiresAt
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
createdAt: new Date().toISOString(),
lastUsedAt: '',
status: 'active',
@@ -156,12 +151,6 @@ class OpenAIResponsesAccountService {
: updates.baseApi
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// OpenAI-Responses 使用 API Key没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
// 直接保存,不做任何调整
}
// 更新 Redis
const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
@@ -223,10 +212,6 @@ class OpenAIResponsesAccountService {
// 转换 isActive 字段为布尔值
account.isActive = account.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
account.expiresAt = account.subscriptionExpiresAt || null
account.platform = account.platform || 'openai-responses'
accounts.push(account)
}
}
@@ -273,10 +258,6 @@ class OpenAIResponsesAccountService {
// 转换 isActive 字段为布尔值
accountData.isActive = accountData.isActive === 'true'
// ✅ 前端显示订阅过期时间(业务字段)
accountData.expiresAt = accountData.subscriptionExpiresAt || null
accountData.platform = accountData.platform || 'openai-responses'
accounts.push(accountData)
}
}
@@ -312,48 +293,6 @@ class OpenAIResponsesAccountService {
)
}
// 🚫 标记账户为未授权状态401错误
async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败401错误') {
const account = await this.getAccount(accountId)
if (!account) {
return
}
const now = new Date().toISOString()
const currentCount = parseInt(account.unauthorizedCount || '0', 10)
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1
await this.updateAccount(accountId, {
status: 'unauthorized',
schedulable: 'false',
errorMessage: reason,
unauthorizedAt: now,
unauthorizedCount: unauthorizedCount.toString()
})
logger.warn(
`🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error`
)
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || accountId,
platform: 'openai',
status: 'unauthorized',
errorCode: 'OPENAI_UNAUTHORIZED',
reason,
timestamp: now
})
logger.info(
`📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state`
)
} catch (webhookError) {
logger.error('Failed to send unauthorized webhook notification:', webhookError)
}
}
// 检查并清除过期的限流状态
async checkAndClearRateLimit(accountId) {
const account = await this.getAccount(accountId)
@@ -523,25 +462,6 @@ class OpenAIResponsesAccountService {
return { success: true, message: 'Account status reset successfully' }
}
// ⏰ 检查账户订阅是否已过期
isSubscriptionExpired(account) {
if (!account.subscriptionExpiresAt) {
return false // 未设置过期时间,视为永不过期
}
const expiryDate = new Date(account.subscriptionExpiresAt)
const now = new Date()
if (expiryDate <= now) {
logger.debug(
`⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}`
)
return true
}
return false
}
// 获取限流信息
_getRateLimitInfo(accountData) {
if (accountData.rateLimitStatus !== 'limited') {

View File

@@ -7,32 +7,6 @@ const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
const config = require('../../config/config')
const crypto = require('crypto')
// 抽取缓存写入 token兼容多种字段命名
function extractCacheCreationTokens(usageData) {
if (!usageData || typeof usageData !== 'object') {
return 0
}
const details = usageData.input_tokens_details || usageData.prompt_tokens_details || {}
const candidates = [
details.cache_creation_input_tokens,
details.cache_creation_tokens,
usageData.cache_creation_input_tokens,
usageData.cache_creation_tokens
]
for (const value of candidates) {
if (value !== undefined && value !== null && value !== '') {
const parsed = Number(value)
if (!Number.isNaN(parsed)) {
return parsed
}
}
}
return 0
}
class OpenAIResponsesRelayService {
constructor() {
this.defaultTimeout = config.requestTimeout || 600000
@@ -107,7 +81,6 @@ class OpenAIResponsesRelayService {
if (fullAccount.proxy) {
const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy)
if (proxyAgent) {
requestOptions.httpAgent = proxyAgent
requestOptions.httpsAgent = proxyAgent
requestOptions.proxy = false
logger.info(
@@ -196,61 +169,6 @@ class OpenAIResponsesRelayService {
errorData
})
if (response.status === 401) {
let reason = 'OpenAI Responses账号认证失败401错误'
if (errorData) {
if (typeof errorData === 'string' && errorData.trim()) {
reason = `OpenAI Responses账号认证失败401错误${errorData.trim()}`
} else if (
errorData.error &&
typeof errorData.error.message === 'string' &&
errorData.error.message.trim()
) {
reason = `OpenAI Responses账号认证失败401错误${errorData.error.message.trim()}`
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
reason = `OpenAI Responses账号认证失败401错误${errorData.message.trim()}`
}
}
try {
await unifiedOpenAIScheduler.markAccountUnauthorized(
account.id,
'openai-responses',
sessionHash,
reason
)
} catch (markError) {
logger.error(
'❌ Failed to mark OpenAI-Responses account unauthorized after 401:',
markError
)
}
let unauthorizedResponse = errorData
if (
!unauthorizedResponse ||
typeof unauthorizedResponse !== 'object' ||
unauthorizedResponse.pipe ||
Buffer.isBuffer(unauthorizedResponse)
) {
const fallbackMessage =
typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized'
unauthorizedResponse = {
error: {
message: fallbackMessage,
type: 'unauthorized',
code: 'unauthorized'
}
}
}
// 清理监听器
req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect)
return res.status(401).json(unauthorizedResponse)
}
// 清理监听器
req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect)
@@ -332,57 +250,6 @@ class OpenAIResponsesRelayService {
}
}
if (status === 401) {
let reason = 'OpenAI Responses账号认证失败401错误'
if (errorData) {
if (typeof errorData === 'string' && errorData.trim()) {
reason = `OpenAI Responses账号认证失败401错误${errorData.trim()}`
} else if (
errorData.error &&
typeof errorData.error.message === 'string' &&
errorData.error.message.trim()
) {
reason = `OpenAI Responses账号认证失败401错误${errorData.error.message.trim()}`
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
reason = `OpenAI Responses账号认证失败401错误${errorData.message.trim()}`
}
}
try {
await unifiedOpenAIScheduler.markAccountUnauthorized(
account.id,
'openai-responses',
sessionHash,
reason
)
} catch (markError) {
logger.error(
'❌ Failed to mark OpenAI-Responses account unauthorized in catch handler:',
markError
)
}
let unauthorizedResponse = errorData
if (
!unauthorizedResponse ||
typeof unauthorizedResponse !== 'object' ||
unauthorizedResponse.pipe ||
Buffer.isBuffer(unauthorizedResponse)
) {
const fallbackMessage =
typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized'
unauthorizedResponse = {
error: {
message: fallbackMessage,
type: 'unauthorized',
code: 'unauthorized'
}
}
}
return res.status(401).json(unauthorizedResponse)
}
return res.status(status).json(errorData)
}
@@ -518,22 +385,19 @@ class OpenAIResponsesRelayService {
if (usageData) {
try {
// OpenAI-Responses 使用 input_tokens/output_tokens标准 OpenAI 使用 prompt_tokens/completion_tokens
const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
// 提取缓存相关的 tokens如果存在
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
const cacheCreateTokens = extractCacheCreationTokens(usageData)
// 计算实际输入token总输入减去缓存部分
const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens)
const totalTokens =
usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
const modelToRecord = actualModel || requestedModel || 'gpt-4'
await apiKeyService.recordUsage(
apiKeyData.id,
actualInputTokens, // 传递实际输入(不含缓存)
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
@@ -542,7 +406,7 @@ class OpenAIResponsesRelayService {
)
logger.info(
`📊 Recorded usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${modelToRecord}`
`📊 Recorded usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${modelToRecord}`
)
// 更新账户的 token 使用统计
@@ -550,18 +414,9 @@ class OpenAIResponsesRelayService {
// 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) {
// 使用CostCalculator正确计算费用考虑缓存token的不同价格
const CostCalculator = require('../utils/costCalculator')
const costInfo = CostCalculator.calculateCost(
{
input_tokens: actualInputTokens, // 实际输入(不含缓存)
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
},
modelToRecord
)
await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total)
// 估算费用根据模型和token数量
const estimatedCost = this._estimateCost(modelToRecord, inputTokens, outputTokens)
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
}
} catch (error) {
logger.error('Failed to record usage:', error)
@@ -647,21 +502,18 @@ class OpenAIResponsesRelayService {
if (usageData) {
try {
// OpenAI-Responses 使用 input_tokens/output_tokens标准 OpenAI 使用 prompt_tokens/completion_tokens
const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
// 提取缓存相关的 tokens如果存在
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
const cacheCreateTokens = extractCacheCreationTokens(usageData)
// 计算实际输入token总输入减去缓存部分
const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens)
const totalTokens =
usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens
const totalTokens = usageData.total_tokens || inputTokens + outputTokens
await apiKeyService.recordUsage(
apiKeyData.id,
actualInputTokens, // 传递实际输入(不含缓存)
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
@@ -670,7 +522,7 @@ class OpenAIResponsesRelayService {
)
logger.info(
`📊 Recorded non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${actualModel}`
`📊 Recorded non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${actualModel}`
)
// 更新账户的 token 使用统计
@@ -678,18 +530,9 @@ class OpenAIResponsesRelayService {
// 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) {
// 使用CostCalculator正确计算费用考虑缓存token的不同价格
const CostCalculator = require('../utils/costCalculator')
const costInfo = CostCalculator.calculateCost(
{
input_tokens: actualInputTokens, // 实际输入(不含缓存)
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
},
actualModel
)
await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total)
// 估算费用根据模型和token数量
const estimatedCost = this._estimateCost(actualModel, inputTokens, outputTokens)
await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost)
}
} catch (error) {
logger.error('Failed to record usage:', error)

View File

@@ -31,25 +31,10 @@ class OpenAIToClaudeConverter {
stream: openaiRequest.stream || false
}
// 定义 Claude Code 的默认系统提示词
// Claude Code 必需的系统消息
const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude."
// 如果 OpenAI 请求中包含系统消息,提取并检查
const systemMessage = this._extractSystemMessage(openaiRequest.messages)
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
// Xcode 系统提示词
claudeRequest.system = systemMessage
logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
)
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
} else {
// 使用 Claude Code 默认系统提示词
claudeRequest.system = claudeCodeSystemMessage
logger.debug(
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`
)
}
// 处理停止序列
if (openaiRequest.stop) {

View File

@@ -1,32 +1,25 @@
const fs = require('fs')
const path = require('path')
const https = require('https')
const crypto = require('crypto')
const pricingSource = require('../../config/pricingSource')
const logger = require('../utils/logger')
class PricingService {
constructor() {
this.dataDir = path.join(process.cwd(), 'data')
this.pricingFile = path.join(this.dataDir, 'model_pricing.json')
this.pricingUrl = pricingSource.pricingUrl
this.hashUrl = pricingSource.hashUrl
this.pricingUrl =
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
this.fallbackFile = path.join(
process.cwd(),
'resources',
'model-pricing',
'model_prices_and_context_window.json'
)
this.localHashFile = path.join(this.dataDir, 'model_pricing.sha256')
this.pricingData = null
this.lastUpdated = null
this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
this.hashCheckInterval = 10 * 60 * 1000 // 10分钟哈希校验
this.fileWatcher = null // 文件监听器
this.reloadDebounceTimer = null // 防抖定时器
this.hashCheckTimer = null // 哈希轮询定时器
this.updateTimer = null // 定时更新任务句柄
this.hashSyncInProgress = false // 哈希同步状态
// 硬编码的 1 小时缓存价格(美元/百万 token
// ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
@@ -88,20 +81,11 @@ class PricingService {
// 检查是否需要下载或更新价格数据
await this.checkAndUpdatePricing()
// 初次启动时执行一次哈希校验,确保与远端保持一致
await this.syncWithRemoteHash()
// 设置定时更新
if (this.updateTimer) {
clearInterval(this.updateTimer)
}
this.updateTimer = setInterval(() => {
setInterval(() => {
this.checkAndUpdatePricing()
}, this.updateInterval)
// 设置哈希轮询
this.setupHashCheck()
// 设置文件监听器
this.setupFileWatcher()
@@ -161,114 +145,6 @@ class PricingService {
}
}
// 哈希轮询设置
setupHashCheck() {
if (this.hashCheckTimer) {
clearInterval(this.hashCheckTimer)
}
this.hashCheckTimer = setInterval(() => {
this.syncWithRemoteHash()
}, this.hashCheckInterval)
logger.info('🕒 已启用价格文件哈希轮询每10分钟校验一次')
}
// 与远端哈希对比
async syncWithRemoteHash() {
if (this.hashSyncInProgress) {
return
}
this.hashSyncInProgress = true
try {
const remoteHash = await this.fetchRemoteHash()
if (!remoteHash) {
return
}
const localHash = this.computeLocalHash()
if (!localHash) {
logger.info('📄 本地价格文件缺失,尝试下载最新版本')
await this.downloadPricingData()
return
}
if (remoteHash !== localHash) {
logger.info('🔁 检测到远端价格文件更新,开始下载最新数据')
await this.downloadPricingData()
}
} catch (error) {
logger.warn(`⚠️ 哈希校验失败:${error.message}`)
} finally {
this.hashSyncInProgress = false
}
}
// 获取远端哈希值
fetchRemoteHash() {
return new Promise((resolve, reject) => {
const request = https.get(this.hashUrl, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`哈希文件获取失败HTTP ${response.statusCode}`))
return
}
let data = ''
response.on('data', (chunk) => {
data += chunk
})
response.on('end', () => {
const hash = data.trim().split(/\s+/)[0]
if (!hash) {
reject(new Error('哈希文件内容为空'))
return
}
resolve(hash)
})
})
request.on('error', (error) => {
reject(new Error(`网络错误:${error.message}`))
})
request.setTimeout(30000, () => {
request.destroy()
reject(new Error('获取哈希超时30秒'))
})
})
}
// 计算本地文件哈希
computeLocalHash() {
if (!fs.existsSync(this.pricingFile)) {
return null
}
if (fs.existsSync(this.localHashFile)) {
const cached = fs.readFileSync(this.localHashFile, 'utf8').trim()
if (cached) {
return cached
}
}
const fileBuffer = fs.readFileSync(this.pricingFile)
return this.persistLocalHash(fileBuffer)
}
// 写入本地哈希文件
persistLocalHash(content) {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
fs.writeFileSync(this.localHashFile, `${hash}\n`)
return hash
}
// 实际的下载逻辑
_downloadFromRemote() {
return new Promise((resolve, reject) => {
@@ -278,21 +154,17 @@ class PricingService {
return
}
const chunks = []
let data = ''
response.on('data', (chunk) => {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
chunks.push(bufferChunk)
data += chunk
})
response.on('end', () => {
try {
const buffer = Buffer.concat(chunks)
const rawContent = buffer.toString('utf8')
const jsonData = JSON.parse(rawContent)
const jsonData = JSON.parse(data)
// 保存到文件并更新哈希
fs.writeFileSync(this.pricingFile, rawContent)
this.persistLocalHash(buffer)
// 保存到文件
fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2))
// 更新内存中的数据
this.pricingData = jsonData
@@ -354,11 +226,8 @@ class PricingService {
const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8')
const jsonData = JSON.parse(fallbackData)
const formattedJson = JSON.stringify(jsonData, null, 2)
// 保存到data目录
fs.writeFileSync(this.pricingFile, formattedJson)
this.persistLocalHash(formattedJson)
fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2))
// 更新内存中的数据
this.pricingData = jsonData
@@ -396,15 +265,6 @@ class PricingService {
return this.pricingData[modelName]
}
// 特殊处理gpt-5-codex 回退到 gpt-5
if (modelName === 'gpt-5-codex' && !this.pricingData['gpt-5-codex']) {
const fallbackPricing = this.pricingData['gpt-5']
if (fallbackPricing) {
logger.info(`💰 Using gpt-5 pricing as fallback for ${modelName}`)
return fallbackPricing
}
}
// 对于Bedrock区域前缀模型如 us.anthropic.claude-sonnet-4-20250514-v1:0
// 尝试去掉区域前缀进行匹配
if (modelName.includes('.anthropic.') || modelName.includes('.claude')) {
@@ -780,11 +640,6 @@ class PricingService {
// 清理资源
cleanup() {
if (this.updateTimer) {
clearInterval(this.updateTimer)
this.updateTimer = null
logger.debug('💰 Pricing update timer cleared')
}
if (this.fileWatcher) {
this.fileWatcher.close()
this.fileWatcher = null
@@ -794,11 +649,6 @@ class PricingService {
clearTimeout(this.reloadDebounceTimer)
this.reloadDebounceTimer = null
}
if (this.hashCheckTimer) {
clearInterval(this.hashCheckTimer)
this.hashCheckTimer = null
logger.debug('💰 Hash check timer cleared')
}
}
}

View File

@@ -110,6 +110,9 @@ class RateLimitCleanupService {
)
}
// 清空已清理账户列表
this.clearedAccounts = []
// 记录错误
const allErrors = [
...results.openai.errors,
@@ -122,8 +125,6 @@ class RateLimitCleanupService {
} catch (error) {
logger.error('❌ Rate limit cleanup failed:', error)
} finally {
// 确保无论成功或失败都重置列表,避免重复通知
this.clearedAccounts = []
this.isRunning = false
}
}
@@ -133,18 +134,11 @@ class RateLimitCleanupService {
*/
async cleanupOpenAIAccounts(result) {
try {
// 使用服务层获取账户数据
const accounts = await openaiAccountService.getAllAccounts()
for (const account of accounts) {
const { rateLimitStatus } = account
const isRateLimited =
rateLimitStatus === 'limited' ||
(rateLimitStatus &&
typeof rateLimitStatus === 'object' &&
(rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true))
if (isRateLimited) {
// 只检查标记为限流的账号
if (account.rateLimitStatus === 'limited') {
result.checked++
try {
@@ -186,24 +180,11 @@ class RateLimitCleanupService {
*/
async cleanupClaudeAccounts(result) {
try {
// 使用 Redis 获取账户数据
const redis = require('../models/redis')
const accounts = await redis.getAllClaudeAccounts()
const accounts = await claudeAccountService.getAllAccounts()
for (const account of accounts) {
// 检查是否处于限流状态(兼容对象和字符串格式)
const isRateLimited =
account.rateLimitStatus === 'limited' ||
(account.rateLimitStatus &&
typeof account.rateLimitStatus === 'object' &&
account.rateLimitStatus.status === 'limited')
const autoStopped = account.rateLimitAutoStopped === 'true'
const needsAutoStopRecovery =
autoStopped && (account.rateLimitEndAt || account.schedulable === 'false')
// 检查所有可能处于限流状态的账号,包括自动停止的账号
if (isRateLimited || account.rateLimitedAt || needsAutoStopRecovery) {
// 检查标记为限流的账号
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
result.checked++
try {
@@ -211,9 +192,6 @@ class RateLimitCleanupService {
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (!isStillLimited) {
if (!isRateLimited && autoStopped) {
await claudeAccountService.removeAccountRateLimit(account.id)
}
result.cleared++
logger.info(
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
@@ -281,24 +259,14 @@ class RateLimitCleanupService {
*/
async cleanupClaudeConsoleAccounts(result) {
try {
// 使用服务层获取账户数据
const accounts = await claudeConsoleAccountService.getAllAccounts()
for (const account of accounts) {
// 检查是否处于限流状态(兼容对象和字符串格式)
const isRateLimited =
account.rateLimitStatus === 'limited' ||
(account.rateLimitStatus &&
typeof account.rateLimitStatus === 'object' &&
account.rateLimitStatus.status === 'limited')
const autoStopped = account.rateLimitAutoStopped === 'true'
const needsAutoStopRecovery = autoStopped && account.schedulable === 'false'
// 检查两种状态字段rateLimitStatus 和 status
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
const hasStatusRateLimited = account.status === 'rate_limited'
if (isRateLimited || hasStatusRateLimited || needsAutoStopRecovery) {
if (hasRateLimitStatus || hasStatusRateLimited) {
result.checked++
try {
@@ -308,13 +276,10 @@ class RateLimitCleanupService {
)
if (!isStillLimited) {
if (!isRateLimited && autoStopped) {
await claudeConsoleAccountService.removeAccountRateLimit(account.id)
}
result.cleared++
// 如果 status 字段是 rate_limited需要额外清理
if (hasStatusRateLimited && !isRateLimited) {
if (hasStatusRateLimited && !hasRateLimitStatus) {
await claudeConsoleAccountService.updateAccount(account.id, {
status: 'active'
})

View File

@@ -1,416 +0,0 @@
/**
* Request Identity Service
*
* 处理 Claude 请求的身份信息规范化:
* 1. Stainless 指纹管理 - 收集、持久化和应用 x-stainless-* 系列请求头
* 2. User ID 规范化 - 重写 metadata.user_id使其与真实账户保持一致
*/
const crypto = require('crypto')
const logger = require('../utils/logger')
const redisService = require('../models/redis')
const SESSION_PREFIX = 'session_'
const ACCOUNT_MARKER = '_account_'
const STAINLESS_HEADER_KEYS = [
'x-stainless-retry-count',
'x-stainless-timeout',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version'
]
const MIN_FINGERPRINT_FIELDS = 4
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
function formatUuidFromSeed(seed) {
const digest = crypto.createHash('sha256').update(String(seed)).digest()
const bytes = Buffer.from(digest.subarray(0, 16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function safeParseJson(value) {
if (typeof value !== 'string' || !value.trim()) {
return null
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : null
} catch (error) {
return null
}
}
function getRedisClient() {
if (!redisService || typeof redisService.getClientSafe !== 'function') {
throw new Error('requestIdentityService: Redis 服务未初始化')
}
return redisService.getClientSafe()
}
function hasFingerprintValues(fingerprint) {
return fingerprint && typeof fingerprint === 'object' && Object.keys(fingerprint).length > 0
}
function sanitizeFingerprint(source) {
if (!source || typeof source !== 'object') {
return {}
}
const normalized = {}
const lowerCaseSource = {}
Object.keys(source).forEach((key) => {
const value = source[key]
if (value === undefined || value === null || String(value).trim() === '') {
return
}
lowerCaseSource[key.toLowerCase()] = String(value)
})
STAINLESS_HEADER_KEYS.forEach((key) => {
if (lowerCaseSource[key]) {
normalized[key] = lowerCaseSource[key]
}
})
return normalized
}
function collectFingerprintFromHeaders(headers) {
if (!headers || typeof headers !== 'object') {
return {}
}
const subset = {}
Object.keys(headers).forEach((key) => {
const lowerKey = key.toLowerCase()
if (STAINLESS_HEADER_KEYS.includes(lowerKey)) {
subset[lowerKey] = headers[key]
}
})
return sanitizeFingerprint(subset)
}
function removeHeaderCaseInsensitive(target, key) {
if (!target || typeof target !== 'object') {
return
}
const lowerKey = key.toLowerCase()
Object.keys(target).forEach((candidate) => {
if (candidate.toLowerCase() === lowerKey) {
delete target[candidate]
}
})
}
function applyFingerprintToHeaders(headers, fingerprint) {
if (!headers || typeof headers !== 'object') {
return headers
}
if (!hasFingerprintValues(fingerprint)) {
return { ...headers }
}
const nextHeaders = { ...headers }
STAINLESS_HEADER_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(fingerprint, key)) {
return
}
removeHeaderCaseInsensitive(nextHeaders, key)
nextHeaders[key] = fingerprint[key]
})
return nextHeaders
}
function persistFingerprint(accountId, fingerprint) {
if (!accountId || !hasFingerprintValues(fingerprint)) {
return
}
const client = getRedisClient()
const key = `${REDIS_KEY_PREFIX}${accountId}`
const serialized = JSON.stringify(fingerprint)
const command = client.set(key, serialized, 'NX')
if (command && typeof command.catch === 'function') {
command.catch((error) => {
logger.error(`requestIdentityService: Redis 持久化指纹失败 (${accountId}): ${error.message}`)
})
}
}
function 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
}
function headersChanged(original, updated) {
if (original === updated) {
return false
}
for (const key of STAINLESS_HEADER_KEYS) {
if (
getHeaderValueCaseInsensitive(original, key) !== getHeaderValueCaseInsensitive(updated, key)
) {
return true
}
}
return false
}
function resolveAccountId(payload) {
if (!payload || typeof payload !== 'object') {
return null
}
const account = payload.account && typeof payload.account === 'object' ? payload.account : null
const candidates = [
payload.accountId,
payload.account_id,
payload.accountID,
account && (account.accountId || account.account_id || account.accountID),
account && (account.id || account.uuid),
account && (account.account_uuid || account.accountUuid),
account && (account.schedulerAccountId || account.scheduler_account_id)
]
for (const candidate of candidates) {
if (candidate === undefined || candidate === null) {
continue
}
const stringified = String(candidate).trim()
if (stringified) {
return stringified
}
}
return null
}
function rewriteHeaders(headers, accountId) {
if (!headers || typeof headers !== 'object') {
return { nextHeaders: headers, changed: false }
}
if (!accountId) {
return { nextHeaders: { ...headers }, changed: false }
}
const workingHeaders = { ...headers }
const fingerprint = collectFingerprintFromHeaders(workingHeaders)
const fieldCount = Object.keys(fingerprint).length
if (fieldCount < MIN_FINGERPRINT_FIELDS) {
logger.warn(
`requestIdentityService: 账号 ${accountId} 提供的 Stainless 指纹字段不足,已保持原样`
)
return { nextHeaders: workingHeaders, changed: false }
}
try {
persistFingerprint(accountId, fingerprint)
} catch (error) {
logger.error(`requestIdentityService: 持久化指纹失败 (${accountId}): ${error.message}`)
return {
abortResponse: {
statusCode: 500,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ error: 'fingerprint_persist_failed', message: '指纹信息持久化失败' })
}
}
}
const appliedHeaders = applyFingerprintToHeaders(workingHeaders, fingerprint)
const changed = headersChanged(workingHeaders, appliedHeaders)
return { nextHeaders: appliedHeaders, changed }
}
function normalizeAccountUuid(candidate) {
if (typeof candidate !== 'string') {
return null
}
const trimmed = candidate.trim()
return trimmed || null
}
function extractAccountUuid(account) {
if (!account || typeof account !== 'object') {
return null
}
const extInfoRaw = account.extInfo
if (!extInfoRaw) {
return null
}
const extInfoObject = typeof extInfoRaw === 'string' ? safeParseJson(extInfoRaw) : null
if (!extInfoObject || typeof extInfoObject !== 'object') {
return null
}
const extUuid = normalizeAccountUuid(extInfoObject.account_uuid)
return extUuid || null
}
function rewriteUserId(body, accountId, accountUuid) {
if (!body || typeof body !== 'object') {
return { nextBody: body, changed: false }
}
const { metadata } = body
if (!metadata || typeof metadata !== 'object') {
return { nextBody: body, changed: false }
}
const userId = metadata.user_id
if (typeof userId !== 'string') {
return { nextBody: body, changed: false }
}
const pivot = userId.lastIndexOf(SESSION_PREFIX)
if (pivot === -1) {
return { nextBody: body, changed: false }
}
const prefixBeforeSession = userId.slice(0, pivot)
const sessionTail = userId.slice(pivot + SESSION_PREFIX.length)
const seedTail = sessionTail || 'default'
const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler'
const hashed = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`)
let normalizedPrefix = prefixBeforeSession
if (accountUuid) {
const trimmedUuid = normalizeAccountUuid(accountUuid)
if (trimmedUuid) {
const accountIndex = normalizedPrefix.indexOf(ACCOUNT_MARKER)
if (accountIndex === -1) {
const base = normalizedPrefix.replace(/_+$/, '')
const baseWithMarker = /_account$/.test(base) ? base : `${base}_account`
normalizedPrefix = `${baseWithMarker}_${trimmedUuid}_`
} else {
const valueStart = accountIndex + ACCOUNT_MARKER.length
let separatorIndex = normalizedPrefix.indexOf('_', valueStart)
if (separatorIndex === -1) {
separatorIndex = normalizedPrefix.length
}
const head = normalizedPrefix.slice(0, valueStart)
let tail = '_'
if (separatorIndex < normalizedPrefix.length) {
tail = normalizedPrefix.slice(separatorIndex)
if (/^_+$/.test(tail)) {
tail = '_'
}
}
normalizedPrefix = `${head}${trimmedUuid}${tail}`
}
}
}
const nextUserId = `${normalizedPrefix}${SESSION_PREFIX}${hashed}`
if (nextUserId === userId) {
return { nextBody: body, changed: false }
}
const nextBody = {
...body,
metadata: {
...metadata,
user_id: nextUserId
}
}
return { nextBody, changed: true }
}
/**
* 转换请求身份信息
* @param {Object} payload - 请求载荷
* @param {Object} payload.body - 请求体
* @param {Object} payload.headers - 请求头
* @param {string} payload.accountId - 账户ID
* @param {Object} payload.account - 账户对象
* @returns {Object} 转换后的 { body, headers, abortResponse? }
*/
function transform(payload = {}) {
const currentBody = payload.body
const currentHeaders = payload.headers
if (!payload.accountId) {
return {
body: currentBody,
headers: currentHeaders
}
}
const accountUuid = extractAccountUuid(payload.account)
const accountIdForHeaders = resolveAccountId(payload)
const { nextBody } = rewriteUserId(currentBody, payload.accountId, accountUuid)
const headerResult = rewriteHeaders(currentHeaders, accountIdForHeaders)
const nextHeaders = headerResult ? headerResult.nextHeaders : currentHeaders
const abortResponse =
headerResult && headerResult.abortResponse ? headerResult.abortResponse : null
return {
body: nextBody,
headers: nextHeaders,
abortResponse
}
}
module.exports = {
transform,
// 导出内部函数供测试使用
_internal: {
formatUuidFromSeed,
collectFingerprintFromHeaders,
rewriteHeaders,
rewriteUserId,
extractAccountUuid,
resolveAccountId
}
}

View File

@@ -28,25 +28,8 @@ class UnifiedClaudeScheduler {
return true // 没有指定模型时,默认支持
}
// Claude OAuth 账户的模型检查
// Claude OAuth 账户的 Opus 模型检查
if (accountType === 'claude-official') {
// 1. 首先检查是否为 Claude 官方支持的模型
// Claude Official API 只支持 Anthropic 自己的模型,不支持第三方模型(如 deepseek-chat)
const isClaudeOfficialModel =
requestedModel.startsWith('claude-') ||
requestedModel.includes('claude') ||
requestedModel.includes('sonnet') ||
requestedModel.includes('opus') ||
requestedModel.includes('haiku')
if (!isClaudeOfficialModel) {
logger.info(
`🚫 Claude official account ${account.name} does not support non-Claude model ${requestedModel}${context ? ` ${context}` : ''}`
)
return false
}
// 2. Opus 模型的订阅级别检查
if (requestedModel.toLowerCase().includes('opus')) {
if (account.subscriptionInfo) {
try {
@@ -148,10 +131,6 @@ class UnifiedClaudeScheduler {
logger.debug(
`🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}`
)
const isOpusRequest =
effectiveModel && typeof effectiveModel === 'string'
? effectiveModel.toLowerCase().includes('opus')
: false
// 如果是 CCR 前缀,只在 CCR 账户池中选择
if (vendor === 'ccr') {
@@ -176,25 +155,12 @@ class UnifiedClaudeScheduler {
// 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
} else {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
}
if (
boundAccount &&
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
this._isSchedulable(boundAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
@@ -202,10 +168,9 @@ class UnifiedClaudeScheduler {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
}
}
} else {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool`
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
}
}
@@ -348,10 +313,6 @@ class UnifiedClaudeScheduler {
// 📋 获取所有可用账户合并官方和Console
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
const availableAccounts = []
const isOpusRequest =
requestedModel && typeof requestedModel === 'string'
? requestedModel.toLowerCase().includes('opus')
: false
// 如果API Key绑定了专属账户优先返回
// 1. 检查Claude OAuth账户绑定
@@ -362,23 +323,11 @@ class UnifiedClaudeScheduler {
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
boundAccount.status !== 'blocked' &&
boundAccount.status !== 'temp_error'
boundAccount.status !== 'temp_error' &&
this._isSchedulable(boundAccount.schedulable)
) {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
)
} else {
if (!isRateLimited) {
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`
)
@@ -394,7 +343,7 @@ class UnifiedClaudeScheduler {
}
} else {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})`
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})`
)
}
}
@@ -498,20 +447,7 @@ class UnifiedClaudeScheduler {
// 检查是否被限流
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (isRateLimited) {
continue
}
if (isOpusRequest) {
const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(account.id)
if (isOpusRateLimited) {
logger.info(
`🚫 Skipping account ${account.name} (${account.id}) due to active Opus limit`
)
continue
}
}
if (!isRateLimited) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -521,131 +457,46 @@ class UnifiedClaudeScheduler {
})
}
}
}
// 获取Claude Console账户
const consoleAccounts = await claudeConsoleAccountService.getAllAccounts()
logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`)
// 🔢 统计Console账户并发排除情况
let consoleAccountsEligibleCount = 0 // 符合基本条件的账户数
let consoleAccountsExcludedByConcurrency = 0 // 因并发满额被排除的账户数
// 🚀 收集需要并发检查的账户ID列表批量查询优化
const accountsNeedingConcurrencyCheck = []
for (const account of consoleAccounts) {
// 主动检查封禁状态并尝试恢复(在过滤之前执行,确保可以恢复被封禁的账户)
const wasBlocked = await claudeConsoleAccountService.isAccountBlocked(account.id)
// 如果账户之前被封禁但现在已恢复,重新获取最新状态
let currentAccount = account
if (wasBlocked === false && account.status === 'account_blocked') {
// 可能刚刚被恢复,重新获取账户状态
const freshAccount = await claudeConsoleAccountService.getAccount(account.id)
if (freshAccount) {
currentAccount = freshAccount
logger.info(`🔄 Account ${account.name} was recovered from blocked status`)
}
}
logger.info(
`🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
`🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
// 注意getAllAccounts返回的isActive是布尔值getAccount返回的也是布尔值
// 注意getAllAccounts返回的isActive是布尔值
if (
currentAccount.isActive === true &&
currentAccount.status === 'active' &&
currentAccount.accountType === 'shared' &&
this._isSchedulable(currentAccount.schedulable)
account.isActive === true &&
account.status === 'active' &&
account.accountType === 'shared' &&
this._isSchedulable(account.schedulable)
) {
// 检查是否可调度
// 检查模型支持
if (!this._isModelSupportedByAccount(currentAccount, 'claude-console', requestedModel)) {
continue
}
// 检查订阅是否过期
if (claudeConsoleAccountService.isSubscriptionExpired(currentAccount)) {
logger.debug(
`⏰ Claude Console account ${currentAccount.name} (${currentAccount.id}) expired at ${currentAccount.subscriptionExpiresAt}`
)
if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) {
continue
}
// 主动触发一次额度检查,确保状态即时生效
try {
await claudeConsoleAccountService.checkQuotaUsage(currentAccount.id)
await claudeConsoleAccountService.checkQuotaUsage(account.id)
} catch (e) {
logger.warn(
`Failed to check quota for Claude Console account ${currentAccount.name}: ${e.message}`
`Failed to check quota for Claude Console account ${account.name}: ${e.message}`
)
// 继续处理该账号
}
// 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
currentAccount.id
)
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
currentAccount.id
)
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
// 🔢 记录符合基本条件的账户(通过了前面所有检查,但可能因并发被排除)
if (!isRateLimited && !isQuotaExceeded) {
consoleAccountsEligibleCount++
// 🚀 将符合条件且需要并发检查的账户加入批量查询列表
if (currentAccount.maxConcurrentTasks > 0) {
accountsNeedingConcurrencyCheck.push(currentAccount)
} else {
// 未配置并发限制的账户直接加入可用池
availableAccounts.push({
...currentAccount,
accountId: currentAccount.id,
accountType: 'claude-console',
priority: parseInt(currentAccount.priority) || 50,
lastUsedAt: currentAccount.lastUsedAt || '0'
})
logger.info(
`✅ Added Claude Console account to available pool: ${currentAccount.name} (priority: ${currentAccount.priority}, no concurrency limit)`
)
}
} else {
if (isRateLimited) {
logger.warn(`⚠️ Claude Console account ${currentAccount.name} is rate limited`)
}
if (isQuotaExceeded) {
logger.warn(`💰 Claude Console account ${currentAccount.name} quota exceeded`)
}
}
} else {
logger.info(
`❌ Claude Console account ${currentAccount.name} not eligible - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
)
}
}
// 🚀 批量查询所有账户的并发数Promise.all 并行执行)
if (accountsNeedingConcurrencyCheck.length > 0) {
logger.debug(
`🚀 Batch checking concurrency for ${accountsNeedingConcurrencyCheck.length} accounts`
)
const concurrencyCheckPromises = accountsNeedingConcurrencyCheck.map((account) =>
redis.getConsoleAccountConcurrency(account.id).then((currentConcurrency) => ({
account,
currentConcurrency
}))
)
const concurrencyResults = await Promise.all(concurrencyCheckPromises)
// 处理批量查询结果
for (const { account, currentConcurrency } of concurrencyResults) {
const isConcurrencyFull = currentConcurrency >= account.maxConcurrentTasks
if (!isConcurrencyFull) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -654,15 +505,20 @@ class UnifiedClaudeScheduler {
lastUsedAt: account.lastUsedAt || '0'
})
logger.info(
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority}, concurrency: ${currentConcurrency}/${account.maxConcurrentTasks})`
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
)
} else {
// 🔢 因并发满额被排除计数器加1
consoleAccountsExcludedByConcurrency++
logger.warn(
`⚠️ Claude Console account ${account.name} reached concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks}`
)
if (isRateLimited) {
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
}
if (isQuotaExceeded) {
logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
}
}
} else {
logger.info(
`❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
)
}
}
@@ -723,14 +579,6 @@ class UnifiedClaudeScheduler {
continue
}
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
)
continue
}
// 检查是否被限流
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
@@ -765,26 +613,6 @@ class UnifiedClaudeScheduler {
logger.info(
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})`
)
// 🚨 最终检查只有在没有任何可用账户时才根据Console并发排除情况抛出专用错误码
if (availableAccounts.length === 0) {
// 如果所有Console账户都因并发满额被排除抛出专用错误码503
if (
consoleAccountsEligibleCount > 0 &&
consoleAccountsExcludedByConcurrency === consoleAccountsEligibleCount
) {
logger.error(
`❌ All ${consoleAccountsEligibleCount} eligible Console accounts are at concurrency limit (no other account types available)`
)
const error = new Error(
'All available Claude Console accounts have reached their concurrency limit'
)
error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL'
throw error
}
// 否则走通用的"无可用账户"错误处理(由上层 selectAccountForApiKey 捕获)
}
return availableAccounts
}
@@ -837,23 +665,7 @@ class UnifiedClaudeScheduler {
// 检查是否限流或过载
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId)
if (isRateLimited || isOverloaded) {
return false
}
if (
requestedModel &&
typeof requestedModel === 'string' &&
requestedModel.toLowerCase().includes('opus')
) {
const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(accountId)
if (isOpusRateLimited) {
logger.info(`🚫 Account ${accountId} skipped due to active Opus limit (session check)`)
return false
}
}
return true
return !isRateLimited && !isOverloaded
} else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account || !account.isActive) {
@@ -883,13 +695,6 @@ class UnifiedClaudeScheduler {
) {
return false
}
// 检查订阅是否过期
if (claudeConsoleAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ Claude Console account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
)
return false
}
// 检查是否超额
try {
await claudeConsoleAccountService.checkQuotaUsage(accountId)
@@ -913,18 +718,6 @@ class UnifiedClaudeScheduler {
if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) {
return false
}
// 检查并发限制(预检查,真正的原子抢占在 relayService 中进行)
if (account.maxConcurrentTasks > 0) {
const currentConcurrency = await redis.getConsoleAccountConcurrency(accountId)
if (currentConcurrency >= account.maxConcurrentTasks) {
logger.info(
`🚫 Claude Console account ${accountId} reached concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks} (pre-check)`
)
return false
}
}
return true
} else if (accountType === 'bedrock') {
const accountResult = await bedrockAccountService.getAccount(accountId)
@@ -960,13 +753,6 @@ class UnifiedClaudeScheduler {
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
return false
}
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)`
)
return false
}
// 检查是否超额
try {
await ccrAccountService.checkQuotaUsage(accountId)
@@ -1033,28 +819,6 @@ class UnifiedClaudeScheduler {
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
}
/**
* 🧹 公共方法:清理粘性会话映射(用于并发满额时的降级处理)
* @param {string} sessionHash - 会话哈希值
*/
async clearSessionMapping(sessionHash) {
// 防御空会话哈希
if (!sessionHash || typeof sessionHash !== 'string') {
logger.debug('⚠️ Skipping session mapping clear - invalid sessionHash')
return
}
try {
await this._deleteSessionMapping(sessionHash)
logger.info(
`🧹 Cleared sticky session mapping for session: ${sessionHash.substring(0, 8)}...`
)
} catch (error) {
logger.error(`❌ Failed to clear session mapping for ${sessionHash}:`, error)
throw error
}
}
// 🔁 续期统一调度会话映射TTL针对 unified_claude_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
try {
@@ -1292,10 +1056,6 @@ class UnifiedClaudeScheduler {
}
const availableAccounts = []
const isOpusRequest =
requestedModel && typeof requestedModel === 'string'
? requestedModel.toLowerCase().includes('opus')
: false
// 获取所有成员账户的详细信息
for (const memberId of memberIds) {
@@ -1355,33 +1115,7 @@ class UnifiedClaudeScheduler {
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id, accountType)
if (isRateLimited) {
continue
}
if (accountType === 'claude-official' && isOpusRequest) {
const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(
account.id
)
if (isOpusRateLimited) {
logger.info(
`🚫 Skipping group member ${account.name} (${account.id}) due to active Opus limit`
)
continue
}
}
// 🔒 检查 Claude Console 账户的并发限制
if (accountType === 'claude-console' && account.maxConcurrentTasks > 0) {
const currentConcurrency = await redis.getConsoleAccountConcurrency(account.id)
if (currentConcurrency >= account.maxConcurrentTasks) {
logger.info(
`🚫 Skipping group member ${account.name} (${account.id}) due to concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks}`
)
continue
}
}
if (!isRateLimited) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -1391,6 +1125,7 @@ class UnifiedClaudeScheduler {
})
}
}
}
if (availableAccounts.length === 0) {
throw new Error(`No available accounts in group ${group.name}`)
@@ -1521,14 +1256,6 @@ class UnifiedClaudeScheduler {
continue
}
// 检查订阅是否过期
if (ccrAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
)
continue
}
// 检查是否被限流或超额
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)

View File

@@ -1,5 +1,4 @@
const geminiAccountService = require('./geminiAccountService')
const geminiApiAccountService = require('./geminiApiAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
@@ -19,70 +18,23 @@ class UnifiedGeminiScheduler {
return schedulable !== false && schedulable !== 'false'
}
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
_isActive(isActive) {
// 兼容布尔值 true 和字符串 'true'
return isActive === true || isActive === 'true'
}
// 🎯 统一调度Gemini账号
async selectAccountForApiKey(
apiKeyData,
sessionHash = null,
requestedModel = null,
options = {}
) {
const { allowApiAccounts = false } = options
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
// 如果API Key绑定了专属账户或分组优先使用
if (apiKeyData.geminiAccountId) {
// 检查是否是 Gemini API 账户api: 前缀)
if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId)
if (
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
logger.info(
`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await geminiApiAccountService.markAccountUsed(accountId)
return {
accountId,
accountType: 'gemini-api'
}
} else {
// 提供详细的不可用原因
const reason = !boundAccount
? 'account not found'
: boundAccount.isActive !== 'true'
? `isActive=${boundAccount.isActive}`
: `status=${boundAccount.status}`
logger.warn(
`⚠️ Bound Gemini-API account ${accountId} is not available (${reason}), falling back to pool`
)
}
}
// 检查是否是分组
else if (apiKeyData.geminiAccountId.startsWith('group:')) {
if (apiKeyData.geminiAccountId.startsWith('group:')) {
const groupId = apiKeyData.geminiAccountId.replace('group:', '')
logger.info(
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
)
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
}
// 普通 Gemini OAuth 专属账户
else {
// 普通专属账户
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
if (
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
logger.info(
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
)
@@ -98,7 +50,6 @@ class UnifiedGeminiScheduler {
)
}
}
}
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
@@ -115,12 +66,8 @@ class UnifiedGeminiScheduler {
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
// 更新账户的最后使用时间(根据账户类型调用正确的服务)
if (mappedAccount.accountType === 'gemini-api') {
await geminiApiAccountService.markAccountUsed(mappedAccount.accountId)
} else {
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(mappedAccount.accountId)
}
return mappedAccount
} else {
logger.warn(
@@ -132,11 +79,7 @@ class UnifiedGeminiScheduler {
}
// 获取所有可用账户
const availableAccounts = await this._getAllAvailableAccounts(
apiKeyData,
requestedModel,
allowApiAccounts
)
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel)
if (availableAccounts.length === 0) {
// 提供更详细的错误信息
@@ -171,12 +114,8 @@ class UnifiedGeminiScheduler {
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间(根据账户类型调用正确的服务)
if (selectedAccount.accountType === 'gemini-api') {
await geminiApiAccountService.markAccountUsed(selectedAccount.accountId)
} else {
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(selectedAccount.accountId)
}
return {
accountId: selectedAccount.accountId,
@@ -189,71 +128,13 @@ class UnifiedGeminiScheduler {
}
// 📋 获取所有可用账户
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) {
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
const availableAccounts = []
// 如果API Key绑定了专属账户优先返回
if (apiKeyData.geminiAccountId) {
// 检查是否是 Gemini API 账户api: 前缀)
if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId)
if (
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
const isRateLimited = await this.isAccountRateLimited(accountId)
if (!isRateLimited) {
// 检查模型支持
if (
requestedModel &&
boundAccount.supportedModels &&
boundAccount.supportedModels.length > 0
) {
const normalizedModel = requestedModel.replace('models/', '')
const modelSupported = boundAccount.supportedModels.some(
(model) => model.replace('models/', '') === normalizedModel
)
if (!modelSupported) {
logger.warn(
`⚠️ Bound Gemini-API account ${boundAccount.name} does not support model ${requestedModel}`
)
return availableAccounts
}
}
logger.info(`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId})`)
return [
{
...boundAccount,
accountId,
accountType: 'gemini-api',
priority: parseInt(boundAccount.priority) || 50,
lastUsedAt: boundAccount.lastUsedAt || '0'
}
]
}
} else {
// 提供详细的不可用原因
const reason = !boundAccount
? 'account not found'
: boundAccount.isActive !== 'true'
? `isActive=${boundAccount.isActive}`
: `status=${boundAccount.status}`
logger.warn(
`⚠️ Bound Gemini-API account ${accountId} is not available in _getAllAvailableAccounts (${reason})`
)
}
}
// 普通 Gemini OAuth 账户
else if (!apiKeyData.geminiAccountId.startsWith('group:')) {
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
if (
boundAccount &&
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
// 检查模型支持
@@ -292,13 +173,12 @@ class UnifiedGeminiScheduler {
logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`)
}
}
}
// 获取所有Gemini OAuth账户(共享池)
// 获取所有Gemini账户共享池
const geminiAccounts = await geminiAccountService.getAllAccounts()
for (const account of geminiAccounts) {
if (
this._isActive(account.isActive) &&
account.isActive === 'true' &&
account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
@@ -343,48 +223,7 @@ class UnifiedGeminiScheduler {
}
}
// 如果允许调度 Gemini API 账户,则添加到可用列表
if (allowApiAccounts) {
const geminiApiAccounts = await geminiApiAccountService.getAllAccounts()
for (const account of geminiApiAccounts) {
if (
this._isActive(account.isActive) &&
account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) &&
this._isSchedulable(account.schedulable)
) {
// 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
const normalizedModel = requestedModel.replace('models/', '')
const modelSupported = account.supportedModels.some(
(model) => model.replace('models/', '') === normalizedModel
)
if (!modelSupported) {
logger.debug(
`⏭️ Skipping Gemini-API account ${account.name} - doesn't support model ${requestedModel}`
)
continue
}
}
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id)
if (!isRateLimited) {
availableAccounts.push({
...account,
accountId: account.id,
accountType: 'gemini-api',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
}
}
}
}
logger.info(
`📊 Total available accounts: ${availableAccounts.length} (Gemini OAuth + ${allowApiAccounts ? 'Gemini API' : 'no API accounts'})`
)
logger.info(`📊 Total available Gemini accounts: ${availableAccounts.length}`)
return availableAccounts
}
@@ -408,7 +247,7 @@ class UnifiedGeminiScheduler {
try {
if (accountType === 'gemini') {
const account = await geminiAccountService.getAccount(accountId)
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
if (!account || account.isActive !== 'true' || account.status === 'error') {
return false
}
// 检查是否可调度
@@ -417,17 +256,6 @@ class UnifiedGeminiScheduler {
return false
}
return !(await this.isAccountRateLimited(accountId))
} else if (accountType === 'gemini-api') {
const account = await geminiApiAccountService.getAccount(accountId)
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
return false
}
// 检查是否可调度
if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
return false
}
return !(await this.isAccountRateLimited(accountId))
}
return false
} catch (error) {
@@ -516,8 +344,6 @@ class UnifiedGeminiScheduler {
try {
if (accountType === 'gemini') {
await geminiAccountService.setAccountRateLimited(accountId, true)
} else if (accountType === 'gemini-api') {
await geminiApiAccountService.setAccountRateLimited(accountId, true)
}
// 删除会话映射
@@ -540,8 +366,6 @@ class UnifiedGeminiScheduler {
try {
if (accountType === 'gemini') {
await geminiAccountService.setAccountRateLimited(accountId, false)
} else if (accountType === 'gemini-api') {
await geminiApiAccountService.setAccountRateLimited(accountId, false)
}
return { success: true }
@@ -555,23 +379,9 @@ class UnifiedGeminiScheduler {
}
// 🔍 检查账户是否处于限流状态
async isAccountRateLimited(accountId, accountType = null) {
async isAccountRateLimited(accountId) {
try {
let account = null
// 如果指定了账户类型,直接使用对应服务
if (accountType === 'gemini-api') {
account = await geminiApiAccountService.getAccount(accountId)
} else if (accountType === 'gemini') {
account = await geminiAccountService.getAccount(accountId)
} else {
// 未指定类型,先尝试 gemini再尝试 gemini-api
account = await geminiAccountService.getAccount(accountId)
if (!account) {
account = await geminiApiAccountService.getAccount(accountId)
}
}
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return false
}
@@ -579,9 +389,7 @@ class UnifiedGeminiScheduler {
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
const limitedAt = new Date(account.rateLimitedAt).getTime()
const now = Date.now()
// 使用账户配置的限流时长,默认1小时
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60
const limitDuration = rateLimitDuration * 60 * 1000
const limitDuration = 60 * 60 * 1000 // 1小时
return now < limitedAt + limitDuration
}
@@ -592,7 +400,7 @@ class UnifiedGeminiScheduler {
}
}
// 👥 从分组中选择账户(支持 Gemini OAuth 和 Gemini API 两种账户类型)
// 👥 从分组中选择账户
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
try {
// 获取分组信息
@@ -624,12 +432,8 @@ class UnifiedGeminiScheduler {
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
// 更新账户的最后使用时间(根据账户类型调用正确的服务)
if (mappedAccount.accountType === 'gemini-api') {
await geminiApiAccountService.markAccountUsed(mappedAccount.accountId)
} else {
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(mappedAccount.accountId)
}
return mappedAccount
}
}
@@ -646,17 +450,9 @@ class UnifiedGeminiScheduler {
const availableAccounts = []
// 获取所有成员账户的详细信息(支持 Gemini OAuth 和 Gemini API 两种类型)
// 获取所有成员账户的详细信息
for (const memberId of memberIds) {
// 首先尝试从 Gemini OAuth 账户服务获取
let account = await geminiAccountService.getAccount(memberId)
let accountType = 'gemini'
// 如果 Gemini OAuth 账户不存在,尝试从 Gemini API 账户服务获取
if (!account) {
account = await geminiApiAccountService.getAccount(memberId)
accountType = 'gemini-api'
}
const account = await geminiAccountService.getAccount(memberId)
if (!account) {
logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`)
@@ -665,12 +461,11 @@ class UnifiedGeminiScheduler {
// 检查账户是否可用
if (
this._isActive(account.isActive) &&
account.isActive === 'true' &&
account.status !== 'error' &&
this._isSchedulable(account.schedulable)
) {
// 对于 Gemini OAuth 账户,检查 token 是否过期
if (accountType === 'gemini') {
// 检查token是否过期
const isExpired = geminiAccountService.isTokenExpired(account)
if (isExpired && !account.refreshToken) {
logger.warn(
@@ -678,7 +473,6 @@ class UnifiedGeminiScheduler {
)
continue
}
}
// 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
@@ -689,19 +483,19 @@ class UnifiedGeminiScheduler {
)
if (!modelSupported) {
logger.debug(
`⏭️ Skipping ${accountType} account ${account.name} in group - doesn't support model ${requestedModel}`
`⏭️ Skipping Gemini account ${account.name} in group - doesn't support model ${requestedModel}`
)
continue
}
}
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id, accountType)
const isRateLimited = await this.isAccountRateLimited(account.id)
if (!isRateLimited) {
availableAccounts.push({
...account,
accountId: account.id,
accountType,
accountType: 'gemini',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
@@ -735,12 +529,8 @@ class UnifiedGeminiScheduler {
`🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`
)
// 更新账户的最后使用时间(根据账户类型调用正确的服务)
if (selectedAccount.accountType === 'gemini-api') {
await geminiApiAccountService.markAccountUsed(selectedAccount.accountId)
} else {
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(selectedAccount.accountId)
}
return {
accountId: selectedAccount.accountId,

View File

@@ -19,114 +19,6 @@ class UnifiedOpenAIScheduler {
return schedulable !== false && schedulable !== 'false'
}
// 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式)
_isRateLimited(rateLimitStatus) {
if (!rateLimitStatus) {
return false
}
// 兼容字符串格式Redis 原始数据)
if (typeof rateLimitStatus === 'string') {
return rateLimitStatus === 'limited'
}
// 兼容对象格式getAllAccounts 返回的数据)
if (typeof rateLimitStatus === 'object') {
if (rateLimitStatus.isRateLimited === false) {
return false
}
// 检查对象中的 status 字段
return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true
}
return false
}
// 🔍 判断账号是否带有限流标记(即便已过期,用于自动恢复)
_hasRateLimitFlag(rateLimitStatus) {
if (!rateLimitStatus) {
return false
}
if (typeof rateLimitStatus === 'string') {
return rateLimitStatus === 'limited'
}
if (typeof rateLimitStatus === 'object') {
return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true
}
return false
}
// ✅ 确保账号在调度前完成限流恢复与 schedulable 校正
async _ensureAccountReadyForScheduling(account, accountId, { sanitized = true } = {}) {
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus)
let rateLimitChecked = false
let stillLimited = false
let isSchedulable = this._isSchedulable(account.schedulable)
if (!isSchedulable) {
if (!hasRateLimitFlag) {
return { canUse: false, reason: 'not_schedulable' }
}
stillLimited = await this.isAccountRateLimited(accountId)
rateLimitChecked = true
if (stillLimited) {
return { canUse: false, reason: 'rate_limited' }
}
// 限流已恢复,矫正本地状态
if (sanitized) {
account.schedulable = true
} else {
account.schedulable = 'true'
}
isSchedulable = true
logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`)
}
if (hasRateLimitFlag) {
if (!rateLimitChecked) {
stillLimited = await this.isAccountRateLimited(accountId)
rateLimitChecked = true
}
if (stillLimited) {
return { canUse: false, reason: 'rate_limited' }
}
// 更新本地限流状态,避免重复判定
if (sanitized) {
account.rateLimitStatus = {
status: 'normal',
isRateLimited: false,
rateLimitedAt: null,
rateLimitResetAt: null,
minutesRemaining: 0
}
} else {
account.rateLimitStatus = 'normal'
account.rateLimitedAt = null
account.rateLimitResetAt = null
}
if (account.status === 'rateLimited') {
account.status = 'active'
}
}
if (!rateLimitChecked) {
stillLimited = await this.isAccountRateLimited(accountId)
if (stillLimited) {
return { canUse: false, reason: 'rate_limited' }
}
}
return { canUse: true }
}
// 🎯 统一调度OpenAI账号
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
try {
@@ -156,69 +48,31 @@ class UnifiedOpenAIScheduler {
accountType = 'openai'
}
const isActiveBoundAccount =
if (
boundAccount &&
(boundAccount.isActive === true || boundAccount.isActive === 'true') &&
boundAccount.status !== 'error' &&
boundAccount.status !== 'unauthorized'
if (isActiveBoundAccount) {
boundAccount.status !== 'error'
) {
// 检查是否被限流
if (accountType === 'openai') {
const readiness = await this._ensureAccountReadyForScheduling(
boundAccount,
boundAccount.id,
{ sanitized: false }
)
if (!readiness.canUse) {
const isRateLimited = readiness.reason === 'rate_limited'
const errorMsg = isRateLimited
? `Dedicated account ${boundAccount.name} is currently rate limited`
: `Dedicated account ${boundAccount.name} is not schedulable`
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = isRateLimited ? 429 : 403
throw error
throw new Error(errorMsg)
}
} else {
const hasRateLimitFlag = this._isRateLimited(boundAccount.rateLimitStatus)
if (hasRateLimitFlag) {
} else if (
accountType === 'openai-responses' &&
boundAccount.rateLimitStatus === 'limited'
) {
// OpenAI-Responses 账户的限流检查
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
boundAccount.id
)
if (!isRateLimitCleared) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 429 // Too Many Requests - 限流
throw error
}
// 限流已解除,刷新账户最新状态,确保后续调度信息准确
boundAccount = await openaiResponsesAccountService.getAccount(boundAccount.id)
if (!boundAccount) {
const errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found after rate limit reset`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 404
throw error
}
}
if (!this._isSchedulable(boundAccount.schedulable)) {
const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 403 // Forbidden - 调度被禁止
throw error
}
// ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) {
const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 403 // Forbidden - 订阅已过期
throw error
throw new Error(errorMsg)
}
}
@@ -234,9 +88,7 @@ class UnifiedOpenAIScheduler {
if (!modelSupported) {
const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = 400 // Bad Request - 请求参数错误
throw error
throw new Error(errorMsg)
}
}
@@ -257,22 +109,11 @@ class UnifiedOpenAIScheduler {
}
} else {
// 专属账户不可用时直接报错,不降级到共享池
let errorMsg
if (!boundAccount) {
errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found`
} else if (!(boundAccount.isActive === true || boundAccount.isActive === 'true')) {
errorMsg = `Dedicated account ${boundAccount.name} is not active`
} else if (boundAccount.status === 'unauthorized') {
errorMsg = `Dedicated account ${boundAccount.name} is unauthorized`
} else if (boundAccount.status === 'error') {
errorMsg = `Dedicated account ${boundAccount.name} is not available (error status)`
} else {
errorMsg = `Dedicated account ${boundAccount.name} is not available (inactive or forbidden)`
}
const errorMsg = boundAccount
? `Dedicated account ${boundAccount.name} is not available (inactive or error status)`
: `Dedicated account ${apiKeyData.openaiAccountId} not found`
logger.warn(`⚠️ ${errorMsg}`)
const error = new Error(errorMsg)
error.statusCode = boundAccount ? 403 : 404 // Forbidden 或 Not Found
throw error
throw new Error(errorMsg)
}
}
@@ -309,15 +150,11 @@ class UnifiedOpenAIScheduler {
if (availableAccounts.length === 0) {
// 提供更详细的错误信息
if (requestedModel) {
const error = new Error(
throw new Error(
`No available OpenAI accounts support the requested model: ${requestedModel}`
)
error.statusCode = 400 // Bad Request - 模型不支持
throw error
} else {
const error = new Error('No available OpenAI accounts')
error.statusCode = 402 // Payment Required - 资源耗尽
throw error
throw new Error('No available OpenAI accounts')
}
}
@@ -373,22 +210,10 @@ class UnifiedOpenAIScheduler {
if (
account.isActive &&
account.status !== 'error' &&
(account.accountType === 'shared' || !account.accountType) // 兼容旧数据
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
const accountId = account.id || account.accountId
const readiness = await this._ensureAccountReadyForScheduling(account, accountId, {
sanitized: true
})
if (!readiness.canUse) {
if (readiness.reason === 'rate_limited') {
logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 仍处于限流状态`)
} else {
logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 已被管理员禁用调度`)
}
continue
}
// 检查是否可调度
// 检查token是否过期并自动刷新
const isExpired = openaiAccountService.isTokenExpired(account)
@@ -425,6 +250,13 @@ class UnifiedOpenAIScheduler {
}
}
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id)
if (isRateLimited) {
logger.debug(`⏭️ Skipping OpenAI account ${account.name} - rate limited`)
continue
}
availableAccounts.push({
...account,
accountId: account.id,
@@ -442,42 +274,20 @@ class UnifiedOpenAIScheduler {
(account.isActive === true || account.isActive === 'true') &&
account.status !== 'error' &&
account.status !== 'rateLimited' &&
(account.accountType === 'shared' || !account.accountType)
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus)
const schedulable = this._isSchedulable(account.schedulable)
if (!schedulable && !hasRateLimitFlag) {
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`)
continue
}
let isRateLimitCleared = false
if (hasRateLimitFlag) {
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
// 检查并清除过期的限流状态
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
account.id
)
if (!isRateLimitCleared) {
// 如果仍然处于限流状态,跳过
if (account.rateLimitStatus === 'limited' && !isRateLimitCleared) {
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
continue
}
if (!schedulable) {
account.schedulable = 'true'
account.status = 'active'
logger.info(`✅ OpenAI-Responses账号 ${account.name} 已解除限流,恢复调度权限`)
}
}
// ⏰ 检查订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
logger.debug(
`⏭️ Skipping OpenAI-Responses account ${account.name} - subscription expired`
)
continue
}
// OpenAI-Responses 账户默认支持所有模型
// 因为它们是第三方兼容 API模型支持由第三方决定
@@ -514,37 +324,21 @@ class UnifiedOpenAIScheduler {
try {
if (accountType === 'openai') {
const account = await openaiAccountService.getAccount(accountId)
if (
!account ||
!account.isActive ||
account.status === 'error' ||
account.status === 'unauthorized'
) {
if (!account || !account.isActive || account.status === 'error') {
return false
}
const readiness = await this._ensureAccountReadyForScheduling(account, accountId, {
sanitized: false
})
if (!readiness.canUse) {
if (readiness.reason === 'rate_limited') {
logger.debug(
`🚫 OpenAI account ${accountId} still rate limited when checking availability`
)
} else {
// 检查是否可调度
if (!this._isSchedulable(account.schedulable)) {
logger.info(`🚫 OpenAI account ${accountId} is not schedulable`)
}
return false
}
return true
return !(await this.isAccountRateLimited(accountId))
} else if (accountType === 'openai-responses') {
const account = await openaiResponsesAccountService.getAccount(accountId)
if (
!account ||
(account.isActive !== true && account.isActive !== 'true') ||
account.status === 'error' ||
account.status === 'unauthorized'
account.status === 'error'
) {
return false
}
@@ -553,15 +347,10 @@ class UnifiedOpenAIScheduler {
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
return false
}
// ⏰ 检查订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
logger.info(`🚫 OpenAI-Responses account ${accountId} subscription expired`)
return false
}
// 检查并清除过期的限流状态
const isRateLimitCleared =
await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
return !this._isRateLimited(account.rateLimitStatus) || isRateLimitCleared
return account.rateLimitStatus !== 'limited' || isRateLimitCleared
}
return false
} catch (error) {
@@ -679,39 +468,6 @@ class UnifiedOpenAIScheduler {
}
}
// 🚫 标记账户为未授权状态
async markAccountUnauthorized(
accountId,
accountType,
sessionHash = null,
reason = 'OpenAI账号认证失败401错误'
) {
try {
if (accountType === 'openai') {
await openaiAccountService.markAccountUnauthorized(accountId, reason)
} else if (accountType === 'openai-responses') {
await openaiResponsesAccountService.markAccountUnauthorized(accountId, reason)
} else {
logger.warn(
`⚠️ Unsupported account type ${accountType} when marking unauthorized for account ${accountId}`
)
return { success: false }
}
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`,
error
)
throw error
}
}
// ✅ 移除账户的限流状态
async removeAccountRateLimit(accountId, accountType) {
try {
@@ -748,7 +504,7 @@ class UnifiedOpenAIScheduler {
return false
}
if (this._isRateLimited(account.rateLimitStatus)) {
if (account.rateLimitStatus === 'limited') {
// 如果有具体的重置时间,使用它
if (account.rateLimitResetAt) {
const resetTime = new Date(account.rateLimitResetAt).getTime()
@@ -786,15 +542,11 @@ class UnifiedOpenAIScheduler {
// 获取分组信息
const group = await accountGroupService.getGroup(groupId)
if (!group) {
const error = new Error(`Group ${groupId} not found`)
error.statusCode = 404 // Not Found - 资源不存在
throw error
throw new Error(`Group ${groupId} not found`)
}
if (group.platform !== 'openai') {
const error = new Error(`Group ${group.name} is not an OpenAI group`)
error.statusCode = 400 // Bad Request - 请求参数错误
throw error
throw new Error(`Group ${group.name} is not an OpenAI group`)
}
logger.info(`👥 Selecting account from OpenAI group: ${group.name}`)
@@ -829,48 +581,20 @@ class UnifiedOpenAIScheduler {
// 获取分组成员
const memberIds = await accountGroupService.getGroupMembers(groupId)
if (memberIds.length === 0) {
const error = new Error(`Group ${group.name} has no members`)
error.statusCode = 402 // Payment Required - 资源耗尽
throw error
throw new Error(`Group ${group.name} has no members`)
}
// 获取可用的分组成员账户(支持 OpenAI 和 OpenAI-Responses 两种类型)
// 获取可用的分组成员账户
const availableAccounts = []
for (const memberId of memberIds) {
// 首先尝试从 OpenAI 账户服务获取
let account = await openaiAccountService.getAccount(memberId)
let accountType = 'openai'
// 如果 OpenAI 账户不存在,尝试从 OpenAI-Responses 账户服务获取
if (!account) {
account = await openaiResponsesAccountService.getAccount(memberId)
accountType = 'openai-responses'
}
const account = await openaiAccountService.getAccount(memberId)
if (
account &&
(account.isActive === true || account.isActive === 'true') &&
account.status !== 'error'
account.isActive &&
account.status !== 'error' &&
this._isSchedulable(account.schedulable)
) {
const readiness = await this._ensureAccountReadyForScheduling(account, account.id, {
sanitized: false
})
if (!readiness.canUse) {
if (readiness.reason === 'rate_limited') {
logger.debug(
`⏭️ Skipping group member ${accountType} account ${account.name} - still rate limited`
)
} else {
logger.debug(
`⏭️ Skipping group member ${accountType} account ${account.name} - not schedulable`
)
}
continue
}
// 检查token是否过期仅对 OpenAI OAuth 账户检查)
if (accountType === 'openai') {
// 检查token是否过期
const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired && !account.refreshToken) {
logger.warn(
@@ -878,7 +602,6 @@ class UnifiedOpenAIScheduler {
)
continue
}
}
// 检查模型支持仅在明确设置了supportedModels且不为空时才检查
// 如果没有设置supportedModels或为空数组则支持所有模型
@@ -886,17 +609,23 @@ class UnifiedOpenAIScheduler {
const modelSupported = account.supportedModels.includes(requestedModel)
if (!modelSupported) {
logger.debug(
`⏭️ Skipping group member ${accountType} account ${account.name} - doesn't support model ${requestedModel}`
`⏭️ Skipping group member OpenAI account ${account.name} - doesn't support model ${requestedModel}`
)
continue
}
}
// 添加到可用账户列表
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id)
if (isRateLimited) {
logger.debug(`⏭️ Skipping group member OpenAI account ${account.name} - rate limited`)
continue
}
availableAccounts.push({
...account,
accountId: account.id,
accountType,
accountType: 'openai',
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
@@ -904,9 +633,7 @@ class UnifiedOpenAIScheduler {
}
if (availableAccounts.length === 0) {
const error = new Error(`No available accounts in group ${group.name}`)
error.statusCode = 402 // Payment Required - 资源耗尽
throw error
throw new Error(`No available accounts in group ${group.name}`)
}
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)

View File

@@ -18,17 +18,7 @@ class WebhookConfigService {
// 返回默认配置
return this.getDefaultConfig()
}
const storedConfig = JSON.parse(configStr)
const defaultConfig = this.getDefaultConfig()
// 合并默认通知类型,确保新增类型有默认值
storedConfig.notificationTypes = {
...defaultConfig.notificationTypes,
...(storedConfig.notificationTypes || {})
}
return storedConfig
return JSON.parse(configStr)
} catch (error) {
logger.error('获取webhook配置失败:', error)
return this.getDefaultConfig()
@@ -40,13 +30,6 @@ class WebhookConfigService {
*/
async saveConfig(config) {
try {
const defaultConfig = this.getDefaultConfig()
config.notificationTypes = {
...defaultConfig.notificationTypes,
...(config.notificationTypes || {})
}
// 验证配置
this.validateConfig(config)
@@ -79,7 +62,6 @@ class WebhookConfigService {
'feishu',
'slack',
'discord',
'telegram',
'custom',
'bark',
'smtp'
@@ -91,7 +73,7 @@ class WebhookConfigService {
}
// Bark和SMTP平台不使用标准URL
if (!['bark', 'smtp', 'telegram'].includes(platform.type)) {
if (platform.type !== 'bark' && platform.type !== 'smtp') {
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
@@ -135,43 +117,6 @@ class WebhookConfigService {
logger.warn('⚠️ Discord webhook URL格式可能不正确')
}
break
case 'telegram':
if (!platform.botToken) {
throw new Error('Telegram 平台必须提供机器人 Token')
}
if (!platform.chatId) {
throw new Error('Telegram 平台必须提供 Chat ID')
}
if (!platform.botToken.includes(':')) {
logger.warn('⚠️ Telegram 机器人 Token 格式可能不正确')
}
if (!/^[-\d]+$/.test(String(platform.chatId))) {
logger.warn('⚠️ Telegram Chat ID 应该是数字如为频道请确认已获取正确ID')
}
if (platform.apiBaseUrl) {
if (!this.isValidUrl(platform.apiBaseUrl)) {
throw new Error('Telegram API 基础地址格式无效')
}
const { protocol } = new URL(platform.apiBaseUrl)
if (!['http:', 'https:'].includes(protocol)) {
throw new Error('Telegram API 基础地址仅支持 http 或 https 协议')
}
}
if (platform.proxyUrl) {
if (!this.isValidUrl(platform.proxyUrl)) {
throw new Error('Telegram 代理地址格式无效')
}
const proxyProtocol = new URL(platform.proxyUrl).protocol
const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:']
if (!supportedProtocols.includes(proxyProtocol)) {
throw new Error('Telegram 代理仅支持 http/https/socks 协议')
}
}
break
case 'custom':
// 自定义webhook用户自行负责格式
break
@@ -329,7 +274,6 @@ class WebhookConfigService {
quotaWarning: true, // 配额警告
systemError: true, // 系统错误
securityAlert: true, // 安全警报
rateLimitRecovery: true, // 限流恢复
test: true // 测试通知
},
retrySettings: {

View File

@@ -1,8 +1,6 @@
const axios = require('axios')
const crypto = require('crypto')
const nodemailer = require('nodemailer')
const { HttpsProxyAgent } = require('https-proxy-agent')
const { SocksProxyAgent } = require('socks-proxy-agent')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
const { getISOStringWithTimezone } = require('../utils/dateHelper')
@@ -16,7 +14,6 @@ class WebhookService {
feishu: this.sendToFeishu.bind(this),
slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.bind(this),
telegram: this.sendToTelegram.bind(this),
custom: this.sendToCustom.bind(this),
bark: this.sendToBark.bind(this),
smtp: this.sendToSMTP.bind(this)
@@ -221,38 +218,6 @@ class WebhookService {
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Telegram Bot 通知
*/
async sendToTelegram(platform, type, data) {
if (!platform.botToken) {
throw new Error('缺少 Telegram 机器人 Token')
}
if (!platform.chatId) {
throw new Error('缺少 Telegram Chat ID')
}
const baseUrl = this.normalizeTelegramApiBase(platform.apiBaseUrl)
const apiUrl = `${baseUrl}/bot${platform.botToken}/sendMessage`
const payload = {
chat_id: platform.chatId,
text: this.formatMessageForTelegram(type, data),
disable_web_page_preview: true
}
const axiosOptions = this.buildTelegramAxiosOptions(platform)
const response = await this.sendHttpRequest(
apiUrl,
payload,
platform.timeout || 10000,
axiosOptions
)
if (!response || response.ok !== true) {
throw new Error(`Telegram API 错误: ${response?.description || '未知错误'}`)
}
}
/**
* Bark webhook
*/
@@ -328,17 +293,13 @@ class WebhookService {
/**
* 发送HTTP请求
*/
async sendHttpRequest(url, payload, timeout, axiosOptions = {}) {
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/2.0',
...(axiosOptions.headers || {})
}
async sendHttpRequest(url, payload, timeout) {
const response = await axios.post(url, payload, {
timeout,
...axiosOptions,
headers
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/2.0'
}
})
if (response.status < 200 || response.status >= 300) {
@@ -433,83 +394,6 @@ class WebhookService {
return `*${title}*\n${details}`
}
/**
* 规范化Telegram基础地址
*/
normalizeTelegramApiBase(baseUrl) {
const defaultBase = 'https://api.telegram.org'
if (!baseUrl) {
return defaultBase
}
try {
const parsed = new URL(baseUrl)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Telegram API 基础地址必须使用 http 或 https 协议')
}
// 移除结尾的 /
return parsed.href.replace(/\/$/, '')
} catch (error) {
logger.warn(`⚠️ Telegram API 基础地址无效,将使用默认值: ${error.message}`)
return defaultBase
}
}
/**
* 构建 Telegram 请求的 axios 选项(代理等)
*/
buildTelegramAxiosOptions(platform) {
const options = {}
if (platform.proxyUrl) {
try {
const proxyUrl = new URL(platform.proxyUrl)
const { protocol } = proxyUrl
if (protocol.startsWith('socks')) {
const agent = new SocksProxyAgent(proxyUrl.toString())
options.httpAgent = agent
options.httpsAgent = agent
options.proxy = false
} else if (protocol === 'http:' || protocol === 'https:') {
const agent = new HttpsProxyAgent(proxyUrl.toString())
options.httpAgent = agent
options.httpsAgent = agent
options.proxy = false
} else {
logger.warn(`⚠️ 不支持的Telegram代理协议: ${protocol}`)
}
} catch (error) {
logger.warn(`⚠️ Telegram代理配置无效将忽略: ${error.message}`)
}
}
return options
}
/**
* 格式化 Telegram 消息
*/
formatMessageForTelegram(type, data) {
const title = this.getNotificationTitle(type)
const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone })
const details = this.buildNotificationDetails(data)
const lines = [`${title}`, '服务: Claude Relay Service']
if (details.length > 0) {
lines.push('')
for (const detail of details) {
lines.push(`${detail.label}: ${detail.value}`)
}
}
lines.push('', `时间: ${timestamp}`)
return lines.join('\n')
}
/**
* 格式化Discord消息
*/

File diff suppressed because one or more lines are too long

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