first commit

This commit is contained in:
shaw
2025-07-14 18:14:13 +08:00
parent a96a372011
commit b1ca3f307e
31 changed files with 20046 additions and 21 deletions

55
.env.example Normal file
View File

@@ -0,0 +1,55 @@
# 🚀 Claude Relay Service Configuration
# 🌐 服务器配置
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
# 🔐 安全配置
JWT_SECRET=your-jwt-secret-here
ADMIN_SESSION_TIMEOUT=86400000
API_KEY_PREFIX=cr_
ENCRYPTION_KEY=your-encryption-key-here
# 📊 Redis 配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# 🎯 Claude API 配置
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🌐 代理配置
DEFAULT_PROXY_TIMEOUT=30000
MAX_PROXY_RETRIES=3
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
DEFAULT_REQUEST_LIMIT=1000
# 🚦 速率限制
RATE_LIMIT_WINDOW=60000
RATE_LIMIT_MAX_REQUESTS=100
# 📝 日志配置
LOG_LEVEL=info
LOG_MAX_SIZE=10m
LOG_MAX_FILES=5
# 🔧 系统配置
CLEANUP_INTERVAL=3600000
TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000
# 🎨 Web 界面配置
WEB_TITLE=Claude Relay Service
WEB_DESCRIPTION=Multi-account Claude API relay service with beautiful management interface
WEB_LOGO_URL=/assets/logo.png
# 🛠️ 开发配置
DEBUG=false
ENABLE_CORS=true
TRUST_PROXY=true

19
.eslintrc.js Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
env: {
node: true,
es2022: true,
},
extends: [
'eslint:recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'no-console': 'off',
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
},
};

229
.gitignore vendored Normal file
View File

@@ -0,0 +1,229 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.*
!.env.example
# Claude specific directories
.claude/
# Data directory (contains sensitive information)
data/
!data/.gitkeep
# Logs directory
logs/
*.log
startup.log
app.log
# Configuration files (may contain sensitive data)
config/config.js
!config/config.example.js
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary folders
tmp/
temp/
.tmp/
.temp/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Backup files
*.bak
*.backup
*~
# Archive files (unless specifically needed)
*.7z
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
# Application specific files
# JWT secrets and encryption keys
secrets/
keys/
certs/
# Database dumps
*.sql
*.db
*.sqlite
*.sqlite3
# Redis dumps
dump.rdb
appendonly.aof
# PM2 files
ecosystem.config.js
.pm2/
# Docker files (keep main ones, ignore volumes)
.docker/
docker-volumes/
# Monitoring data
prometheus/
grafana/
# Test files and coverage
test-results/
coverage/
.nyc_output/
# Documentation build
docs/build/
docs/dist/
# Deployment files
deploy/
.deploy/
# Package lock files (choose one)
# Uncomment the one you DON'T want to track
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# Local development files
.local/
local/
# Debug files
debug.log
error.log
access.log
# Session files
sessions/
# Upload directories
uploads/
files/
# Cache directories
.cache/
cache/
# Build artifacts
build/
dist/
out/
# Runtime files
*.sock

181
CLAUDE.md Normal file
View File

@@ -0,0 +1,181 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
这个文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导。
## 项目概述
Claude Relay Service 是一个功能完整的 Claude API 中转服务支持多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern与 Anthropic API 之间的中间件,提供认证、限流、监控等功能。
## 核心架构
### 关键架构概念
- **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
- **Token管理**: 自动监控OAuth token过期并刷新支持10秒提前刷新策略
- **代理支持**: 每个Claude账户支持独立代理配置OAuth token交换也通过代理进行
- **数据加密**: 敏感数据refreshToken, accessToken使用AES加密存储在Redis
### 主要服务组件
- **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
- **claudeAccountService.js**: Claude账户管理OAuth token刷新和账户选择
- **apiKeyService.js**: API Key管理验证、限流和使用统计
- **oauthHelper.js**: OAuth工具PKCE流程实现和代理支持
### 认证和代理流程
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集成
- **PKCE流程**: 完整的OAuth 2.0 PKCE实现支持代理
- **自动刷新**: 智能token过期检测和自动刷新机制
- **代理支持**: OAuth授权和token交换全程支持代理配置
- **安全存储**: claudeAiOauth数据加密存储包含accessToken、refreshToken、scopes
## 常用命令
### 基本开发命令
```bash
# 安装依赖和初始化
npm install
npm run setup # 生成配置和管理员凭据
npm run install:web # 安装Web界面依赖
# 开发和运行
npm run dev # 开发模式(热重载)
npm start # 生产模式
npm test # 运行测试
npm run lint # 代码检查
# Docker部署
docker-compose up -d # 推荐方式
docker-compose --profile monitoring up -d # 包含监控
# 服务管理
npm run service:start:daemon # 后台启动(推荐)
npm run service:status # 查看服务状态
npm run service:logs # 查看日志
npm run service:stop # 停止服务
# CLI管理工具
npm run cli admin # 管理员操作
npm run cli keys # API Key管理
npm run cli accounts # Claude账户管理
npm run cli status # 系统状态
```
### 开发环境配置
必须配置的环境变量:
- `JWT_SECRET`: JWT密钥32字符以上随机字符串
- `ENCRYPTION_KEY`: 数据加密密钥32字符固定长度
- `REDIS_HOST`: Redis主机地址默认localhost
- `REDIS_PORT`: Redis端口默认6379
- `REDIS_PASSWORD`: Redis密码可选
初始化命令:
```bash
cp config/config.example.js config/config.js
cp .env.example .env
npm run setup # 自动生成密钥并创建管理员账户
```
## Web界面功能
### OAuth账户添加流程
1. **基本信息和代理设置**: 配置账户名称、描述和代理参数
2. **OAuth授权**:
- 生成授权URL → 用户打开链接并登录Claude Code账号
- 授权后会显示Authorization Code → 复制并粘贴到输入框
- 系统自动交换token并创建账户
### 核心管理功能
- **实时仪表板**: 系统统计、账户状态、使用量监控
- **API Key管理**: 创建、配额设置、使用统计查看
- **Claude账户管理**: OAuth账户添加、代理配置、状态监控
- **系统日志**: 实时日志查看,多级别过滤
## 重要端点
### API转发端点
- `POST /api/v1/messages` - 主要消息处理端点(支持流式)
- `GET /api/v1/models` - 模型列表(兼容性)
- `GET /api/v1/usage` - 使用统计查询
- `GET /api/v1/key-info` - API Key信息
### OAuth管理端点
- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL含代理
- `POST /admin/claude-accounts/exchange-code` - 交换authorization code
- `POST /admin/claude-accounts` - 创建OAuth账户
### 系统端点
- `GET /health` - 健康检查
- `GET /web` - Web管理界面
- `GET /admin/dashboard` - 系统概览数据
## 故障排除
### OAuth相关问题
1. **代理配置错误**: 检查代理设置是否正确OAuth token交换也需要代理
2. **授权码无效**: 确保复制了完整的Authorization Code没有遗漏字符
3. **Token刷新失败**: 检查refreshToken有效性和代理配置
### 常见开发问题
1. **Redis连接失败**: 确认Redis服务运行检查连接配置
2. **管理员登录失败**: 检查init.json同步到Redis运行npm run setup
3. **API Key格式错误**: 确保使用cr_前缀格式
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
### 调试工具
- **日志系统**: Winston结构化日志支持不同级别
- **CLI工具**: 命令行状态查看和管理
- **Web界面**: 实时日志查看和系统监控
- **健康检查**: /health端点提供系统状态
## 开发最佳实践
### 代码修改原则
- 对现有文件进行修改时,首先检查代码库的现有模式和风格
- 尽可能重用现有的服务和工具函数,避免重复代码
- 遵循项目现有的错误处理和日志记录模式
- 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现)
### 测试和质量保证
- 运行 `npm run lint` 进行代码风格检查(使用 ESLint
- 运行 `npm test` 执行测试套件Jest + SuperTest 配置)
- 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status`
- 检查日志文件 `logs/claude-relay-*.log` 确认服务正常运行
- 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试
### 常见文件位置
- 核心服务逻辑:`src/services/` 目录
- 路由处理:`src/routes/` 目录
- 中间件:`src/middleware/` 目录
- 配置管理:`config/config.js`
- Redis 模型:`src/models/redis.js`
- 工具函数:`src/utils/` 目录
### 重要架构决策
- 所有敏感数据OAuth token、refreshToken都使用 AES 加密存储在 Redis
- 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
- API Key 使用哈希存储,支持 `cr_` 前缀格式
- 请求流程API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发
- 支持流式和非流式响应,客户端断开时自动清理资源
### 核心数据流和性能优化
- **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找
- **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据
- **多维度统计**: 支持按时间、模型、用户的实时使用统计
- **异步处理**: 非阻塞的统计记录和日志写入
- **原子操作**: Redis 管道操作确保数据一致性
### 安全和容错机制
- **多层加密**: API Key 哈希 + OAuth Token AES 加密
- **零信任验证**: 每个请求都需要完整的认证链
- **优雅降级**: Redis 连接失败时的回退机制
- **自动重试**: 指数退避重试策略和错误隔离
- **资源清理**: 客户端断开时的自动清理机制

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# 🐳 使用官方 Node.js 18 Alpine 镜像
FROM node:18-alpine
# 📋 设置标签
LABEL maintainer="claude-relay-service@example.com"
LABEL description="Claude Code API Relay Service"
LABEL version="1.0.0"
# 🔧 安装系统依赖
RUN apk add --no-cache \
curl \
dumb-init \
&& rm -rf /var/cache/apk/*
# 👤 创建应用用户
RUN addgroup -g 1001 -S nodejs && \
adduser -S claude -u 1001 -G nodejs
# 📁 设置工作目录
WORKDIR /app
# 📦 复制 package 文件
COPY package*.json ./
# 🔽 安装依赖 (生产环境)
RUN npm ci --only=production && \
npm cache clean --force
# 📋 复制应用代码
COPY --chown=claude:nodejs . .
# 📁 创建必要目录
RUN mkdir -p logs data temp && \
chown -R claude:nodejs logs data temp
# 🔐 切换到非 root 用户
USER claude
# 🌐 暴露端口
EXPOSE 3000
# 🏥 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# 🚀 启动应用
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "src/app.js"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Wesley Liddick
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

407
README.md Normal file
View File

@@ -0,0 +1,407 @@
# Claude Relay Service
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
**🔐 自己搭建的Claude API中转服务支持多账户管理**
[English](#english) • [中文文档](#中文文档)
</div>
---
## ⚠️ 重要提醒
**使用本项目前请仔细阅读:**
🚨 **服务条款风险**: 使用本项目可能违反Anthropic的服务条款。请在使用前仔细阅读Anthropic的用户协议使用本项目的一切风险由用户自行承担。
📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
---
## 🤔 这个项目适合你吗?
- 🌍 **地区限制**: 所在地区无法直接访问Claude Code服务
- 🔒 **隐私担忧**: 担心第三方镜像服务会记录或泄露你的对话内容?
- 👥 **成本分摊**: 想和朋友一起分摊Claude Code Max订阅费用
-**稳定性**: 第三方镜像站经常故障不稳定,影响效率
如果有以上困惑,那这个项目可能适合你。
### 适合的场景
**找朋友拼车**: 三五好友一起分摊Claude Code Max订阅Opus爽用
**隐私敏感**: 不想让第三方镜像看到你的对话内容
**技术折腾**: 有基本的技术基础,愿意自己搭建和维护
**稳定需求**: 需要长期稳定的Claude访问不想受制于镜像站
**地区受限**: 无法直接访问Claude官方服务
### 不适合的场景
**纯小白**: 完全不懂技术,连服务器都不会买
**偶尔使用**: 一个月用不了几次,没必要折腾
**注册问题**: 无法自行注册Claude账号
**支付问题**: 没有支付渠道订阅Claude Code
---
## 💭 为什么要自己搭?
### 现有镜像站可能的问题
- 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了
- 🐌 **性能不稳**: 用的人多了就慢,高峰期经常卡死
- 💰 **价格不透明**: 不知道实际成本
### 自建的好处
- 🔐 **数据安全**: 所有接口请求都只经过你自己的服务器直连Anthropic API
-**性能可控**: 就你们几个人用Max 200刀套餐基本上可以爽用Opus
- 💰 **成本透明**: 用了多少token一目了然按官方价格换算了具体费用
- 📊 **监控完整**: 使用情况、成本分析、性能监控全都有
---
## 🚀 核心功能
### 基础功能
-**多账户管理**: 可以添加多个Claude账户自动轮换
-**自定义API Key**: 给每个人分配独立的Key
-**使用统计**: 详细记录每个人用了多少token
### 高级功能
- 🔄 **智能切换**: 账户出问题自动换下一个
- 🚀 **性能优化**: 连接池、缓存,减少延迟
- 📊 **监控面板**: Web界面查看所有数据
- 🛡️ **安全控制**: 访问限制、速率控制
- 🌐 **代理支持**: 支持HTTP/SOCKS5代理
---
## 📋 部署要求
### 硬件要求(最低配置)
- **CPU**: 1核心就够了
- **内存**: 512MB建议1GB
- **硬盘**: 30GB可用空间
- **网络**: 能访问到Anthropic API建议使用US地区的机器
- **建议**: 2核4G的基本够了网络尽量选回国线路快一点的为了提高速度建议不要开代理或者设置服务器的IP直连
### 软件要求
- **Node.js** 18或更高版本
- **Redis** 6或更高版本
- **操作系统**: 建议Linux
### 费用估算
- **服务器**: 轻量云服务器一个月10-30块
- **Claude订阅**: 看你怎么分摊了
- **其他**: 基本没有了
---
## 🐳 最简单的部署方式Docker
如果你懒得折腾环境直接用Docker
```bash
# 1. 下载项目
git clone https://github.com/yourusername/claude-relay-service.git
cd claude-relay-service
# 2. 一键启动
docker-compose up -d
# 3. 查看是否启动成功
docker-compose ps
```
就这么简单,服务就跑起来了。
---
## 📦 手动部署(适合折腾党)
### 第一步:环境准备
**Ubuntu/Debian用户**
```bash
# 安装Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# 安装Redis
sudo apt update
sudo apt install redis-server
sudo systemctl start redis-server
```
**CentOS/RHEL用户**
```bash
# 安装Node.js
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
sudo yum install -y nodejs
# 安装Redis
sudo yum install redis
sudo systemctl start redis
```
### 第二步:下载和配置
```bash
# 下载项目
git clone https://github.com/yourusername/claude-relay-service.git
cd claude-relay-service
# 安装依赖
npm install
# 复制配置文件(重要!)
cp config/config.example.js config/config.js
cp .env.example .env
```
### 第三步:配置文件设置
**编辑 `.env` 文件:**
```bash
# 这两个密钥随便生成,但要记住
JWT_SECRET=你的超级秘密密钥
ENCRYPTION_KEY=32位的加密密钥随便写
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
```
**编辑 `config/config.js` 文件:**
```javascript
module.exports = {
server: {
port: 3000, // 服务端口,可以改
host: '0.0.0.0' // 不用改
},
redis: {
host: '127.0.0.1', // Redis地址
port: 6379 // Redis端口
},
// 其他配置保持默认就行
}
```
### 第四步:启动服务
```bash
# 初始化
npm run setup
# 启动服务
npm run service:start:daemon # 后台运行(推荐)
# 查看状态
npm run service:status
```
---
## 🎮 开始使用
### 1. 打开管理界面
浏览器访问:`http://你的服务器IP:3000/web`
默认管理员账号admin / admin123
### 2. 添加Claude账户
这一步比较关键需要OAuth授权
1. 点击「Claude账户」标签
2. 如果你在国内,先配置代理(重要!)
3. 点击「添加账户」
4. 点击「生成授权链接」,会打开一个新页面
5. 在新页面完成Claude登录和授权
6. 复制返回的Authorization Code
7. 粘贴到页面完成添加
**注意**: 如果你在国内,这一步可能需要科学上网。
### 3. 创建API Key
给每个使用者分配一个Key
1. 点击「API Keys」标签
2. 点击「创建新Key」
3. 给Key起个名字比如「张三的Key」
4. 设置使用限制(可选)
5. 保存记下生成的Key
### 4. 开始使用API
现在你可以用自己的服务替换官方API了
**原来的请求:**
```bash
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: 官方的key" \
-H "content-type: application/json" \
-d '{"model":"claude-3-sonnet-20240229","messages":[{"role":"user","content":"你好"}]}'
```
**现在的请求:**
```bash
curl http://你的域名:3000/api/v1/messages \
-H "x-api-key: cr_你创建的key" \
-H "content-type: application/json" \
-d '{"model":"claude-3-sonnet-20240229","messages":[{"role":"user","content":"你好"}]}'
```
就是把域名换一下API Key换成你自己生成的其他都一样。
---
## 🔧 日常维护
### 服务管理
```bash
# 查看服务状态
npm run service:status
# 查看日志
npm run service:logs
# 重启服务
npm run service:restart:daemon
# 停止服务
npm run service:stop
```
### 监控使用情况
- **Web界面**: `http://你的域名:3000/web` - 查看使用统计
- **健康检查**: `http://你的域名:3000/health` - 确认服务正常
- **日志文件**: `logs/` 目录下的各种日志文件
### 常见问题处理
**Redis连不上**
```bash
# 检查Redis是否启动
redis-cli ping
# 应该返回 PONG
```
**OAuth授权失败**
- 检查代理设置是否正确
- 确保能正常访问 claude.ai
- 清除浏览器缓存重试
**API请求失败**
- 检查API Key是否正确
- 查看日志文件找错误信息
- 确认Claude账户状态正常
---
## 🛠️ 高级玩法
### 设置代理(国内用户必看)
如果你在国内,需要配置代理才能正常使用:
```javascript
// 在账户配置中添加
{
"proxy": {
"type": "socks5", // 或者 "http"
"host": "127.0.0.1",
"port": 1080,
"username": "用户名", // 如果代理需要认证
"password": "密码" // 如果代理需要认证
}
}
```
### 命令行管理工具
懒得打开网页?用命令行:
```bash
# 查看所有API Key
npm run cli keys list
# 创建新Key
npm run cli keys create --name "测试Key" --limit 1000
# 查看账户状态
npm run cli accounts list
# 测试账户连接
npm run cli accounts test --id 账户ID
```
### 监控集成
如果你想要更专业的监控可以接入Prometheus
访问 `http://你的域名(或IP):3000/metrics` 获取指标数据。
---
## 💡 使用建议
### 账户管理
- **多账户**: 建议添加2-3个Claude账户防止单点故障
- **定期检查**: 每周看看账户状态,及时处理异常
- **备用方案**: 准备几个备用账户,关键时刻能顶上
### 成本控制
- **设置限额**: 给每个API Key设置合理的使用限制
- **监控支出**: 定期查看成本统计,控制预算
- **合理分配**: 根据使用频率分配配额
### 安全建议
- **定期备份**: 重要配置和数据要备份
- **监控日志**: 定期查看异常日志
- **更新密钥**: 定期更换JWT和加密密钥
---
## 🆘 遇到问题怎么办?
### 自助排查
1. **查看日志**: `logs/` 目录下的日志文件
2. **检查配置**: 确认配置文件设置正确
3. **测试连通性**: 用 curl 测试API是否正常
4. **重启服务**: 有时候重启一下就好了
### 寻求帮助
- **GitHub Issues**: 提交详细的错误信息
- **查看文档**: 仔细阅读错误信息和文档
- **社区讨论**: 看看其他人是否遇到类似问题
---
## 📄 许可证
本项目采用 [MIT许可证](LICENSE)。
---
<div align="center">
**⭐ 觉得有用的话给个Star呗这是对作者最大的鼓励**
**🤝 有问题欢迎提Issue有改进建议欢迎PR**
</div>

408
README_EN.md Normal file
View File

@@ -0,0 +1,408 @@
# Claude Relay Service
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
**🔐 Self-hosted Claude API relay service with multi-account management**
[English](#english) • [中文文档](#中文文档)
</div>
---
## ⚠️ Important Notice
**Please read carefully before using this project:**
🚨 **Terms of Service Risk**: Using this project may violate Anthropic's terms of service. Please carefully read Anthropic's user agreement before use. All risks from using this project are borne by the user.
📖 **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.
---
## 🤔 Is This Project Right for You?
- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region?
- 🔒 **Privacy Concerns**: Worried about third-party mirror services logging or leaking your conversation content?
- 👥 **Cost Sharing**: Want to share Claude Code Max subscription costs with friends?
-**Stability**: Third-party mirror sites often have outages and instability, affecting efficiency?
If you nodded yes, this project might be for you.
### Suitable Scenarios
**Cost Sharing with Friends**: 3-5 friends sharing Claude Code Max subscription, enjoying Opus freely
**Privacy Sensitive**: Don't want third parties to see your conversation content
**Technical Tinkering**: Have basic technical skills, willing to build and maintain yourself
**Stability Needs**: Need long-term stable Claude access, don't want to be restricted by mirror sites
**Regional Restrictions**: Cannot directly access Claude official service
### Unsuitable Scenarios
**Complete Beginner**: Don't understand technology at all, don't even know how to buy a server
**Occasional Use**: Use it only a few times a month, not worth the hassle
**Registration Issues**: Cannot register Claude account yourself
**Payment Issues**: No payment method to subscribe to Claude Code
---
## 💭 Why Build Your Own?
Honestly, there are quite a few Claude proxy services online now, but there are also many issues:
### Problems with Existing Proxies
- 🕵️ **Privacy Risk**: Your conversation content is seen clearly by others, forget about business secrets
- 🐌 **Performance Instability**: Slow when many people use it, often crashes during peak hours
- 💰 **Price Opacity**: Don't know the actual costs
### Benefits of Self-hosting
- 🔐 **Data Security**: All API requests only go through your own server, direct connection to Anthropic API
-**Controllable Performance**: Only a few of you using it, as fast as you want
- 💰 **Cost Transparency**: Clear view of how many tokens used, specific costs calculated at official prices
- 📊 **Complete Monitoring**: Usage statistics, cost analysis, performance monitoring all available
---
## 🚀 Core Features
### Basic Features
-**Multi-account Management**: Add multiple Claude accounts for automatic rotation
-**Custom API Keys**: Assign independent keys to each person
-**Usage Statistics**: Detailed records of how many tokens each person used
### Advanced Features
- 🔄 **Smart Switching**: Automatically switch to next account when one has issues
- 🚀 **Performance Optimization**: Connection pooling, caching to reduce latency
- 📊 **Monitoring Dashboard**: Web interface to view all data
- 🛡️ **Security Control**: Access restrictions, rate limiting
- 🌐 **Proxy Support**: Support for HTTP/SOCKS5 proxies
---
## 📋 Deployment Requirements
### Hardware Requirements (Minimum Configuration)
- **CPU**: 1 core is enough
- **Memory**: 512MB (1GB recommended)
- **Storage**: 30GB available space
- **Network**: Access to Anthropic API (recommend US region servers)
- **Suggestion**: 2 cores 4GB is basically enough, choose network with good return routes to your country (to improve speed, recommend not using proxy or setting server IP for direct connection)
### Software Requirements
- **Node.js** 18 or higher
- **Redis** 6 or higher
- **Operating System**: Linux recommended
### Cost Estimation
- **Server**: Light cloud server, 10-30 RMB per month
- **Claude Subscription**: Depends on how you share costs
- **Others**: Basically none
---
## 🐳 Simplest Deployment Method (Docker)
If you're too lazy to set up the environment, use Docker directly:
```bash
# 1. Download project
git clone https://github.com/yourusername/claude-relay-service.git
cd claude-relay-service
# 2. One-click start
docker-compose up -d
# 3. Check if started successfully
docker-compose ps
```
That simple, the service is running.
---
## 📦 Manual Deployment (For Tinkerers)
### Step 1: Environment Setup
**Ubuntu/Debian users:**
```bash
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install Redis
sudo apt update
sudo apt install redis-server
sudo systemctl start redis-server
```
**CentOS/RHEL users:**
```bash
# Install Node.js
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
sudo yum install -y nodejs
# Install Redis
sudo yum install redis
sudo systemctl start redis
```
### Step 2: Download and Configure
```bash
# Download project
git clone https://github.com/yourusername/claude-relay-service.git
cd claude-relay-service
# Install dependencies
npm install
# Copy configuration files (Important!)
cp config/config.example.js config/config.js
cp .env.example .env
```
### Step 3: Configuration File Setup
**Edit `.env` file:**
```bash
# Generate these two keys randomly, but remember them
JWT_SECRET=your-super-secret-key
ENCRYPTION_KEY=32-character-encryption-key-write-randomly
# Redis configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
```
**Edit `config/config.js` file:**
```javascript
module.exports = {
server: {
port: 3000, // Service port, can be changed
host: '0.0.0.0' // Don't change
},
redis: {
host: '127.0.0.1', // Redis address
port: 6379 // Redis port
},
// Keep other configurations as default
}
```
### Step 4: Start Service
```bash
# Initialize
npm run setup
# Start service
npm run service:start:daemon # Run in background (recommended)
# Check status
npm run service:status
```
---
## 🎮 Getting Started
### 1. Open Management Interface
Browser visit: `http://your-server-IP:3000/web`
Default admin account: admin / admin123
### 2. Add Claude Account
This step is quite important, requires OAuth authorization:
1. Click "Claude Accounts" tab
2. If you're in China, configure proxy first (Important!)
3. Click "Add Account"
4. Click "Generate Authorization Link", will open a new page
5. Complete Claude login and authorization in the new page
6. Copy the returned Authorization Code
7. Paste to page to complete addition
**Note**: If you're in China, this step may require VPN.
### 3. Create API Key
Assign a key to each user:
1. Click "API Keys" tab
2. Click "Create New Key"
3. Give the key a name, like "Zhang San's Key"
4. Set usage limits (optional)
5. Save, note down the generated key
### 4. Start Using API
Now you can replace the official API with your own service:
**Original request:**
```bash
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: official-key" \
-H "content-type: application/json" \
-d '{"model":"claude-3-sonnet-20240229","messages":[{"role":"user","content":"Hello"}]}'
```
**Current request:**
```bash
curl http://your-domain:3000/api/v1/messages \
-H "x-api-key: cr_your-created-key" \
-H "content-type: application/json" \
-d '{"model":"claude-3-sonnet-20240229","messages":[{"role":"user","content":"Hello"}]}'
```
Just change the domain and API Key to your own generated one, everything else is the same.
---
## 🔧 Daily Maintenance
### Service Management
```bash
# Check service status
npm run service:status
# View logs
npm run service:logs
# Restart service
npm run service:restart:daemon
# Stop service
npm run service:stop
```
### Monitor Usage
- **Web Interface**: `http://your-domain:3000/web` - View usage statistics
- **Health Check**: `http://your-domain:3000/health` - Confirm service is normal
- **Log Files**: Various log files in `logs/` directory
### Common Issue Resolution
**Can't connect to Redis?**
```bash
# Check if Redis is running
redis-cli ping
# Should return PONG
```
**OAuth authorization failed?**
- Check if proxy settings are correct
- Ensure normal access to claude.ai
- Clear browser cache and retry
**API request failed?**
- Check if API Key is correct
- View log files for error information
- Confirm Claude account status is normal
---
## 🛠️ Advanced Usage
### Setting Up Proxy (Must-read for Chinese Users)
If you're in China, you need to configure proxy to use normally:
```javascript
// Add in account configuration
{
"proxy": {
"type": "socks5", // or "http"
"host": "127.0.0.1",
"port": 1080,
"username": "username", // if proxy requires authentication
"password": "password" // if proxy requires authentication
}
}
```
### Command Line Management Tool
Too lazy to open webpage? Use command line:
```bash
# View all API Keys
npm run cli keys list
# Create new Key
npm run cli keys create --name "Test Key" --limit 1000
# View account status
npm run cli accounts list
# Test account connection
npm run cli accounts test --id account-ID
```
### Monitoring Integration
If you want more professional monitoring, you can integrate Prometheus:
Visit `http://your-domain(or-IP):3000/metrics` to get metrics data.
---
## 💡 Usage Recommendations
### Account Management
- **Multiple Accounts**: Recommend adding 2-3 Claude accounts to prevent single point of failure
- **Regular Checks**: Check account status weekly, handle exceptions promptly
- **Backup Plan**: Prepare several backup accounts that can step in during critical moments
### Cost Control
- **Set Limits**: Set reasonable usage limits for each API Key
- **Monitor Spending**: Regularly check cost statistics, control budget
- **Reasonable Allocation**: Allocate quotas based on usage frequency
### Security Recommendations
- **Regular Backups**: Back up important configurations and data
- **Monitor Logs**: Regularly check exception logs
- **Update Keys**: Regularly change JWT and encryption keys
---
## 🆘 What to Do When You Encounter Problems?
### Self-troubleshooting
1. **Check Logs**: Log files in `logs/` directory
2. **Check Configuration**: Confirm configuration files are set correctly
3. **Test Connectivity**: Use curl to test if API is normal
4. **Restart Service**: Sometimes restarting fixes it
### Seeking Help
- **GitHub Issues**: Submit detailed error information
- **Read Documentation**: Carefully read error messages and documentation
- **Community Discussion**: See if others have encountered similar problems
---
## 📄 License
This project uses the [MIT License](LICENSE).
---
<div align="center">
**⭐ If you find it useful, please give it a Star, this is the greatest encouragement to the author!**
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div>

772
cli/index.js Normal file
View File

@@ -0,0 +1,772 @@
#!/usr/bin/env node
const { Command } = require('commander');
const inquirer = require('inquirer');
const chalk = require('chalk');
const ora = require('ora');
const Table = require('table').table;
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const config = require('../config/config');
const redis = require('../src/models/redis');
const apiKeyService = require('../src/services/apiKeyService');
const claudeAccountService = require('../src/services/claudeAccountService');
const program = new Command();
// 🎨 样式
const styles = {
title: chalk.bold.blue,
success: chalk.green,
error: chalk.red,
warning: chalk.yellow,
info: chalk.cyan,
dim: chalk.dim
};
// 🔧 初始化
async function initialize() {
const spinner = ora('正在连接 Redis...').start();
try {
await redis.connect();
spinner.succeed('Redis 连接成功');
} catch (error) {
spinner.fail('Redis 连接失败');
console.error(styles.error(error.message));
process.exit(1);
}
}
// 🔐 管理员账户管理
program
.command('admin')
.description('管理员账户操作')
.action(async () => {
await initialize();
const { action } = await inquirer.prompt({
type: 'list',
name: 'action',
message: '选择操作:',
choices: [
{ name: '🔑 设置管理员密码', value: 'set-password' },
{ name: '👤 创建初始管理员', value: 'create-admin' },
{ name: '🔄 重置管理员密码', value: 'reset-password' },
{ name: '📊 查看管理员信息', value: 'view-admin' }
]
});
switch (action) {
case 'set-password':
await setAdminPassword();
break;
case 'create-admin':
await createInitialAdmin();
break;
case 'reset-password':
await resetAdminPassword();
break;
case 'view-admin':
await viewAdminInfo();
break;
}
await redis.disconnect();
});
// 🔑 API Key 管理
program
.command('keys')
.description('API Key 管理')
.action(async () => {
await initialize();
// 尝试兼容不同版本的inquirer
let prompt = inquirer.prompt || inquirer.default?.prompt || inquirer;
if (typeof prompt !== 'function') {
prompt = (await import('inquirer')).default;
}
const { action } = await prompt({
type: 'list',
name: 'action',
message: '选择操作:',
choices: [
{ name: '📋 列出所有 API Keys', value: 'list' },
{ name: '🔑 创建新的 API Key', value: 'create' },
{ name: '📝 更新 API Key', value: 'update' },
{ name: '🗑️ 删除 API Key', value: 'delete' },
{ name: '📊 查看使用统计', value: 'stats' },
{ name: '🧹 重置所有统计数据', value: 'reset-stats' }
]
});
switch (action) {
case 'list':
await listApiKeys();
break;
case 'create':
await createApiKey();
break;
case 'update':
await updateApiKey();
break;
case 'delete':
await deleteApiKey();
break;
case 'stats':
await viewApiKeyStats();
break;
case 'reset-stats':
await resetAllApiKeyStats();
break;
}
await redis.disconnect();
});
// 🏢 Claude 账户管理
program
.command('accounts')
.description('Claude 账户管理')
.action(async () => {
await initialize();
const { action } = await inquirer.prompt({
type: 'list',
name: 'action',
message: '选择操作:',
choices: [
{ name: '📋 列出所有账户', value: 'list' },
{ name: '🏢 创建新账户', value: 'create' },
{ name: '📝 更新账户', value: 'update' },
{ name: '🗑️ 删除账户', value: 'delete' },
{ name: '🔄 刷新 Token', value: 'refresh' },
{ name: '🧪 测试账户', value: 'test' }
]
});
switch (action) {
case 'list':
await listClaudeAccounts();
break;
case 'create':
await createClaudeAccount();
break;
case 'update':
await updateClaudeAccount();
break;
case 'delete':
await deleteClaudeAccount();
break;
case 'refresh':
await refreshAccountToken();
break;
case 'test':
await testClaudeAccount();
break;
}
await redis.disconnect();
});
// 🧹 重置统计数据命令
program
.command('reset-stats')
.description('重置所有API Key的统计数据')
.option('--force', '跳过确认直接重置')
.option('--debug', '显示详细的Redis键调试信息')
.action(async (options) => {
await initialize();
console.log(styles.title('\n🧹 重置所有API Key统计数据\n'));
// 如果启用调试显示当前Redis键
if (options.debug) {
console.log(styles.info('🔍 调试模式: 检查Redis中的实际键...\n'));
try {
const usageKeys = await redis.getClient().keys('usage:*');
const apiKeyKeys = await redis.getClient().keys('apikey:*');
console.log(styles.dim('API Key 键:'));
apiKeyKeys.forEach(key => console.log(` ${key}`));
console.log(styles.dim('\nUsage 键:'));
usageKeys.forEach(key => console.log(` ${key}`));
// 检查今日统计键
const today = new Date().toISOString().split('T')[0];
const dailyKeys = await redis.getClient().keys(`usage:daily:*:${today}`);
console.log(styles.dim(`\n今日统计键 (${today}):`));
dailyKeys.forEach(key => console.log(` ${key}`));
console.log('');
} catch (error) {
console.error(styles.error('调试信息获取失败:', error.message));
}
}
// 显示警告信息
console.log(styles.warning('⚠️ 警告: 此操作将删除所有API Key的使用统计数据!'));
console.log(styles.dim(' 包括: Token使用量、请求数量、每日/每月统计、最后使用时间等'));
console.log(styles.dim(' 此操作不可逆,请谨慎操作!\n'));
if (!options.force) {
console.log(styles.info('如需强制执行,请使用: npm run cli reset-stats -- --force\n'));
console.log(styles.error('操作已取消 - 请添加 --force 参数确认重置'));
await redis.disconnect();
return;
}
// 获取当前统计概览
const spinner = ora('正在获取当前统计数据...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
spinner.succeed('统计数据获取完成');
console.log(styles.info('\n📊 当前统计概览:'));
console.log(` API Keys 数量: ${apiKeys.length}`);
console.log(` 总 Token 使用量: ${totalTokens.toLocaleString()}`);
console.log(` 总请求数量: ${totalRequests.toLocaleString()}\n`);
// 执行重置操作
const resetSpinner = ora('正在重置所有API Key统计数据...').start();
const stats = await redis.resetAllUsageStats();
resetSpinner.succeed('所有统计数据重置完成');
// 显示重置结果
console.log(styles.success('\n✅ 重置操作完成!\n'));
console.log(styles.info('📊 重置详情:'));
console.log(` 重置的API Key数量: ${stats.resetApiKeys}`);
console.log(` 删除的总体统计: ${stats.deletedKeys}`);
console.log(` 删除的每日统计: ${stats.deletedDailyKeys}`);
console.log(` 删除的每月统计: ${stats.deletedMonthlyKeys}`);
console.log(styles.warning('\n💡 提示: API Key本身未被删除只是清空了使用统计数据'));
} catch (error) {
spinner.fail('重置操作失败');
console.error(styles.error(error.message));
}
await redis.disconnect();
});
// 📊 系统状态
program
.command('status')
.description('查看系统状态')
.action(async () => {
await initialize();
const spinner = ora('正在获取系统状态...').start();
try {
const [systemStats, apiKeys, accounts] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts()
]);
spinner.succeed('系统状态获取成功');
console.log(styles.title('\n📊 系统状态概览\n'));
const statusData = [
['项目', '数量', '状态'],
['API Keys', apiKeys.length, `${apiKeys.filter(k => k.isActive).length} 活跃`],
['Claude 账户', accounts.length, `${accounts.filter(a => a.isActive).length} 活跃`],
['Redis 连接', redis.isConnected ? '已连接' : '未连接', redis.isConnected ? '🟢' : '🔴'],
['运行时间', `${Math.floor(process.uptime() / 60)} 分钟`, '🕐']
];
console.log(table(statusData));
// 使用统计
const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
console.log(styles.title('\n📈 使用统计\n'));
console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`);
console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`);
} catch (error) {
spinner.fail('获取系统状态失败');
console.error(styles.error(error.message));
}
await redis.disconnect();
});
// 🧹 清理命令
program
.command('cleanup')
.description('清理过期数据')
.action(async () => {
await initialize();
const { confirm } = await inquirer.prompt({
type: 'confirm',
name: 'confirm',
message: '确定要清理过期数据吗?',
default: false
});
if (!confirm) {
console.log(styles.warning('操作已取消'));
await redis.disconnect();
return;
}
const spinner = ora('正在清理过期数据...').start();
try {
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
]);
await redis.cleanup();
spinner.succeed('清理完成');
console.log(`${styles.success('✅')} 清理了 ${expiredKeys} 个过期 API Key`);
console.log(`${styles.success('✅')} 重置了 ${errorAccounts} 个错误账户`);
} catch (error) {
spinner.fail('清理失败');
console.error(styles.error(error.message));
}
await redis.disconnect();
});
// 实现具体功能函数
async function createInitialAdmin() {
console.log(styles.title('\n🔐 创建初始管理员账户\n'));
const adminData = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: '用户名:',
default: 'admin',
validate: input => input.length >= 3 || '用户名至少3个字符'
},
{
type: 'password',
name: 'password',
message: '密码:',
validate: input => input.length >= 8 || '密码至少8个字符'
},
{
type: 'password',
name: 'confirmPassword',
message: '确认密码:',
validate: (input, answers) => input === answers.password || '密码不匹配'
}
]);
const spinner = ora('正在创建管理员账户...').start();
try {
const passwordHash = await bcrypt.hash(adminData.password, 12);
const credentials = {
username: adminData.username,
passwordHash,
createdAt: new Date().toISOString(),
id: crypto.randomBytes(16).toString('hex')
};
await redis.setSession('admin_credentials', credentials, 0); // 永不过期
spinner.succeed('管理员账户创建成功');
console.log(`${styles.success('✅')} 用户名: ${adminData.username}`);
console.log(`${styles.info('')} 请妥善保管登录凭据`);
} catch (error) {
spinner.fail('创建管理员账户失败');
console.error(styles.error(error.message));
}
}
async function setAdminPassword() {
console.log(styles.title('\n🔑 设置管理员密码\n'));
const passwordData = await inquirer.prompt([
{
type: 'password',
name: 'newPassword',
message: '新密码:',
validate: input => input.length >= 8 || '密码至少8个字符'
},
{
type: 'password',
name: 'confirmPassword',
message: '确认密码:',
validate: (input, answers) => input === answers.newPassword || '密码不匹配'
}
]);
const spinner = ora('正在更新密码...').start();
try {
const adminData = await redis.getSession('admin_credentials');
if (!adminData || Object.keys(adminData).length === 0) {
spinner.fail('未找到管理员账户');
console.log(styles.warning('请先创建初始管理员账户'));
return;
}
const passwordHash = await bcrypt.hash(passwordData.newPassword, 12);
adminData.passwordHash = passwordHash;
adminData.updatedAt = new Date().toISOString();
await redis.setSession('admin_credentials', adminData, 0);
spinner.succeed('密码更新成功');
console.log(`${styles.success('✅')} 管理员密码已更新`);
} catch (error) {
spinner.fail('密码更新失败');
console.error(styles.error(error.message));
}
}
async function listApiKeys() {
const spinner = ora('正在获取 API Keys...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
spinner.succeed(`找到 ${apiKeys.length} 个 API Key`);
if (apiKeys.length === 0) {
console.log(styles.warning('没有找到任何 API Key'));
return;
}
const tableData = [
['ID', '名称', '状态', 'Token使用', '请求数', '创建时间']
];
apiKeys.forEach(key => {
tableData.push([
key.id.substring(0, 8) + '...',
key.name,
key.isActive ? '🟢 活跃' : '🔴 停用',
key.usage?.total?.tokens?.toLocaleString() || '0',
key.usage?.total?.requests?.toLocaleString() || '0',
new Date(key.createdAt).toLocaleDateString()
]);
});
console.log('\n📋 API Keys 列表:\n');
console.log(table(tableData));
} catch (error) {
spinner.fail('获取 API Keys 失败');
console.error(styles.error(error.message));
}
}
async function createApiKey() {
console.log(styles.title('\n🔑 创建新的 API Key\n'));
const keyData = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'API Key 名称:',
validate: input => input.length > 0 || '名称不能为空'
},
{
type: 'input',
name: 'description',
message: '描述 (可选):'
},
{
type: 'number',
name: 'tokenLimit',
message: 'Token 限制 (0=无限制):',
default: 1000000
},
{
type: 'number',
name: 'requestLimit',
message: '请求限制 (0=无限制):',
default: 1000
}
]);
const spinner = ora('正在创建 API Key...').start();
try {
const newKey = await apiKeyService.generateApiKey(keyData);
spinner.succeed('API Key 创建成功');
console.log(`${styles.success('✅')} API Key: ${styles.warning(newKey.apiKey)}`);
console.log(`${styles.info('')} 请妥善保管此 API Key它只会显示一次`);
} catch (error) {
spinner.fail('创建 API Key 失败');
console.error(styles.error(error.message));
}
}
async function resetAllApiKeyStats() {
console.log(styles.title('\n🧹 重置所有API Key统计数据\n'));
// 显示警告信息
console.log(styles.warning('⚠️ 警告: 此操作将删除所有API Key的使用统计数据!'));
console.log(styles.dim(' 包括: Token使用量、请求数量、每日/每月统计、最后使用时间等'));
console.log(styles.dim(' 此操作不可逆,请谨慎操作!\n'));
// 第一次确认
const { firstConfirm } = await inquirer.prompt({
type: 'confirm',
name: 'firstConfirm',
message: '您确定要重置所有API Key的统计数据吗',
default: false
});
if (!firstConfirm) {
console.log(styles.info('操作已取消'));
return;
}
// 获取当前统计概览
const spinner = ora('正在获取当前统计数据...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
spinner.succeed('统计数据获取完成');
console.log(styles.info('\n📊 当前统计概览:'));
console.log(` API Keys 数量: ${apiKeys.length}`);
console.log(` 总 Token 使用量: ${totalTokens.toLocaleString()}`);
console.log(` 总请求数量: ${totalRequests.toLocaleString()}\n`);
// 第二次确认(需要输入"RESET"
const { confirmation } = await inquirer.prompt({
type: 'input',
name: 'confirmation',
message: '请输入 "RESET" 来确认重置操作:',
validate: input => input === 'RESET' || '请输入正确的确认文本 "RESET"'
});
if (confirmation !== 'RESET') {
console.log(styles.info('操作已取消'));
return;
}
// 执行重置操作
const resetSpinner = ora('正在重置所有API Key统计数据...').start();
const stats = await redis.resetAllUsageStats();
resetSpinner.succeed('所有统计数据重置完成');
// 显示重置结果
console.log(styles.success('\n✅ 重置操作完成!\n'));
console.log(styles.info('📊 重置详情:'));
console.log(` 重置的API Key数量: ${stats.resetApiKeys}`);
console.log(` 删除的总体统计: ${stats.deletedKeys}`);
console.log(` 删除的每日统计: ${stats.deletedDailyKeys}`);
console.log(` 删除的每月统计: ${stats.deletedMonthlyKeys}`);
console.log(styles.warning('\n💡 提示: API Key本身未被删除只是清空了使用统计数据'));
} catch (error) {
spinner.fail('重置操作失败');
console.error(styles.error(error.message));
}
}
async function viewApiKeyStats() {
console.log(styles.title('\n📊 API Key 使用统计\n'));
const spinner = ora('正在获取统计数据...').start();
try {
const apiKeys = await apiKeyService.getAllApiKeys();
if (apiKeys.length === 0) {
spinner.succeed('获取完成');
console.log(styles.warning('没有找到任何 API Key'));
return;
}
spinner.succeed(`找到 ${apiKeys.length} 个 API Key 的统计数据`);
const tableData = [
['名称', 'Token总量', '输入Token', '输出Token', '请求数', '最后使用']
];
let totalTokens = 0;
let totalRequests = 0;
apiKeys.forEach(key => {
const usage = key.usage?.total || {};
const tokens = usage.tokens || 0;
const inputTokens = usage.inputTokens || 0;
const outputTokens = usage.outputTokens || 0;
const requests = usage.requests || 0;
totalTokens += tokens;
totalRequests += requests;
tableData.push([
key.name,
tokens.toLocaleString(),
inputTokens.toLocaleString(),
outputTokens.toLocaleString(),
requests.toLocaleString(),
key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : '从未使用'
]);
});
console.log(table(tableData));
console.log(styles.info('\n📈 总计统计:'));
console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`);
console.log(`总请求数量: ${styles.success(totalRequests.toLocaleString())}`);
} catch (error) {
spinner.fail('获取统计数据失败');
console.error(styles.error(error.message));
}
}
async function updateApiKey() {
console.log(styles.title('\n📝 更新 API Key\n'));
console.log(styles.warning('功能开发中...'));
}
async function deleteApiKey() {
console.log(styles.title('\n🗑 删除 API Key\n'));
console.log(styles.warning('功能开发中...'));
}
async function resetAdminPassword() {
console.log(styles.title('\n🔄 重置管理员密码\n'));
console.log(styles.warning('功能开发中...'));
}
async function viewAdminInfo() {
console.log(styles.title('\n👤 管理员信息\n'));
const spinner = ora('正在获取管理员信息...').start();
try {
const adminData = await redis.getSession('admin_credentials');
if (!adminData || Object.keys(adminData).length === 0) {
spinner.fail('未找到管理员账户');
console.log(styles.warning('请先创建初始管理员账户'));
return;
}
spinner.succeed('管理员信息获取成功');
console.log(`用户名: ${styles.info(adminData.username)}`);
console.log(`创建时间: ${styles.dim(new Date(adminData.createdAt).toLocaleString())}`);
console.log(`最后登录: ${adminData.lastLogin ? styles.dim(new Date(adminData.lastLogin).toLocaleString()) : '从未登录'}`);
} catch (error) {
spinner.fail('获取管理员信息失败');
console.error(styles.error(error.message));
}
}
async function createClaudeAccount() {
console.log(styles.title('\n🏢 创建 Claude 账户\n'));
console.log(styles.warning('功能开发中... 请使用Web界面创建OAuth账户'));
}
async function updateClaudeAccount() {
console.log(styles.title('\n📝 更新 Claude 账户\n'));
console.log(styles.warning('功能开发中...'));
}
async function deleteClaudeAccount() {
console.log(styles.title('\n🗑 删除 Claude 账户\n'));
console.log(styles.warning('功能开发中...'));
}
async function refreshAccountToken() {
console.log(styles.title('\n🔄 刷新账户 Token\n'));
console.log(styles.warning('功能开发中...'));
}
async function testClaudeAccount() {
console.log(styles.title('\n🧪 测试 Claude 账户\n'));
console.log(styles.warning('功能开发中...'));
}
async function listClaudeAccounts() {
const spinner = ora('正在获取 Claude 账户...').start();
try {
const accounts = await claudeAccountService.getAllAccounts();
spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`);
if (accounts.length === 0) {
console.log(styles.warning('没有找到任何 Claude 账户'));
return;
}
const tableData = [
['ID', '名称', '邮箱', '状态', '代理', '最后使用']
];
accounts.forEach(account => {
tableData.push([
account.id.substring(0, 8) + '...',
account.name,
account.email || '-',
account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用',
account.proxy ? '🌐 是' : '-',
account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-'
]);
});
console.log('\n🏢 Claude 账户列表:\n');
console.log(table(tableData));
} catch (error) {
spinner.fail('获取 Claude 账户失败');
console.error(styles.error(error.message));
}
}
// 程序信息
program
.name('claude-relay-cli')
.description('Claude Relay Service 命令行管理工具')
.version('1.0.0');
// 解析命令行参数
program.parse();
// 如果没有提供命令,显示帮助
if (!process.argv.slice(2).length) {
console.log(styles.title('🚀 Claude Relay Service CLI\n'));
console.log('使用以下命令管理服务:\n');
console.log(' claude-relay-cli admin - 管理员账户操作');
console.log(' claude-relay-cli keys - API Key 管理 (包含重置统计数据)');
console.log(' claude-relay-cli accounts - Claude 账户管理');
console.log(' claude-relay-cli status - 查看系统状态');
console.log(' claude-relay-cli cleanup - 清理过期数据');
console.log(' claude-relay-cli reset-stats - 重置所有API Key统计数据');
console.log('\n使用 --help 查看详细帮助信息');
}

90
config/config.example.js Normal file
View File

@@ -0,0 +1,90 @@
const path = require('path');
require('dotenv').config();
const config = {
// 🌐 服务器配置
server: {
port: parseInt(process.env.PORT) || 3000,
host: process.env.HOST || '0.0.0.0',
nodeEnv: process.env.NODE_ENV || 'development',
trustProxy: process.env.TRUST_PROXY === 'true'
},
// 🔐 安全配置
security: {
jwtSecret: process.env.JWT_SECRET || 'CHANGE-THIS-JWT-SECRET-IN-PRODUCTION',
adminSessionTimeout: parseInt(process.env.ADMIN_SESSION_TIMEOUT) || 86400000, // 24小时
apiKeyPrefix: process.env.API_KEY_PREFIX || 'cr_',
encryptionKey: process.env.ENCRYPTION_KEY || 'CHANGE-THIS-32-CHARACTER-KEY-NOW'
},
// 📊 Redis配置
redis: {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || '',
db: parseInt(process.env.REDIS_DB) || 0,
connectTimeout: 10000,
commandTimeout: 5000,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 3,
lazyConnect: true
},
// 🎯 Claude API配置
claude: {
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01',
betaHeader: process.env.CLAUDE_BETA_HEADER || 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
},
// 🌐 代理配置
proxy: {
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3
},
// 📈 使用限制
limits: {
defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000,
defaultRequestLimit: parseInt(process.env.DEFAULT_REQUEST_LIMIT) || 1000
},
// 🚦 速率限制
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW) || 60000,
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
},
// 📝 日志配置
logging: {
level: process.env.LOG_LEVEL || 'info',
dirname: path.join(__dirname, '..', 'logs'),
maxSize: process.env.LOG_MAX_SIZE || '10m',
maxFiles: parseInt(process.env.LOG_MAX_FILES) || 5
},
// 🔧 系统配置
system: {
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000 // 1分钟
},
// 🎨 Web界面配置
web: {
title: process.env.WEB_TITLE || 'Claude Relay Service',
description: process.env.WEB_DESCRIPTION || 'Multi-account Claude API relay service with beautiful management interface',
logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png',
enableCors: process.env.ENABLE_CORS === 'true',
sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET'
},
// 🛠️ 开发配置
development: {
debug: process.env.DEBUG === 'true',
hotReload: process.env.HOT_RELOAD === 'true'
}
};
module.exports = config;

117
docker-compose.yml Normal file
View File

@@ -0,0 +1,117 @@
version: '3.8'
services:
# 🚀 Claude Relay Service
claude-relay:
build: .
container_name: claude-relay-service
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- REDIS_HOST=redis
- REDIS_PORT=6379
volumes:
- ./logs:/app/logs
- ./data:/app/data
depends_on:
- redis
networks:
- claude-relay-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
# 📊 Redis Database
redis:
image: redis:7-alpine
container_name: claude-relay-redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
- ./config/redis.conf:/usr/local/etc/redis/redis.conf:ro
command: redis-server /usr/local/etc/redis/redis.conf
networks:
- claude-relay-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# 📈 Redis Monitoring (Optional)
redis-commander:
image: rediscommander/redis-commander:latest
container_name: claude-relay-redis-web
restart: unless-stopped
ports:
- "${REDIS_WEB_PORT:-8081}:8081"
environment:
- REDIS_HOSTS=local:redis:6379
depends_on:
- redis
networks:
- claude-relay-network
profiles:
- monitoring
# 📊 Application Monitoring (Optional)
prometheus:
image: prom/prometheus:latest
container_name: claude-relay-prometheus
restart: unless-stopped
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--web.enable-lifecycle'
networks:
- claude-relay-network
profiles:
- monitoring
# 📈 Grafana Dashboard (Optional)
grafana:
image: grafana/grafana:latest
container_name: claude-relay-grafana
restart: unless-stopped
ports:
- "${GRAFANA_PORT:-3001}:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123}
volumes:
- grafana_data:/var/lib/grafana
- ./config/grafana:/etc/grafana/provisioning
depends_on:
- prometheus
networks:
- claude-relay-network
profiles:
- monitoring
volumes:
redis_data:
driver: local
prometheus_data:
driver: local
grafana_data:
driver: local
networks:
claude-relay-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

7555
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

72
package.json Normal file
View File

@@ -0,0 +1,72 @@
{
"name": "claude-relay-service",
"version": "1.0.0",
"description": "Claude Code API relay service with multi-account management and API key authentication",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"build:web": "cd web && npm run build",
"install:web": "cd web && npm install",
"setup": "node scripts/setup.js",
"cli": "node cli/index.js",
"service": "node scripts/manage.js",
"service:start": "node scripts/manage.js start",
"service:start:daemon": "node scripts/manage.js start -d",
"service:start:d": "node scripts/manage.js start -d",
"service:daemon": "node scripts/manage.js start -d",
"service:stop": "node scripts/manage.js stop",
"service:restart": "node scripts/manage.js restart",
"service:restart:daemon": "node scripts/manage.js restart -d",
"service:restart:d": "node scripts/manage.js restart -d",
"service:status": "node scripts/manage.js status",
"service:logs": "node scripts/manage.js logs",
"test": "jest",
"lint": "eslint src/**/*.js",
"docker:build": "docker build -t claude-relay-service .",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
},
"dependencies": {
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.2.15",
"ioredis": "^5.3.2",
"morgan": "^1.10.0",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
"socks-proxy-agent": "^8.0.2",
"table": "^6.8.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1"
},
"devDependencies": {
"@types/node": "^20.8.9",
"eslint": "^8.53.0",
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"supertest": "^6.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"claude",
"api",
"proxy",
"relay",
"claude-code",
"anthropic"
],
"author": "Claude Relay Service",
"license": "MIT"
}

335
scripts/manage.js Normal file
View File

@@ -0,0 +1,335 @@
#!/usr/bin/env node
const { spawn, exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const process = require('process');
const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid');
const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log');
const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log');
const APP_FILE = path.join(__dirname, '..', 'src', 'app.js');
class ServiceManager {
constructor() {
this.ensureLogDir();
}
ensureLogDir() {
const logDir = path.dirname(LOG_FILE);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
}
getPid() {
try {
if (fs.existsSync(PID_FILE)) {
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim());
return pid;
}
} catch (error) {
console.error('读取PID文件失败:', error.message);
}
return null;
}
isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
writePid(pid) {
try {
fs.writeFileSync(PID_FILE, pid.toString());
console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`);
} catch (error) {
console.error('写入PID文件失败:', error.message);
}
}
removePidFile() {
try {
if (fs.existsSync(PID_FILE)) {
fs.unlinkSync(PID_FILE);
console.log('🗑️ 已清理PID文件');
}
} catch (error) {
console.error('清理PID文件失败:', error.message);
}
}
getStatus() {
const pid = this.getPid();
if (pid && this.isProcessRunning(pid)) {
return { running: true, pid };
}
return { running: false, pid: null };
}
start(daemon = false) {
const status = this.getStatus();
if (status.running) {
console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`);
return false;
}
console.log('🚀 启动 Claude Relay Service...');
if (daemon) {
// 后台运行模式 - 使用nohup实现真正的后台运行
const { exec } = require('child_process');
const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!`;
exec(command, (error, stdout, stderr) => {
if (error) {
console.error('❌ 后台启动失败:', error.message);
return;
}
const pid = parseInt(stdout.trim());
if (pid && !isNaN(pid)) {
this.writePid(pid);
console.log(`🔄 服务已在后台启动 (PID: ${pid})`);
console.log(`📝 日志文件: ${LOG_FILE}`);
console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`);
console.log('✅ 终端现在可以安全关闭');
} else {
console.error('❌ 无法获取进程ID');
}
});
// 给exec一点时间执行
setTimeout(() => {
process.exit(0);
}, 1000);
} else {
// 前台运行模式
const child = spawn('node', [APP_FILE], {
stdio: 'inherit'
});
console.log(`🔄 服务已启动 (PID: ${child.pid})`);
this.writePid(child.pid);
// 监听进程退出
child.on('exit', (code, signal) => {
this.removePidFile();
if (code !== 0) {
console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`);
}
});
child.on('error', (error) => {
console.error('❌ 启动失败:', error.message);
this.removePidFile();
});
}
return true;
}
stop() {
const status = this.getStatus();
if (!status.running) {
console.log('⚠️ 服务未在运行');
this.removePidFile(); // 清理可能存在的过期PID文件
return false;
}
console.log(`🛑 停止服务 (PID: ${status.pid})...`);
try {
// 优雅关闭先发送SIGTERM
process.kill(status.pid, 'SIGTERM');
// 等待进程退出
let attempts = 0;
const maxAttempts = 30; // 30秒超时
const checkExit = setInterval(() => {
attempts++;
if (!this.isProcessRunning(status.pid)) {
clearInterval(checkExit);
console.log('✅ 服务已停止');
this.removePidFile();
return;
}
if (attempts >= maxAttempts) {
clearInterval(checkExit);
console.log('⚠️ 优雅关闭超时,强制终止进程...');
try {
process.kill(status.pid, 'SIGKILL');
console.log('✅ 服务已强制停止');
} catch (error) {
console.error('❌ 强制停止失败:', error.message);
}
this.removePidFile();
}
}, 1000);
} catch (error) {
console.error('❌ 停止服务失败:', error.message);
this.removePidFile();
return false;
}
return true;
}
restart(daemon = false) {
console.log('🔄 重启服务...');
const stopResult = this.stop();
// 等待停止完成
setTimeout(() => {
this.start(daemon);
}, 2000);
return true;
}
status() {
const status = this.getStatus();
if (status.running) {
console.log(`✅ 服务正在运行 (PID: ${status.pid})`);
// 显示进程信息
exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => {
if (!error && stdout.trim()) {
console.log('\n📊 进程信息:');
console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND');
console.log(stdout.trim());
}
});
} else {
console.log('❌ 服务未运行');
}
return status.running;
}
logs(lines = 50) {
console.log(`📖 最近 ${lines} 行日志:\n`);
exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => {
if (error) {
console.error('读取日志失败:', error.message);
return;
}
console.log(stdout);
});
}
help() {
console.log(`
🔧 Claude Relay Service 进程管理器
用法: npm run service <command> [options]
重要提示:
如果要传递参数请在npm run命令中使用 -- 分隔符
npm run service <command> -- [options]
命令:
start [-d|--daemon] 启动服务 (-d: 后台运行)
stop 停止服务
restart [-d|--daemon] 重启服务 (-d: 后台运行)
status 查看服务状态
logs [lines] 查看日志 (默认50行)
help 显示帮助信息
命令缩写:
s, start 启动服务
r, restart 重启服务
st, status 查看状态
l, log, logs 查看日志
halt, stop 停止服务
h, help 显示帮助
示例:
npm run service start # 前台启动
npm run service -- start -d # 后台启动(正确方式)
npm run service:start:d # 后台启动(推荐快捷方式)
npm run service:daemon # 后台启动(推荐快捷方式)
npm run service stop # 停止服务
npm run service -- restart -d # 后台重启(正确方式)
npm run service:restart:d # 后台重启(推荐快捷方式)
npm run service status # 查看状态
npm run service logs # 查看日志
npm run service -- logs 100 # 查看最近100行日志
推荐的快捷方式(无需 -- 分隔符):
npm run service:start:d # 等同于 npm run service -- start -d
npm run service:restart:d # 等同于 npm run service -- restart -d
npm run service:daemon # 等同于 npm run service -- start -d
直接使用脚本(推荐):
node scripts/manage.js start -d # 后台启动
node scripts/manage.js restart -d # 后台重启
node scripts/manage.js status # 查看状态
node scripts/manage.js logs 100 # 查看最近100行日志
文件位置:
PID文件: ${PID_FILE}
日志文件: ${LOG_FILE}
错误日志: ${ERROR_LOG_FILE}
`);
}
}
// 主程序
function main() {
const manager = new ServiceManager();
const args = process.argv.slice(2);
const command = args[0];
const isDaemon = args.includes('-d') || args.includes('--daemon');
switch (command) {
case 'start':
case 's':
manager.start(isDaemon);
break;
case 'stop':
case 'halt':
manager.stop();
break;
case 'restart':
case 'r':
manager.restart(isDaemon);
break;
case 'status':
case 'st':
manager.status();
break;
case 'logs':
case 'log':
case 'l':
const lines = parseInt(args[1]) || 50;
manager.logs(lines);
break;
case 'help':
case '--help':
case '-h':
case 'h':
manager.help();
break;
default:
console.log('❌ 未知命令:', command);
manager.help();
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = ServiceManager;

107
scripts/setup.js Normal file
View File

@@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const chalk = require('chalk');
const ora = require('ora');
const config = require('../config/config');
async function setup() {
console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n'));
const spinner = ora('正在进行初始化设置...').start();
try {
// 1. 创建必要目录
const directories = [
'logs',
'data',
'temp'
];
directories.forEach(dir => {
const dirPath = path.join(__dirname, '..', dir);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
});
// 2. 生成环境配置文件
if (!fs.existsSync(path.join(__dirname, '..', '.env'))) {
const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8');
// 生成随机密钥
const jwtSecret = crypto.randomBytes(64).toString('hex');
const encryptionKey = crypto.randomBytes(32).toString('hex');
const envContent = envTemplate
.replace('your-jwt-secret-here', jwtSecret)
.replace('your-encryption-key-here', encryptionKey);
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
}
// 3. 生成随机管理员凭据
const adminUsername = `cr_admin_${crypto.randomBytes(4).toString('hex')}`;
const adminPassword = crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
// 4. 创建初始化完成标记文件
const initData = {
initializedAt: new Date().toISOString(),
adminUsername,
adminPassword,
version: '1.0.0'
};
fs.writeFileSync(
path.join(__dirname, '..', 'data', 'init.json'),
JSON.stringify(initData, null, 2)
);
spinner.succeed('初始化设置完成');
console.log(chalk.green('\n✅ 设置完成!\n'));
console.log(chalk.yellow('📋 重要信息:\n'));
console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`);
console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`);
console.log(chalk.red('\n⚠ 请立即保存这些凭据!首次登录后建议修改密码。\n'));
console.log(chalk.blue('🚀 启动服务:\n'));
console.log(' npm start - 启动生产服务');
console.log(' npm run dev - 启动开发服务');
console.log(' npm run cli admin - 管理员CLI工具\n');
console.log(chalk.blue('🌐 访问地址:\n'));
console.log(` Web管理界面: http://localhost:${config.server.port}/web`);
console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`);
console.log(` 健康检查: http://localhost:${config.server.port}/health\n`);
} catch (error) {
spinner.fail('初始化设置失败');
console.error(chalk.red('❌ 错误:'), error.message);
process.exit(1);
}
}
// 检查是否已初始化
function checkInitialized() {
const initFile = path.join(__dirname, '..', 'data', 'init.json');
if (fs.existsSync(initFile)) {
const initData = JSON.parse(fs.readFileSync(initFile, 'utf8'));
console.log(chalk.yellow('⚠️ 服务已经初始化过了!'));
console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`);
console.log(` 管理员用户名: ${initData.adminUsername}`);
console.log('\n如需重新初始化请删除 data/init.json 文件。');
return true;
}
return false;
}
if (require.main === module) {
if (!checkInitialized()) {
setup();
}
}
module.exports = { setup, checkInitialized };

367
src/app.js Normal file
View File

@@ -0,0 +1,367 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const config = require('../config/config');
const logger = require('./utils/logger');
const redis = require('./models/redis');
const pricingService = require('./services/pricingService');
// Import routes
const apiRoutes = require('./routes/api');
const adminRoutes = require('./routes/admin');
const webRoutes = require('./routes/web');
// Import middleware
const {
corsMiddleware,
requestLogger,
securityMiddleware,
errorHandler,
globalRateLimit,
requestSizeLimit
} = require('./middleware/auth');
class Application {
constructor() {
this.app = express();
this.server = null;
}
async initialize() {
try {
// 🔗 连接Redis
logger.info('🔄 Connecting to Redis...');
await redis.connect();
logger.success('✅ Redis connected successfully');
// 💰 初始化价格服务
logger.info('🔄 Initializing pricing service...');
await pricingService.initialize();
// 🔧 初始化管理员凭据
logger.info('🔄 Initializing admin credentials...');
await this.initializeAdmin();
// 🛡️ 安全中间件
this.app.use(helmet({
contentSecurityPolicy: false, // 允许内联样式和脚本
crossOriginEmbedderPolicy: false
}));
// 🌐 CORS
if (config.web.enableCors) {
this.app.use(cors());
} else {
this.app.use(corsMiddleware);
}
// 📦 压缩
this.app.use(compression());
// 🚦 全局速率限制(仅在生产环境启用)
if (process.env.NODE_ENV === 'production') {
this.app.use(globalRateLimit);
}
// 📏 请求大小限制
this.app.use(requestSizeLimit);
// 📝 请求日志使用自定义logger而不是morgan
this.app.use(requestLogger);
// 🔧 基础中间件
this.app.use(express.json({
limit: '10mb',
verify: (req, res, buf, encoding) => {
// 验证JSON格式
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
throw new Error('Invalid JSON: empty body');
}
}
}));
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }));
this.app.use(securityMiddleware);
// 🎯 信任代理
if (config.server.trustProxy) {
this.app.set('trust proxy', 1);
}
// 🛣️ 路由
this.app.use('/api', apiRoutes);
this.app.use('/admin', adminRoutes);
this.app.use('/web', webRoutes);
// 🏠 根路径重定向到管理界面
this.app.get('/', (req, res) => {
res.redirect('/web');
});
// 🏥 增强的健康检查端点
this.app.get('/health', async (req, res) => {
try {
const timer = logger.timer('health-check');
// 检查各个组件健康状态
const [redisHealth, loggerHealth] = await Promise.all([
this.checkRedisHealth(),
this.checkLoggerHealth()
]);
const memory = process.memoryUsage();
const health = {
status: 'healthy',
service: 'claude-relay-service',
version: '1.0.0',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: {
used: Math.round(memory.heapUsed / 1024 / 1024) + 'MB',
total: Math.round(memory.heapTotal / 1024 / 1024) + 'MB',
external: Math.round(memory.external / 1024 / 1024) + 'MB'
},
components: {
redis: redisHealth,
logger: loggerHealth
},
stats: logger.getStats()
};
timer.end('completed');
res.json(health);
} catch (error) {
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// 📊 指标端点
this.app.get('/metrics', async (req, res) => {
try {
const stats = await redis.getSystemStats();
const metrics = {
...stats,
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
};
res.json(metrics);
} catch (error) {
logger.error('❌ Metrics collection failed:', error);
res.status(500).json({ error: 'Failed to collect metrics' });
}
});
// 🚫 404 处理
this.app.use('*', (req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.originalUrl} not found`,
timestamp: new Date().toISOString()
});
});
// 🚨 错误处理
this.app.use(errorHandler);
logger.success('✅ Application initialized successfully');
} catch (error) {
logger.error('💥 Application initialization failed:', error);
throw error;
}
}
// 🔧 初始化管理员凭据
async initializeAdmin() {
try {
// 检查Redis中是否已存在管理员凭据
const existingAdmin = await redis.getSession('admin_credentials');
if (!existingAdmin || Object.keys(existingAdmin).length === 0) {
// 尝试从初始化文件读取
const initFilePath = path.join(__dirname, '..', 'data', 'init.json');
if (fs.existsSync(initFilePath)) {
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'));
// 将明文密码哈希化
const saltRounds = 10;
const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds);
// 存储到Redis
const adminCredentials = {
username: initData.adminUsername,
passwordHash: passwordHash,
createdAt: new Date().toISOString(),
lastLogin: null
};
await redis.setSession('admin_credentials', adminCredentials);
logger.success('✅ Admin credentials initialized from setup data');
} else {
logger.warn('⚠️ No admin credentials found. Please run npm run setup first.');
}
} else {
logger.info(' Admin credentials already exist in Redis');
}
} catch (error) {
logger.error('❌ Failed to initialize admin credentials:', { error: error.message, stack: error.stack });
throw error;
}
}
// 🔍 Redis健康检查
async checkRedisHealth() {
try {
const start = Date.now();
await redis.getClient().ping();
const latency = Date.now() - start;
return {
status: 'healthy',
connected: redis.isConnected,
latency: `${latency}ms`
};
} catch (error) {
return {
status: 'unhealthy',
connected: false,
error: error.message
};
}
}
// 📝 Logger健康检查
async checkLoggerHealth() {
try {
const health = logger.healthCheck();
return {
status: health.healthy ? 'healthy' : 'unhealthy',
...health
};
} catch (error) {
return {
status: 'unhealthy',
error: error.message
};
}
}
async start() {
try {
await this.initialize();
this.server = this.app.listen(config.server.port, config.server.host, () => {
logger.start(`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`);
logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/web`);
logger.info(`🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages`);
logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`);
logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`);
logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`);
});
// 🔄 定期清理任务
this.startCleanupTasks();
// 🛑 优雅关闭
this.setupGracefulShutdown();
} catch (error) {
logger.error('💥 Failed to start server:', error);
process.exit(1);
}
}
startCleanupTasks() {
// 🧹 每小时清理一次过期数据
setInterval(async () => {
try {
logger.info('🧹 Starting scheduled cleanup...');
const apiKeyService = require('./services/apiKeyService');
const claudeAccountService = require('./services/claudeAccountService');
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
]);
await redis.cleanup();
logger.success(`🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset`);
} catch (error) {
logger.error('❌ Cleanup task failed:', error);
}
}, config.system.cleanupInterval);
logger.info(`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`);
}
setupGracefulShutdown() {
const shutdown = async (signal) => {
logger.info(`🛑 Received ${signal}, starting graceful shutdown...`);
if (this.server) {
this.server.close(async () => {
logger.info('🚪 HTTP server closed');
try {
await redis.disconnect();
logger.info('👋 Redis disconnected');
} catch (error) {
logger.error('❌ Error disconnecting Redis:', error);
}
logger.success('✅ Graceful shutdown completed');
process.exit(0);
});
// 强制关闭超时
setTimeout(() => {
logger.warn('⚠️ Forced shutdown due to timeout');
process.exit(1);
}, 10000);
} else {
process.exit(0);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// 处理未捕获异常
process.on('uncaughtException', (error) => {
logger.error('💥 Uncaught exception:', error);
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason);
shutdown('unhandledRejection');
});
}
}
// 启动应用
if (require.main === module) {
const app = new Application();
app.start().catch((error) => {
logger.error('💥 Application startup failed:', error);
process.exit(1);
});
}
module.exports = Application;

532
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,532 @@
const apiKeyService = require('../services/apiKeyService');
const logger = require('../utils/logger');
const redis = require('../models/redis');
const { RateLimiterRedis } = require('rate-limiter-flexible');
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
const startTime = Date.now();
try {
// 安全提取API Key支持多种格式
const apiKey = req.headers['x-api-key'] ||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.headers['api-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 header or Authorization header'
});
}
// 基本API Key格式验证
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`);
return res.status(401).json({
error: 'Invalid API key format',
message: 'API key format is invalid'
});
}
// 验证API Key带缓存优化
const validation = await apiKeyService.validateApiKey(apiKey);
if (!validation.valid) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`);
return res.status(401).json({
error: 'Invalid API key',
message: validation.error
});
}
// 检查速率限制(优化:只在验证成功后检查)
const rateLimitResult = await apiKeyService.checkRateLimit(validation.keyData.id);
if (!rateLimitResult.allowed) {
logger.security(`🚦 Rate limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name})`);
return res.status(429).json({
error: 'Rate limit exceeded',
message: `Too many requests. Limit: ${rateLimitResult.limit} requests per minute`,
resetTime: rateLimitResult.resetTime,
retryAfter: rateLimitResult.resetTime
});
}
// 设置标准速率限制响应头
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
res.setHeader('X-RateLimit-Remaining', Math.max(0, rateLimitResult.limit - rateLimitResult.current));
res.setHeader('X-RateLimit-Reset', rateLimitResult.resetTime);
res.setHeader('X-RateLimit-Policy', `${rateLimitResult.limit};w=60`);
// 将验证信息添加到请求对象(只包含必要信息)
req.apiKey = {
id: validation.keyData.id,
name: validation.keyData.name,
tokenLimit: validation.keyData.tokenLimit,
requestLimit: validation.keyData.requestLimit,
claudeAccountId: validation.keyData.claudeAccountId
};
req.usage = validation.keyData.usage;
const authDuration = Date.now() - startTime;
logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`);
next();
} catch (error) {
const authDuration = Date.now() - startTime;
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
error: error.message,
stack: error.stack,
ip: req.ip,
userAgent: req.get('User-Agent'),
url: req.originalUrl
});
res.status(500).json({
error: 'Authentication error',
message: 'Internal server error during authentication'
});
}
};
// 🛡️ 管理员验证中间件(优化版)
const authenticateAdmin = async (req, res, next) => {
const startTime = Date.now();
try {
// 安全提取token支持多种方式
const token = req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
req.cookies?.adminToken ||
req.headers['x-admin-token'];
if (!token) {
logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`);
return res.status(401).json({
error: 'Missing admin token',
message: 'Please provide an admin token'
});
}
// 基本token格式验证
if (typeof token !== 'string' || token.length < 32 || token.length > 512) {
logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`);
return res.status(401).json({
error: 'Invalid admin token format',
message: 'Admin token format is invalid'
});
}
// 获取管理员会话(带超时处理)
const adminSession = await Promise.race([
redis.getSession(token),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Session lookup timeout')), 5000)
)
]);
if (!adminSession || Object.keys(adminSession).length === 0) {
logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`);
return res.status(401).json({
error: 'Invalid admin token',
message: 'Invalid or expired admin session'
});
}
// 检查会话活跃性(可选:检查最后活动时间)
const now = new Date();
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime);
const inactiveDuration = now - lastActivity;
const maxInactivity = 24 * 60 * 60 * 1000; // 24小时
if (inactiveDuration > maxInactivity) {
logger.security(`🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}`);
await redis.deleteSession(token); // 清理过期会话
return res.status(401).json({
error: 'Session expired',
message: 'Admin session has expired due to inactivity'
});
}
// 更新最后活动时间(异步,不阻塞请求)
redis.setSession(token, {
...adminSession,
lastActivity: now.toISOString()
}, 86400).catch(error => {
logger.error('Failed to update admin session activity:', error);
});
// 设置管理员信息(只包含必要信息)
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: token,
loginTime: adminSession.loginTime
};
const authDuration = Date.now() - startTime;
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`);
next();
} catch (error) {
const authDuration = Date.now() - startTime;
logger.error(`❌ Admin authentication error (${authDuration}ms):`, {
error: error.message,
ip: req.ip,
userAgent: req.get('User-Agent'),
url: req.originalUrl
});
res.status(500).json({
error: 'Authentication error',
message: 'Internal server error during admin authentication'
});
}
};
// 注意:使用统计现在直接在/api/v1/messages路由中处理
// 以便从Claude API响应中提取真实的usage数据
// 🚦 CORS中间件优化版
const corsMiddleware = (req, res, next) => {
const origin = req.headers.origin;
// 允许的源(可以从配置文件读取)
const allowedOrigins = [
'http://localhost:3000',
'https://localhost:3000',
'http://127.0.0.1:3000',
'https://127.0.0.1:3000'
];
// 设置CORS头
if (allowedOrigins.includes(origin) || !origin) {
res.header('Access-Control-Allow-Origin', origin || '*');
}
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', [
'Origin',
'X-Requested-With',
'Content-Type',
'Accept',
'Authorization',
'x-api-key',
'api-key',
'x-admin-token'
].join(', '));
res.header('Access-Control-Expose-Headers', [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'X-RateLimit-Policy'
].join(', '));
res.header('Access-Control-Max-Age', '86400'); // 24小时预检缓存
res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') {
res.status(204).end();
} else {
next();
}
};
// 📝 请求日志中间件(优化版)
const requestLogger = (req, res, next) => {
const start = Date.now();
const requestId = Math.random().toString(36).substring(2, 15);
// 添加请求ID到请求对象
req.requestId = requestId;
res.setHeader('X-Request-ID', requestId);
// 获取客户端信息
const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown';
const userAgent = req.get('User-Agent') || 'unknown';
const referer = req.get('Referer') || 'none';
// 记录请求开始
if (req.originalUrl !== '/health') { // 避免健康检查日志过多
logger.request(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`);
}
res.on('finish', () => {
const duration = Date.now() - start;
const contentLength = res.get('Content-Length') || '0';
// 构建日志元数据
const logMetadata = {
requestId,
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration,
contentLength,
ip: clientIP,
userAgent,
referer
};
// 根据状态码选择日志级别
if (res.statusCode >= 500) {
logger.error(`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, logMetadata);
} else if (res.statusCode >= 400) {
logger.warn(`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, logMetadata);
} else if (req.originalUrl !== '/health') {
logger.request(`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, logMetadata);
}
// API Key相关日志
if (req.apiKey) {
logger.api(`📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms`);
}
// 慢请求警告
if (duration > 5000) {
logger.warn(`🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}`);
}
});
res.on('error', (error) => {
const duration = Date.now() - start;
logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error);
});
next();
};
// 🛡️ 安全中间件(增强版)
const securityMiddleware = (req, res, next) => {
// 设置基础安全头
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// 添加更多安全头
res.setHeader('X-DNS-Prefetch-Control', 'off');
res.setHeader('X-Download-Options', 'noopen');
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
// Cross-Origin-Opener-Policy (仅对可信来源设置)
const host = req.get('host') || '';
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0');
const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https';
if (isLocalhost || isHttps) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Origin-Agent-Cluster', '?1');
}
// Content Security Policy (适用于web界面)
if (req.path.startsWith('/web') || req.path === '/') {
res.setHeader('Content-Security-Policy', [
'default-src \'self\'',
'script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net',
'style-src \'self\' \'unsafe-inline\' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com',
'font-src \'self\' https://cdnjs.cloudflare.com',
'img-src \'self\' data:',
'connect-src \'self\'',
'frame-ancestors \'none\'',
'base-uri \'self\'',
'form-action \'self\''
].join('; '));
}
// Strict Transport Security (HTTPS)
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains');
}
// 移除泄露服务器信息的头
res.removeHeader('X-Powered-By');
res.removeHeader('Server');
// 防止信息泄露
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
};
// 🚨 错误处理中间件(增强版)
const errorHandler = (error, req, res, _next) => {
const requestId = req.requestId || 'unknown';
const isDevelopment = process.env.NODE_ENV === 'development';
// 记录详细错误信息
logger.error(`💥 [${requestId}] Unhandled error:`, {
error: error.message,
stack: error.stack,
url: req.originalUrl,
method: req.method,
ip: req.ip || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
apiKey: req.apiKey ? req.apiKey.id : 'none',
admin: req.admin ? req.admin.username : 'none'
});
// 确定HTTP状态码
let statusCode = 500;
let errorMessage = 'Internal Server Error';
let userMessage = 'Something went wrong';
if (error.status && error.status >= 400 && error.status < 600) {
statusCode = error.status;
}
// 根据错误类型提供友好的错误消息
switch (error.name) {
case 'ValidationError':
statusCode = 400;
errorMessage = 'Validation Error';
userMessage = 'Invalid input data';
break;
case 'CastError':
statusCode = 400;
errorMessage = 'Cast Error';
userMessage = 'Invalid data format';
break;
case 'MongoError':
case 'RedisError':
statusCode = 503;
errorMessage = 'Database Error';
userMessage = 'Database temporarily unavailable';
break;
case 'TimeoutError':
statusCode = 408;
errorMessage = 'Request Timeout';
userMessage = 'Request took too long to process';
break;
default:
if (error.message && !isDevelopment) {
// 在生产环境中,只显示安全的错误消息
if (error.message.includes('ECONNREFUSED')) {
userMessage = 'Service temporarily unavailable';
} else if (error.message.includes('timeout')) {
userMessage = 'Request timeout';
}
}
}
// 设置响应头
res.setHeader('X-Request-ID', requestId);
// 构建错误响应
const errorResponse = {
error: errorMessage,
message: isDevelopment ? error.message : userMessage,
requestId,
timestamp: new Date().toISOString()
};
// 在开发环境中包含更多调试信息
if (isDevelopment) {
errorResponse.stack = error.stack;
errorResponse.url = req.originalUrl;
errorResponse.method = req.method;
}
res.status(statusCode).json(errorResponse);
};
// 🌐 全局速率限制中间件(延迟初始化)
let rateLimiter = null;
const getRateLimiter = () => {
if (!rateLimiter) {
try {
const client = redis.getClient();
if (!client) {
logger.warn('⚠️ Redis client not available for rate limiter');
return null;
}
rateLimiter = new RateLimiterRedis({
storeClient: client,
keyPrefix: 'global_rate_limit',
points: 1000, // 请求数量
duration: 900, // 15分钟 (900秒)
blockDuration: 900, // 阻塞时间15分钟
});
logger.info('✅ Rate limiter initialized successfully');
} catch (error) {
logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message });
return null;
}
}
return rateLimiter;
};
const globalRateLimit = async (req, res, next) => {
// 跳过健康检查和内部请求
if (req.path === '/health' || req.path === '/api/health') {
return next();
}
const limiter = getRateLimiter();
if (!limiter) {
// 如果Redis不可用直接跳过速率限制
return next();
}
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
try {
await limiter.consume(clientIP);
next();
} catch (rejRes) {
const remainingPoints = rejRes.remainingPoints || 0;
const msBeforeNext = rejRes.msBeforeNext || 900000;
logger.security(`🚦 Global rate limit exceeded for IP: ${clientIP}`);
res.set({
'Retry-After': Math.round(msBeforeNext / 1000) || 900,
'X-RateLimit-Limit': 1000,
'X-RateLimit-Remaining': remainingPoints,
'X-RateLimit-Reset': new Date(Date.now() + msBeforeNext).toISOString()
});
res.status(429).json({
error: 'Too Many Requests',
message: 'Too many requests from this IP, please try again later.',
retryAfter: Math.round(msBeforeNext / 1000)
});
}
};
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const maxSize = 10 * 1024 * 1024; // 10MB
const contentLength = parseInt(req.headers['content-length'] || '0');
if (contentLength > maxSize) {
logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`);
return res.status(413).json({
error: 'Payload Too Large',
message: 'Request body size exceeds limit',
limit: '10MB'
});
}
next();
};
module.exports = {
authenticateApiKey,
authenticateAdmin,
corsMiddleware,
requestLogger,
securityMiddleware,
errorHandler,
globalRateLimit,
requestSizeLimit
};

678
src/models/redis.js Normal file
View File

@@ -0,0 +1,678 @@
const Redis = require('ioredis');
const config = require('../../config/config');
const logger = require('../utils/logger');
class RedisClient {
constructor() {
this.client = null;
this.isConnected = false;
}
async connect() {
try {
this.client = new Redis({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
db: config.redis.db,
retryDelayOnFailover: config.redis.retryDelayOnFailover,
maxRetriesPerRequest: config.redis.maxRetriesPerRequest,
lazyConnect: config.redis.lazyConnect
});
this.client.on('connect', () => {
this.isConnected = true;
logger.info('🔗 Redis connected successfully');
});
this.client.on('error', (err) => {
this.isConnected = false;
logger.error('❌ Redis connection error:', err);
});
this.client.on('close', () => {
this.isConnected = false;
logger.warn('⚠️ Redis connection closed');
});
await this.client.connect();
return this.client;
} catch (error) {
logger.error('💥 Failed to connect to Redis:', error);
throw error;
}
}
async disconnect() {
if (this.client) {
await this.client.quit();
this.isConnected = false;
logger.info('👋 Redis disconnected');
}
}
getClient() {
if (!this.client || !this.isConnected) {
logger.warn('⚠️ Redis client is not connected');
return null;
}
return this.client;
}
// 安全获取客户端(用于关键操作)
getClientSafe() {
if (!this.client || !this.isConnected) {
throw new Error('Redis client is not connected');
}
return this.client;
}
// 🔑 API Key 相关操作
async setApiKey(keyId, keyData, hashedKey = null) {
const key = `apikey:${keyId}`;
const client = this.getClientSafe();
// 维护哈希映射表(用于快速查找)
// hashedKey参数是实际的哈希值用于建立映射
if (hashedKey) {
await client.hset('apikey:hash_map', hashedKey, keyId);
}
await client.hset(key, keyData);
await client.expire(key, 86400 * 365); // 1年过期
}
async getApiKey(keyId) {
const key = `apikey:${keyId}`;
return await this.client.hgetall(key);
}
async deleteApiKey(keyId) {
const key = `apikey:${keyId}`;
// 获取要删除的API Key哈希值以便从映射表中移除
const keyData = await this.client.hgetall(key);
if (keyData && keyData.apiKey) {
// keyData.apiKey现在存储的是哈希值直接从映射表删除
await this.client.hdel('apikey:hash_map', keyData.apiKey);
}
return await this.client.del(key);
}
async getAllApiKeys() {
const keys = await this.client.keys('apikey:*');
const apiKeys = [];
for (const key of keys) {
// 过滤掉hash_map它不是真正的API Key
if (key === 'apikey:hash_map') {
continue;
}
const keyData = await this.client.hgetall(key);
if (keyData && Object.keys(keyData).length > 0) {
apiKeys.push({ id: key.replace('apikey:', ''), ...keyData });
}
}
return apiKeys;
}
// 🔍 通过哈希值查找API Key性能优化
async findApiKeyByHash(hashedKey) {
// 使用反向映射表hash -> keyId
const keyId = await this.client.hget('apikey:hash_map', hashedKey);
if (!keyId) {
return null;
}
const keyData = await this.client.hgetall(`apikey:${keyId}`);
if (keyData && Object.keys(keyData).length > 0) {
return { id: keyId, ...keyData };
}
// 如果数据不存在,清理映射表
await this.client.hdel('apikey:hash_map', hashedKey);
return null;
}
// 📊 使用统计相关操作支持缓存token统计和模型信息
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
const key = `usage:${keyId}`;
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const daily = `usage:daily:${keyId}:${today}`;
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
// 按模型统计的键
const modelDaily = `usage:model:daily:${model}:${today}`;
const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`;
// API Key级别的模型统计
const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`;
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
// 智能处理输入输出token分配
const finalInputTokens = inputTokens || 0;
const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens);
const finalCacheCreateTokens = cacheCreateTokens || 0;
const finalCacheReadTokens = cacheReadTokens || 0;
// 重新计算真实的总token数包括缓存token
const totalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens;
// 核心token不包括缓存- 用于与历史数据兼容
const coreTokens = finalInputTokens + finalOutputTokens;
await Promise.all([
// 核心token统计保持向后兼容
this.client.hincrby(key, 'totalTokens', coreTokens),
this.client.hincrby(key, 'totalInputTokens', finalInputTokens),
this.client.hincrby(key, 'totalOutputTokens', finalOutputTokens),
// 缓存token统计新增
this.client.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens),
this.client.hincrby(key, 'totalAllTokens', totalTokens), // 包含所有类型的总token
// 请求计数
this.client.hincrby(key, 'totalRequests', 1),
// 每日统计
this.client.hincrby(daily, 'tokens', coreTokens),
this.client.hincrby(daily, 'inputTokens', finalInputTokens),
this.client.hincrby(daily, 'outputTokens', finalOutputTokens),
this.client.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(daily, 'allTokens', totalTokens),
this.client.hincrby(daily, 'requests', 1),
// 每月统计
this.client.hincrby(monthly, 'tokens', coreTokens),
this.client.hincrby(monthly, 'inputTokens', finalInputTokens),
this.client.hincrby(monthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(monthly, 'allTokens', totalTokens),
this.client.hincrby(monthly, 'requests', 1),
// 按模型统计 - 每日
this.client.hincrby(modelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(modelDaily, 'outputTokens', finalOutputTokens),
this.client.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(modelDaily, 'allTokens', totalTokens),
this.client.hincrby(modelDaily, 'requests', 1),
// 按模型统计 - 每月
this.client.hincrby(modelMonthly, 'inputTokens', finalInputTokens),
this.client.hincrby(modelMonthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(modelMonthly, 'allTokens', totalTokens),
this.client.hincrby(modelMonthly, 'requests', 1),
// API Key级别的模型统计 - 每日
this.client.hincrby(keyModelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens),
this.client.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(keyModelDaily, 'allTokens', totalTokens),
this.client.hincrby(keyModelDaily, 'requests', 1),
// API Key级别的模型统计 - 每月
this.client.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens),
this.client.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens),
this.client.hincrby(keyModelMonthly, 'requests', 1),
// 设置过期时间
this.client.expire(daily, 86400 * 32), // 32天过期
this.client.expire(monthly, 86400 * 365), // 1年过期
this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期
this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期
this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期
this.client.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
]);
}
async getUsageStats(keyId) {
const totalKey = `usage:${keyId}`;
const today = new Date().toISOString().split('T')[0];
const dailyKey = `usage:daily:${keyId}:${today}`;
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`;
const [total, daily, monthly] = await Promise.all([
this.client.hgetall(totalKey),
this.client.hgetall(dailyKey),
this.client.hgetall(monthlyKey)
]);
// 获取API Key的创建时间来计算平均值
const keyData = await this.client.hgetall(`apikey:${keyId}`);
const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date();
const now = new Date();
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)));
const totalTokens = parseInt(total.totalTokens) || 0;
const totalRequests = parseInt(total.totalRequests) || 0;
// 计算平均RPM (requests per minute) 和 TPM (tokens per minute)
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60);
const avgRPM = totalRequests / totalMinutes;
const avgTPM = totalTokens / totalMinutes;
// 处理旧数据兼容性支持缓存token
const handleLegacyData = (data) => {
// 优先使用total*字段(存储时使用的字段)
const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0;
// 新增缓存token字段
const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
const totalFromSeparate = inputTokens + outputTokens;
if (totalFromSeparate === 0 && tokens > 0) {
// 旧数据:没有输入输出分离
return {
tokens,
inputTokens: Math.round(tokens * 0.3), // 假设30%为输入
outputTokens: Math.round(tokens * 0.7), // 假设70%为输出
cacheCreateTokens: 0, // 旧数据没有缓存token
cacheReadTokens: 0,
allTokens: tokens, // 对于旧数据allTokens等于tokens
requests
};
} else {
// 新数据或无数据
return {
tokens,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值
requests
};
}
};
const totalData = handleLegacyData(total);
const dailyData = handleLegacyData(daily);
const monthlyData = handleLegacyData(monthly);
return {
total: totalData,
daily: dailyData,
monthly: monthlyData,
averages: {
rpm: Math.round(avgRPM * 100) / 100, // 保留2位小数
tpm: Math.round(avgTPM * 100) / 100,
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
}
};
}
// 🧹 清空所有API Key的使用统计数据
async resetAllUsageStats() {
const client = this.getClientSafe();
const stats = {
deletedKeys: 0,
deletedDailyKeys: 0,
deletedMonthlyKeys: 0,
resetApiKeys: 0
};
try {
// 获取所有API Key ID
const apiKeyIds = [];
const apiKeyKeys = await client.keys('apikey:*');
for (const key of apiKeyKeys) {
if (key === 'apikey:hash_map') continue; // 跳过哈希映射表
const keyId = key.replace('apikey:', '');
apiKeyIds.push(keyId);
}
// 清空每个API Key的使用统计
for (const keyId of apiKeyIds) {
// 删除总体使用统计
const usageKey = `usage:${keyId}`;
const deleted = await client.del(usageKey);
if (deleted > 0) {
stats.deletedKeys++;
}
// 删除该API Key的每日统计使用精确的keyId匹配
const dailyKeys = await client.keys(`usage:daily:${keyId}:*`);
if (dailyKeys.length > 0) {
await client.del(...dailyKeys);
stats.deletedDailyKeys += dailyKeys.length;
}
// 删除该API Key的每月统计使用精确的keyId匹配
const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`);
if (monthlyKeys.length > 0) {
await client.del(...monthlyKeys);
stats.deletedMonthlyKeys += monthlyKeys.length;
}
// 重置API Key的lastUsedAt字段
const keyData = await client.hgetall(`apikey:${keyId}`);
if (keyData && Object.keys(keyData).length > 0) {
keyData.lastUsedAt = '';
await client.hset(`apikey:${keyId}`, keyData);
stats.resetApiKeys++;
}
}
// 额外清理删除所有可能遗漏的usage相关键
const allUsageKeys = await client.keys('usage:*');
if (allUsageKeys.length > 0) {
await client.del(...allUsageKeys);
stats.deletedKeys += allUsageKeys.length;
}
return stats;
} catch (error) {
throw new Error(`Failed to reset usage stats: ${error.message}`);
}
}
// 🏢 Claude 账户管理
async setClaudeAccount(accountId, accountData) {
const key = `claude:account:${accountId}`;
await this.client.hset(key, accountData);
}
async getClaudeAccount(accountId) {
const key = `claude:account:${accountId}`;
return await this.client.hgetall(key);
}
async getAllClaudeAccounts() {
const keys = await this.client.keys('claude: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('claude:account:', ''), ...accountData });
}
}
return accounts;
}
async deleteClaudeAccount(accountId) {
const key = `claude:account:${accountId}`;
return await this.client.del(key);
}
// 🔐 会话管理
async setSession(sessionId, sessionData, ttl = 86400) {
const key = `session:${sessionId}`;
await this.client.hset(key, sessionData);
await this.client.expire(key, ttl);
}
async getSession(sessionId) {
const key = `session:${sessionId}`;
return await this.client.hgetall(key);
}
async deleteSession(sessionId) {
const key = `session:${sessionId}`;
return await this.client.del(key);
}
// 🗝️ API Key哈希索引管理
async setApiKeyHash(hashedKey, keyData, ttl = 0) {
const key = `apikey_hash:${hashedKey}`;
await this.client.hset(key, keyData);
if (ttl > 0) {
await this.client.expire(key, ttl);
}
}
async getApiKeyHash(hashedKey) {
const key = `apikey_hash:${hashedKey}`;
return await this.client.hgetall(key);
}
async deleteApiKeyHash(hashedKey) {
const key = `apikey_hash:${hashedKey}`;
return await this.client.del(key);
}
// 🔗 OAuth会话管理
async setOAuthSession(sessionId, sessionData, ttl = 600) { // 10分钟过期
const key = `oauth:${sessionId}`;
await this.client.hset(key, sessionData);
await this.client.expire(key, ttl);
}
async getOAuthSession(sessionId) {
const key = `oauth:${sessionId}`;
return await this.client.hgetall(key);
}
async deleteOAuthSession(sessionId) {
const key = `oauth:${sessionId}`;
return await this.client.del(key);
}
// 🚦 速率限制
async checkRateLimit(identifier, limit = 100, window = 60) {
const key = `ratelimit:${identifier}`;
const current = await this.client.incr(key);
if (current === 1) {
await this.client.expire(key, window);
}
return {
allowed: current <= limit,
current,
limit,
resetTime: await this.client.ttl(key)
};
}
// 📈 系统统计
async getSystemStats() {
const keys = await Promise.all([
this.client.keys('apikey:*'),
this.client.keys('claude:account:*'),
this.client.keys('usage:*')
]);
return {
totalApiKeys: keys[0].length,
totalClaudeAccounts: keys[1].length,
totalUsageRecords: keys[2].length
};
}
// 📊 获取今日系统统计
async getTodayStats() {
try {
const today = new Date().toISOString().split('T')[0];
const dailyKeys = await this.client.keys(`usage:daily:*:${today}`);
let totalRequestsToday = 0;
let totalTokensToday = 0;
let totalInputTokensToday = 0;
let totalOutputTokensToday = 0;
let totalCacheCreateTokensToday = 0;
let totalCacheReadTokensToday = 0;
// 批量获取所有今日数据,提高性能
if (dailyKeys.length > 0) {
const pipeline = this.client.pipeline();
dailyKeys.forEach(key => pipeline.hgetall(key));
const results = await pipeline.exec();
for (const [error, dailyData] of results) {
if (error || !dailyData) continue;
totalRequestsToday += parseInt(dailyData.requests) || 0;
const currentDayTokens = parseInt(dailyData.tokens) || 0;
totalTokensToday += currentDayTokens;
// 处理旧数据兼容性如果有总token但没有输入输出分离则使用总token作为输出token
const inputTokens = parseInt(dailyData.inputTokens) || 0;
const outputTokens = parseInt(dailyData.outputTokens) || 0;
const cacheCreateTokens = parseInt(dailyData.cacheCreateTokens) || 0;
const cacheReadTokens = parseInt(dailyData.cacheReadTokens) || 0;
const totalTokensFromSeparate = inputTokens + outputTokens;
if (totalTokensFromSeparate === 0 && currentDayTokens > 0) {
// 旧数据没有输入输出分离假设70%为输出30%为输入(基于一般对话比例)
totalOutputTokensToday += Math.round(currentDayTokens * 0.7);
totalInputTokensToday += Math.round(currentDayTokens * 0.3);
} else {
// 新数据:使用实际的输入输出分离
totalInputTokensToday += inputTokens;
totalOutputTokensToday += outputTokens;
}
// 添加cache token统计
totalCacheCreateTokensToday += cacheCreateTokens;
totalCacheReadTokensToday += cacheReadTokens;
}
}
// 获取今日创建的API Key数量批量优化
const allApiKeys = await this.client.keys('apikey:*');
let apiKeysCreatedToday = 0;
if (allApiKeys.length > 0) {
const pipeline = this.client.pipeline();
allApiKeys.forEach(key => pipeline.hget(key, 'createdAt'));
const results = await pipeline.exec();
for (const [error, createdAt] of results) {
if (!error && createdAt && createdAt.startsWith(today)) {
apiKeysCreatedToday++;
}
}
}
return {
requestsToday: totalRequestsToday,
tokensToday: totalTokensToday,
inputTokensToday: totalInputTokensToday,
outputTokensToday: totalOutputTokensToday,
cacheCreateTokensToday: totalCacheCreateTokensToday,
cacheReadTokensToday: totalCacheReadTokensToday,
apiKeysCreatedToday
};
} catch (error) {
console.error('Error getting today stats:', error);
return {
requestsToday: 0,
tokensToday: 0,
inputTokensToday: 0,
outputTokensToday: 0,
cacheCreateTokensToday: 0,
cacheReadTokensToday: 0,
apiKeysCreatedToday: 0
};
}
}
// 📈 获取系统总的平均RPM和TPM
async getSystemAverages() {
try {
const allApiKeys = await this.client.keys('apikey:*');
let totalRequests = 0;
let totalTokens = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let oldestCreatedAt = new Date();
// 批量获取所有usage数据和key数据提高性能
const usageKeys = allApiKeys.map(key => `usage:${key.replace('apikey:', '')}`);
const pipeline = this.client.pipeline();
// 添加所有usage查询
usageKeys.forEach(key => pipeline.hgetall(key));
// 添加所有key数据查询
allApiKeys.forEach(key => pipeline.hgetall(key));
const results = await pipeline.exec();
const usageResults = results.slice(0, usageKeys.length);
const keyResults = results.slice(usageKeys.length);
for (let i = 0; i < allApiKeys.length; i++) {
const totalData = usageResults[i][1] || {};
const keyData = keyResults[i][1] || {};
totalRequests += parseInt(totalData.totalRequests) || 0;
totalTokens += parseInt(totalData.totalTokens) || 0;
totalInputTokens += parseInt(totalData.totalInputTokens) || 0;
totalOutputTokens += parseInt(totalData.totalOutputTokens) || 0;
const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date();
if (createdAt < oldestCreatedAt) {
oldestCreatedAt = createdAt;
}
}
const now = new Date();
// 保持与个人API Key计算一致的算法按天计算然后转换为分钟
const daysSinceOldest = Math.max(1, Math.ceil((now - oldestCreatedAt) / (1000 * 60 * 60 * 24)));
const totalMinutes = daysSinceOldest * 24 * 60;
return {
systemRPM: Math.round((totalRequests / totalMinutes) * 100) / 100,
systemTPM: Math.round((totalTokens / totalMinutes) * 100) / 100,
totalInputTokens,
totalOutputTokens,
totalTokens
};
} catch (error) {
console.error('Error getting system averages:', error);
return {
systemRPM: 0,
systemTPM: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0
};
}
}
// 🧹 清理过期数据
async cleanup() {
try {
const patterns = [
'usage:daily:*',
'ratelimit:*',
'session:*',
'oauth:*'
];
for (const pattern of patterns) {
const keys = await this.client.keys(pattern);
const pipeline = this.client.pipeline();
for (const key of keys) {
const ttl = await this.client.ttl(key);
if (ttl === -1) { // 没有设置过期时间的键
if (key.startsWith('oauth:')) {
pipeline.expire(key, 600); // OAuth会话设置10分钟过期
} else {
pipeline.expire(key, 86400); // 其他设置1天过期
}
}
}
await pipeline.exec();
}
logger.info('🧹 Redis cleanup completed');
} catch (error) {
logger.error('❌ Redis cleanup failed:', error);
}
}
}
module.exports = new RedisClient();

901
src/routes/admin.js Normal file
View File

@@ -0,0 +1,901 @@
const express = require('express');
const apiKeyService = require('../services/apiKeyService');
const claudeAccountService = require('../services/claudeAccountService');
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 pricingService = require('../services/pricingService');
const router = express.Router();
// 🔑 API Keys 管理
// 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
const apiKeys = await apiKeyService.getAllApiKeys();
res.json({ success: true, data: apiKeys });
} catch (error) {
logger.error('❌ Failed to get API keys:', error);
res.status(500).json({ error: 'Failed to get API keys', message: error.message });
}
});
// 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
tokenLimit,
requestLimit,
expiresAt,
claudeAccountId
} = req.body;
// 输入验证
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'Name is required and must be a non-empty string' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Name must be less than 100 characters' });
}
if (description && (typeof description !== 'string' || description.length > 500)) {
return res.status(400).json({ error: 'Description must be a string with less than 500 characters' });
}
if (tokenLimit && (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0)) {
return res.status(400).json({ error: 'Token limit must be a non-negative integer' });
}
if (requestLimit && (!Number.isInteger(Number(requestLimit)) || Number(requestLimit) < 0)) {
return res.status(400).json({ error: 'Request limit must be a non-negative integer' });
}
const newKey = await apiKeyService.generateApiKey({
name,
description,
tokenLimit,
requestLimit,
expiresAt,
claudeAccountId
});
logger.success(`🔑 Admin created new API key: ${name}`);
res.json({ success: true, data: newKey });
} catch (error) {
logger.error('❌ Failed to create API key:', error);
res.status(500).json({ error: 'Failed to create API key', message: error.message });
}
});
// 更新API Key
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const updates = req.body;
await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`);
res.json({ success: true, message: 'API key updated successfully' });
} catch (error) {
logger.error('❌ Failed to update API key:', error);
res.status(500).json({ error: 'Failed to update API key', message: error.message });
}
});
// 删除API Key
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
await apiKeyService.deleteApiKey(keyId);
logger.success(`🗑️ Admin deleted API key: ${keyId}`);
res.json({ success: true, message: 'API key deleted successfully' });
} catch (error) {
logger.error('❌ Failed to delete API key:', error);
res.status(500).json({ error: 'Failed to delete API key', message: error.message });
}
});
// 🏢 Claude 账户管理
// 生成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');
res.json({
success: true,
data: {
authUrl: oauthParams.authUrl,
sessionId: sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 Anthropic 账户',
'3. 同意应用权限',
'4. 复制浏览器地址栏中的完整 URL',
'5. 在添加账户表单中粘贴完整的回调 URL 和授权码'
]
}
});
} catch (error) {
logger.error('❌ Failed to generate OAuth URL:', error);
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');
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')
});
res.status(500).json({ error: 'Failed to exchange authorization code', message: error.message });
}
});
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await claudeAccountService.getAllAccounts();
res.json({ success: true, data: accounts });
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error);
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
}
});
// 创建新的Claude账户
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const newAccount = await claudeAccountService.createAccount({
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy
});
logger.success(`🏢 Admin created new Claude account: ${name}`);
res.json({ success: true, data: newAccount });
} catch (error) {
logger.error('❌ Failed to create Claude account:', error);
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;
await claudeAccountService.updateAccount(accountId, updates);
logger.success(`📝 Admin updated Claude account: ${accountId}`);
res.json({ success: true, message: 'Claude account updated successfully' });
} catch (error) {
logger.error('❌ Failed to update Claude account:', error);
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;
await claudeAccountService.deleteAccount(accountId);
logger.success(`🗑️ Admin deleted Claude account: ${accountId}`);
res.json({ success: true, message: 'Claude account deleted successfully' });
} catch (error) {
logger.error('❌ Failed to delete Claude account:', error);
res.status(500).json({ error: 'Failed to delete Claude account', 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}`);
res.json({ success: true, data: result });
} catch (error) {
logger.error('❌ Failed to refresh Claude account token:', error);
res.status(500).json({ error: 'Failed to refresh token', message: error.message });
}
});
// 📊 系统统计
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
const [, apiKeys, accounts, todayStats, systemAverages] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages()
]);
// 计算使用统计包含cache tokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 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;
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
const dashboard = {
overview: {
totalApiKeys: apiKeys.length,
activeApiKeys,
totalClaudeAccounts: accounts.length,
activeClaudeAccounts: activeAccounts,
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
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: activeAccounts > 0,
uptime: process.uptime()
}
};
res.json({ success: true, data: dashboard });
} catch (error) {
logger.error('❌ Failed to get dashboard data:', error);
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
}));
res.json({ success: true, data: { period, stats } });
} catch (error) {
logger.error('❌ Failed to get usage stats:', error);
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' } = req.query; // daily, monthly
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`);
const client = redis.getClientSafe();
// 获取所有模型的统计数据
const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`;
logger.info(`📊 Searching pattern: ${pattern}`);
const keys = await client.keys(pattern);
logger.info(`📊 Found ${keys.length} matching keys:`, keys);
const modelStats = [];
for (const key of keys) {
const match = key.match(period === 'daily' ?
/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ :
/usage:model:monthly:(.+):\d{4}-\d{2}$/
);
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`);
continue;
}
const model = match[1];
const data = await client.hgetall(key);
logger.info(`📊 Model ${model} data:`, data);
if (data && Object.keys(data).length > 0) {
const usage = {
input_tokens: parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
};
// 计算费用
const costData = CostCalculator.calculateCost(usage, model);
modelStats.push({
model,
period,
requests: parseInt(data.requests) || 0,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: parseInt(data.allTokens) || 0,
usage: {
requests: parseInt(data.requests) || 0,
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);
res.json({ success: true, data: modelStats });
} catch (error) {
logger.error('❌ Failed to get model stats:', error);
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`);
res.json({
success: true,
message: 'Cleanup completed',
data: {
expiredKeysRemoved: expiredKeys,
errorAccountsReset: errorAccounts
}
});
} catch (error) {
logger.error('❌ Cleanup failed:', error);
res.status(500).json({ error: 'Cleanup failed', message: error.message });
}
});
// 获取使用趋势数据
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
try {
const { days = 7 } = req.query;
const daysCount = parseInt(days) || 7;
const client = redis.getClientSafe();
const trendData = [];
const today = new Date();
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
// 汇总当天所有API Key的使用数据
const pattern = `usage:daily:*:${dateStr}`;
const keys = await client.keys(pattern);
let dayInputTokens = 0;
let dayOutputTokens = 0;
let dayRequests = 0;
let dayCacheCreateTokens = 0;
let dayCacheReadTokens = 0;
let dayCost = 0;
for (const key of keys) {
const data = await client.hgetall(key);
if (data) {
dayInputTokens += parseInt(data.inputTokens) || 0;
dayOutputTokens += parseInt(data.outputTokens) || 0;
dayRequests += parseInt(data.requests) || 0;
dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
// 计算当天费用(使用通用模型价格估算)
const usage = {
input_tokens: dayInputTokens,
output_tokens: dayOutputTokens,
cache_creation_input_tokens: dayCacheCreateTokens,
cache_read_input_tokens: dayCacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'unknown');
dayCost = costResult.costs.total;
trendData.push({
date: dateStr,
inputTokens: dayInputTokens,
outputTokens: dayOutputTokens,
requests: dayRequests,
cacheCreateTokens: dayCacheCreateTokens,
cacheReadTokens: dayCacheReadTokens,
totalTokens: dayInputTokens + dayOutputTokens + dayCacheCreateTokens + dayCacheReadTokens,
cost: dayCost,
formattedCost: CostCalculator.formatCost(dayCost)
});
}
// 按日期正序排列
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
res.json({ success: true, data: trendData });
} catch (error) {
logger.error('❌ Failed to get usage trend:', error);
res.status(500).json({ error: 'Failed to get usage trend', message: error.message });
}
});
// 获取单个API Key的模型统计
router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const { period = 'monthly', startDate, endDate } = req.query;
logger.info(`📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}`);
const client = redis.getClientSafe();
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
let searchPatterns = [];
if (period === 'custom' && 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' });
}
// 限制最大范围为31天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
if (daysDiff > 31) {
return res.status(400).json({ error: 'Date range cannot exceed 31 days' });
}
// 生成日期范围内所有日期的搜索模式
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`);
}
logger.info(`📊 Custom date range patterns: ${searchPatterns.length} days from ${startDate} to ${endDate}`);
} else {
// 原有的预设期间逻辑
const pattern = period === 'daily' ?
`usage:${keyId}:model:daily:*:${today}` :
`usage:${keyId}:model:monthly:*:${currentMonth}`;
searchPatterns = [pattern];
logger.info(`📊 Preset period pattern: ${pattern}`);
}
// 汇总所有匹配的数据
const modelStatsMap = new Map();
const modelStats = []; // 定义结果数组
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern);
logger.info(`📊 Pattern ${pattern} found ${keys.length} keys`);
for (const key of keys) {
const match = key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`);
continue;
}
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 累加同一模型的数据
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
});
}
const stats = modelStatsMap.get(model);
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;
}
}
}
// 将汇总的数据转换为最终结果
for (const [model, stats] of modelStatsMap) {
logger.info(`📊 Model ${model} aggregated data:`, stats);
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
// 使用CostCalculator计算费用
const costData = CostCalculator.calculateCost(usage, model);
modelStats.push({
model,
requests: stats.requests,
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
cacheCreateTokens: stats.cacheCreateTokens,
cacheReadTokens: stats.cacheReadTokens,
allTokens: stats.allTokens,
// 添加费用信息
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing,
usingDynamicPricing: costData.usingDynamicPricing
});
}
// 如果没有找到模型级别的详细数据,尝试从汇总数据中生成展示
if (modelStats.length === 0) {
logger.info(`📊 No detailed model stats found, trying to get aggregate data for API key ${keyId}`);
// 尝试从API Keys列表中获取usage数据作为备选方案
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const targetApiKey = apiKeys.find(key => key.id === keyId);
if (targetApiKey && targetApiKey.usage) {
logger.info(`📊 Found API key usage data from getAllApiKeys for ${keyId}:`, targetApiKey.usage);
// 从汇总数据创建展示条目
let usageData;
if (period === 'custom' || period === 'daily') {
// 对于自定义或日统计使用daily数据或total数据
usageData = targetApiKey.usage.daily || targetApiKey.usage.total;
} else {
// 对于月统计使用monthly数据或total数据
usageData = targetApiKey.usage.monthly || targetApiKey.usage.total;
}
if (usageData && usageData.allTokens > 0) {
const usage = {
input_tokens: usageData.inputTokens || 0,
output_tokens: usageData.outputTokens || 0,
cache_creation_input_tokens: usageData.cacheCreateTokens || 0,
cache_read_input_tokens: usageData.cacheReadTokens || 0
};
// 对于汇总数据,使用默认模型计算费用
const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022');
modelStats.push({
model: '总体使用 (历史数据)',
requests: usageData.requests || 0,
inputTokens: usageData.inputTokens || 0,
outputTokens: usageData.outputTokens || 0,
cacheCreateTokens: usageData.cacheCreateTokens || 0,
cacheReadTokens: usageData.cacheReadTokens || 0,
allTokens: usageData.allTokens || 0,
// 添加费用信息
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing,
usingDynamicPricing: costData.usingDynamicPricing
});
logger.info('📊 Generated display data from API key usage stats');
} else {
logger.info(`📊 No usage data found for period ${period} in API key data`);
}
} else {
logger.info(`📊 API key ${keyId} not found or has no usage data`);
}
} catch (error) {
logger.error('❌ Error fetching API key usage data:', error);
}
}
// 按总token数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens);
logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats);
res.json({ success: true, data: modelStats });
} catch (error) {
logger.error('❌ Failed to get API key model stats:', error);
res.status(500).json({ error: 'Failed to get API key model stats', message: error.message });
}
});
// 计算总体使用费用
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
try {
const { period = 'all' } = req.query; // all, today, monthly
logger.info(`💰 Calculating usage costs for period: ${period}`);
// 获取所有API Keys的使用统计
const apiKeys = await apiKeyService.getAllApiKeys();
let totalCosts = {
inputCost: 0,
outputCost: 0,
cacheCreateCost: 0,
cacheReadCost: 0,
totalCost: 0
};
let modelCosts = {};
// 按模型统计费用
const client = redis.getClientSafe();
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
let pattern;
if (period === 'today') {
pattern = `usage:model:daily:*:${today}`;
} else if (period === 'monthly') {
pattern = `usage:model:monthly:*:${currentMonth}`;
} else {
// 全部时间使用API Key汇总数据
for (const apiKey of apiKeys) {
if (apiKey.usage && apiKey.usage.total) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
};
// 计算未知模型的费用(汇总数据)
const costResult = CostCalculator.calculateCost(usage, 'unknown');
totalCosts.inputCost += costResult.costs.input;
totalCosts.outputCost += costResult.costs.output;
totalCosts.cacheCreateCost += costResult.costs.cacheWrite;
totalCosts.cacheReadCost += costResult.costs.cacheRead;
totalCosts.totalCost += costResult.costs.total;
}
}
res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: [],
pricingServiceStatus: pricingService.getStatus()
}
});
return;
}
// 对于今日或本月从Redis获取详细的模型统计
const keys = await client.keys(pattern);
for (const key of keys) {
const match = key.match(period === 'today' ?
/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ :
/usage:model:monthly:(.+):\d{4}-\d{2}$/
);
if (!match) continue;
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const usage = {
input_tokens: parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
};
const costResult = CostCalculator.calculateCost(usage, model);
// 累加总费用
totalCosts.inputCost += costResult.costs.input;
totalCosts.outputCost += costResult.costs.output;
totalCosts.cacheCreateCost += costResult.costs.cacheWrite;
totalCosts.cacheReadCost += costResult.costs.cacheRead;
totalCosts.totalCost += costResult.costs.total;
// 记录模型费用
modelCosts[model] = {
model,
requests: parseInt(data.requests) || 0,
usage,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
};
}
}
res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total),
pricingServiceStatus: pricingService.getStatus()
}
});
} catch (error) {
logger.error('❌ Failed to calculate usage costs:', error);
res.status(500).json({ error: 'Failed to calculate usage costs', message: error.message });
}
});
module.exports = router;

225
src/routes/api.js Normal file
View File

@@ -0,0 +1,225 @@
const express = require('express');
const claudeRelayService = require('../services/claudeRelayService');
const apiKeyService = require('../services/apiKeyService');
const { authenticateApiKey } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
// 🚀 Claude API messages 端点
router.post('/v1/messages', authenticateApiKey, async (req, res) => {
try {
const startTime = Date.now();
// 严格的输入验证
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({
error: 'Invalid request',
message: 'Request body must be a valid JSON object'
});
}
if (!req.body.messages || !Array.isArray(req.body.messages)) {
return res.status(400).json({
error: 'Invalid request',
message: 'Missing or invalid field: messages (must be an array)'
});
}
if (req.body.messages.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Messages array cannot be empty'
});
}
// 检查是否为流式请求
const isStream = req.body.stream === true;
logger.api(`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}`);
if (isStream) {
// 流式响应 - 只使用官方真实usage数据
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
let usageDataCaptured = false;
// 使用自定义流处理器来捕获usage数据
await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, (usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2));
if (usageData && usageData.input_tokens !== undefined && usageData.output_tokens !== undefined) {
const inputTokens = usageData.input_tokens || 0;
const outputTokens = usageData.output_tokens || 0;
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0;
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
const model = usageData.model || 'unknown';
// 记录真实的token使用量包含模型信息和所有4种token
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => {
logger.error('❌ Failed to record stream usage:', error);
});
usageDataCaptured = true;
logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`);
} else {
logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData));
}
});
// 流式请求完成后 - 如果没有捕获到usage数据记录警告但不进行估算
setTimeout(() => {
if (!usageDataCaptured) {
logger.warn('⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)');
}
}, 1000); // 1秒后检查
} else {
// 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id,
apiKeyName: req.apiKey.name
});
const response = await claudeRelayService.relayRequest(req.body, req.apiKey);
logger.info('📡 Claude API response received', {
statusCode: response.statusCode,
headers: JSON.stringify(response.headers),
bodyLength: response.body ? response.body.length : 0
});
res.status(response.statusCode);
// 设置响应头
Object.keys(response.headers).forEach(key => {
if (key.toLowerCase() !== 'content-encoding') {
res.setHeader(key, response.headers[key]);
}
});
let usageRecorded = false;
// 尝试解析JSON响应并提取usage信息
try {
const jsonData = JSON.parse(response.body);
logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2));
// 从Claude API响应中提取usage信息完整的token分类体系
if (jsonData.usage && jsonData.usage.input_tokens !== undefined && jsonData.usage.output_tokens !== undefined) {
const inputTokens = jsonData.usage.input_tokens || 0;
const outputTokens = jsonData.usage.output_tokens || 0;
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0;
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
const model = jsonData.model || req.body.model || 'unknown';
// 记录真实的token使用量包含模型信息和所有4种token
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
usageRecorded = true;
logger.api(`📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`);
} else {
logger.warn('⚠️ No usage data found in Claude API JSON response');
}
res.json(jsonData);
} catch (parseError) {
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message);
logger.info('📄 Raw response body:', response.body);
res.send(response.body);
}
// 如果没有记录usage只记录警告不进行估算
if (!usageRecorded) {
logger.warn('⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)');
}
}
const duration = Date.now() - startTime;
logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`);
} catch (error) {
logger.error('❌ Claude relay error:', error);
if (!res.headersSent) {
res.status(500).json({
error: 'Relay service error',
message: error.message
});
}
}
});
// 🏥 健康检查端点
router.get('/health', async (req, res) => {
try {
const healthStatus = await claudeRelayService.healthCheck();
res.status(healthStatus.healthy ? 200 : 503).json({
status: healthStatus.healthy ? 'healthy' : 'unhealthy',
service: 'claude-relay-service',
version: '1.0.0',
...healthStatus
});
} catch (error) {
logger.error('❌ Health check error:', error);
res.status(503).json({
status: 'unhealthy',
service: 'claude-relay-service',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// 📊 API Key状态检查端点
router.get('/v1/key-info', authenticateApiKey, async (req, res) => {
try {
const usage = await apiKeyService.getUsageStats(req.apiKey.id);
res.json({
keyInfo: {
id: req.apiKey.id,
name: req.apiKey.name,
tokenLimit: req.apiKey.tokenLimit,
requestLimit: req.apiKey.requestLimit,
usage
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('❌ Key info error:', error);
res.status(500).json({
error: 'Failed to get key info',
message: error.message
});
}
});
// 📈 使用统计端点
router.get('/v1/usage', authenticateApiKey, async (req, res) => {
try {
const usage = await apiKeyService.getUsageStats(req.apiKey.id);
res.json({
usage,
limits: {
tokens: req.apiKey.tokenLimit,
requests: req.apiKey.requestLimit
},
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('❌ Usage stats error:', error);
res.status(500).json({
error: 'Failed to get usage stats',
message: error.message
});
}
});
module.exports = router;

202
src/routes/web.js Normal file
View File

@@ -0,0 +1,202 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const redis = require('../models/redis');
const logger = require('../utils/logger');
const config = require('../../config/config');
const router = express.Router();
// 🏠 服务静态文件
router.use('/assets', express.static(path.join(__dirname, '../../web/assets')));
// 🔒 Web管理界面文件白名单 - 仅允许这些特定文件
const ALLOWED_FILES = {
'index.html': {
path: path.join(__dirname, '../../web/admin/index.html'),
contentType: 'text/html; charset=utf-8'
},
'app.js': {
path: path.join(__dirname, '../../web/admin/app.js'),
contentType: 'application/javascript; charset=utf-8'
},
'style.css': {
path: path.join(__dirname, '../../web/admin/style.css'),
contentType: 'text/css; charset=utf-8'
}
};
// 🛡️ 安全文件服务函数
function serveWhitelistedFile(req, res, filename) {
const fileConfig = ALLOWED_FILES[filename];
if (!fileConfig) {
logger.security(`🚨 Attempted access to non-whitelisted file: ${filename}`);
return res.status(404).json({ error: 'File not found' });
}
try {
// 检查文件是否存在
if (!fs.existsSync(fileConfig.path)) {
logger.error(`❌ Whitelisted file not found: ${fileConfig.path}`);
return res.status(404).json({ error: 'File not found' });
}
// 读取并返回文件内容
const content = fs.readFileSync(fileConfig.path, 'utf8');
res.setHeader('Content-Type', fileConfig.contentType);
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.send(content);
logger.info(`📄 Served whitelisted file: ${filename}`);
} catch (error) {
logger.error(`❌ Error serving file ${filename}:`, error);
res.status(500).json({ error: 'Internal server error' });
}
}
// 🔐 管理员登录
router.post('/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: 'Missing credentials',
message: 'Username and password are required'
});
}
// 从Redis获取管理员信息
const adminData = await redis.getSession('admin_credentials');
if (!adminData || Object.keys(adminData).length === 0) {
return res.status(401).json({
error: 'Invalid credentials',
message: 'Invalid username or password'
});
}
// 验证用户名和密码
const isValidUsername = adminData.username === username;
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash);
if (!isValidUsername || !isValidPassword) {
logger.security(`🔒 Failed login attempt for username: ${username}`);
return res.status(401).json({
error: 'Invalid credentials',
message: 'Invalid username or password'
});
}
// 生成会话token
const sessionId = crypto.randomBytes(32).toString('hex');
// 存储会话
const sessionData = {
username: adminData.username,
loginTime: new Date().toISOString(),
lastActivity: new Date().toISOString()
};
await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout);
// 更新最后登录时间
adminData.lastLogin = new Date().toISOString();
await redis.setSession('admin_credentials', adminData);
logger.success(`🔐 Admin login successful: ${username}`);
res.json({
success: true,
token: sessionId,
expiresIn: config.security.adminSessionTimeout
});
} catch (error) {
logger.error('❌ Login error:', error);
res.status(500).json({
error: 'Login failed',
message: 'Internal server error'
});
}
});
// 🚪 管理员登出
router.post('/auth/logout', async (req, res) => {
try {
const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken;
if (token) {
await redis.deleteSession(token);
logger.success('🚪 Admin logout successful');
}
res.json({ success: true, message: 'Logout successful' });
} catch (error) {
logger.error('❌ Logout error:', error);
res.status(500).json({
error: 'Logout failed',
message: 'Internal server error'
});
}
});
// 🔄 刷新token
router.post('/auth/refresh', async (req, res) => {
try {
const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken;
if (!token) {
return res.status(401).json({
error: 'No token provided',
message: 'Authentication required'
});
}
const sessionData = await redis.getSession(token);
if (!sessionData) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
});
}
// 更新最后活动时间
sessionData.lastActivity = new Date().toISOString();
await redis.setSession(token, sessionData, config.security.adminSessionTimeout);
res.json({
success: true,
token: token,
expiresIn: config.security.adminSessionTimeout
});
} catch (error) {
logger.error('❌ Token refresh error:', error);
res.status(500).json({
error: 'Token refresh failed',
message: 'Internal server error'
});
}
});
// 🌐 Web管理界面路由 - 使用固定白名单
router.get('/', (req, res) => {
serveWhitelistedFile(req, res, 'index.html');
});
router.get('/app.js', (req, res) => {
serveWhitelistedFile(req, res, 'app.js');
});
router.get('/style.css', (req, res) => {
serveWhitelistedFile(req, res, 'style.css');
});
module.exports = router;

View File

@@ -0,0 +1,271 @@
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const config = require('../../config/config');
const redis = require('../models/redis');
const logger = require('../utils/logger');
class ApiKeyService {
constructor() {
this.prefix = config.security.apiKeyPrefix;
}
// 🔑 生成新的API Key
async generateApiKey(options = {}) {
const {
name = 'Unnamed Key',
description = '',
tokenLimit = config.limits.defaultTokenLimit,
requestLimit = config.limits.defaultRequestLimit,
expiresAt = null,
claudeAccountId = null,
isActive = true
} = options;
// 生成简单的API Key (64字符十六进制)
const apiKey = `${this.prefix}${this._generateSecretKey()}`;
const keyId = uuidv4();
const hashedKey = this._hashApiKey(apiKey);
const keyData = {
id: keyId,
name,
description,
apiKey: hashedKey,
tokenLimit: String(tokenLimit ?? 0),
requestLimit: String(requestLimit ?? 0),
isActive: String(isActive),
claudeAccountId: claudeAccountId || '',
createdAt: new Date().toISOString(),
lastUsedAt: '',
expiresAt: expiresAt || '',
createdBy: 'admin' // 可以根据需要扩展用户系统
};
// 保存API Key数据并建立哈希映射
await redis.setApiKey(keyId, keyData, hashedKey);
logger.success(`🔑 Generated new API key: ${name} (${keyId})`);
return {
id: keyId,
apiKey, // 只在创建时返回完整的key
name: keyData.name,
description: keyData.description,
tokenLimit: parseInt(keyData.tokenLimit),
requestLimit: parseInt(keyData.requestLimit),
isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId,
createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy
};
}
// 🔍 验证API Key
async validateApiKey(apiKey) {
try {
if (!apiKey || !apiKey.startsWith(this.prefix)) {
return { valid: false, error: 'Invalid API key format' };
}
// 计算API Key的哈希值
const hashedKey = this._hashApiKey(apiKey);
// 通过哈希值直接查找API Key性能优化
const keyData = await redis.findApiKeyByHash(hashedKey);
if (!keyData) {
return { valid: false, error: 'API key not found' };
}
// 检查是否激活
if (keyData.isActive !== 'true') {
return { valid: false, error: 'API key is disabled' };
}
// 检查是否过期
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
return { valid: false, error: 'API key has expired' };
}
// 检查使用限制
const usage = await redis.getUsageStats(keyData.id);
const tokenLimit = parseInt(keyData.tokenLimit);
const requestLimit = parseInt(keyData.requestLimit);
if (tokenLimit > 0 && usage.total.tokens >= tokenLimit) {
return { valid: false, error: 'Token limit exceeded' };
}
if (requestLimit > 0 && usage.total.requests >= requestLimit) {
return { valid: false, error: 'Request limit exceeded' };
}
// 更新最后使用时间优化只在实际API调用时更新而不是验证时
// 注意lastUsedAt的更新已移至recordUsage方法中
logger.api(`🔓 API key validated successfully: ${keyData.id}`);
return {
valid: true,
keyData: {
id: keyData.id,
name: keyData.name,
claudeAccountId: keyData.claudeAccountId,
tokenLimit: parseInt(keyData.tokenLimit),
requestLimit: parseInt(keyData.requestLimit),
usage
}
};
} catch (error) {
logger.error('❌ API key validation error:', error);
return { valid: false, error: 'Internal validation error' };
}
}
// 📋 获取所有API Keys
async getAllApiKeys() {
try {
const apiKeys = await redis.getAllApiKeys();
// 为每个key添加使用统计
for (const key of apiKeys) {
key.usage = await redis.getUsageStats(key.id);
key.tokenLimit = parseInt(key.tokenLimit);
key.requestLimit = parseInt(key.requestLimit);
key.isActive = key.isActive === 'true';
delete key.apiKey; // 不返回哈希后的key
}
return apiKeys;
} catch (error) {
logger.error('❌ Failed to get API keys:', error);
throw error;
}
}
// 📝 更新API Key
async updateApiKey(keyId, updates) {
try {
const keyData = await redis.getApiKey(keyId);
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found');
}
// 允许更新的字段
const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'isActive', 'claudeAccountId', 'expiresAt'];
const updatedData = { ...keyData };
for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) {
updatedData[field] = (value != null ? value : '').toString();
}
}
updatedData.updatedAt = new Date().toISOString();
// 更新时不需要重新建立哈希映射因为API Key本身没有变化
await redis.setApiKey(keyId, updatedData);
logger.success(`📝 Updated API key: ${keyId}`);
return { success: true };
} catch (error) {
logger.error('❌ Failed to update API key:', error);
throw error;
}
}
// 🗑️ 删除API Key
async deleteApiKey(keyId) {
try {
const result = await redis.deleteApiKey(keyId);
if (result === 0) {
throw new Error('API key not found');
}
logger.success(`🗑️ Deleted API key: ${keyId}`);
return { success: true };
} catch (error) {
logger.error('❌ Failed to delete API key:', error);
throw error;
}
}
// 📊 记录使用情况支持缓存token
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
try {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
// 更新最后使用时间(性能优化:只在实际使用时更新)
const keyData = await redis.getApiKey(keyId);
if (keyData && Object.keys(keyData).length > 0) {
keyData.lastUsedAt = new Date().toISOString();
// 使用记录时不需要重新建立哈希映射
await redis.setApiKey(keyId, keyData);
}
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
if (cacheCreateTokens > 0) logParts.push(`Cache Create: ${cacheCreateTokens}`);
if (cacheReadTokens > 0) logParts.push(`Cache Read: ${cacheReadTokens}`);
logParts.push(`Total: ${totalTokens} tokens`);
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`);
} catch (error) {
logger.error('❌ Failed to record usage:', error);
}
}
// 🔐 生成密钥
_generateSecretKey() {
return crypto.randomBytes(32).toString('hex');
}
// 🔒 哈希API Key
_hashApiKey(apiKey) {
return crypto.createHash('sha256').update(apiKey + config.security.encryptionKey).digest('hex');
}
// 📈 获取使用统计
async getUsageStats(keyId) {
return await redis.getUsageStats(keyId);
}
// 🚦 检查速率限制
async checkRateLimit(keyId, limit = null) {
const rateLimit = limit || config.rateLimit.maxRequests;
const window = Math.floor(config.rateLimit.windowMs / 1000);
return await redis.checkRateLimit(`apikey:${keyId}`, rateLimit, window);
}
// 🧹 清理过期的API Keys
async cleanupExpiredKeys() {
try {
const apiKeys = await redis.getAllApiKeys();
const now = new Date();
let cleanedCount = 0;
for (const key of apiKeys) {
if (key.expiresAt && new Date(key.expiresAt) < now) {
await redis.deleteApiKey(key.id);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`);
}
return cleanedCount;
} catch (error) {
logger.error('❌ Failed to cleanup expired keys:', error);
return 0;
}
}
}
module.exports = new ApiKeyService();

View File

@@ -0,0 +1,452 @@
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpsProxyAgent } = require('https-proxy-agent');
const axios = require('axios');
const redis = require('../models/redis');
const logger = require('../utils/logger');
const config = require('../../config/config');
class ClaudeAccountService {
constructor() {
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token';
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc';
this.ENCRYPTION_SALT = 'salt';
}
// 🏢 创建Claude账户
async createAccount(options = {}) {
const {
name = 'Unnamed Account',
description = '',
email = '',
password = '',
refreshToken = '',
claudeAiOauth = null, // Claude标准格式的OAuth数据
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
isActive = true
} = options;
const accountId = uuidv4();
let accountData;
if (claudeAiOauth) {
// 使用Claude标准格式的OAuth数据
accountData = {
id: accountId,
name,
description,
email: this._encryptSensitiveData(email),
password: this._encryptSensitiveData(password),
claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)),
accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken),
refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken),
expiresAt: claudeAiOauth.expiresAt.toString(),
scopes: claudeAiOauth.scopes.join(' '),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
createdAt: new Date().toISOString(),
lastUsedAt: '',
lastRefreshAt: '',
status: 'active', // 有OAuth数据的账户直接设为active
errorMessage: ''
};
} else {
// 兼容旧格式
accountData = {
id: accountId,
name,
description,
email: this._encryptSensitiveData(email),
password: this._encryptSensitiveData(password),
refreshToken: this._encryptSensitiveData(refreshToken),
accessToken: '',
expiresAt: '',
scopes: '',
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
createdAt: new Date().toISOString(),
lastUsedAt: '',
lastRefreshAt: '',
status: 'created', // created, active, expired, error
errorMessage: ''
};
}
await redis.setClaudeAccount(accountId, accountData);
logger.success(`🏢 Created Claude account: ${name} (${accountId})`);
return {
id: accountId,
name,
description,
email,
isActive,
proxy,
status: accountData.status,
createdAt: accountData.createdAt,
expiresAt: accountData.expiresAt,
scopes: claudeAiOauth ? claudeAiOauth.scopes : []
};
}
// 🔄 刷新Claude账户token
async refreshAccountToken(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found');
}
const refreshToken = this._decryptSensitiveData(accountData.refreshToken);
if (!refreshToken) {
throw new Error('No refresh token available');
}
// 创建代理agent
const agent = this._createProxyAgent(accountData.proxy);
const response = await axios.post(this.claudeApiUrl, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.claudeOauthClientId
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'User-Agent': 'claude-relay-service/1.0.0'
},
httpsAgent: agent,
timeout: 30000
});
if (response.status === 200) {
const { access_token, refresh_token, expires_in } = response.data;
// 更新账户数据
accountData.accessToken = this._encryptSensitiveData(access_token);
accountData.refreshToken = this._encryptSensitiveData(refresh_token);
accountData.expiresAt = (Date.now() + (expires_in * 1000)).toString();
accountData.lastRefreshAt = new Date().toISOString();
accountData.status = 'active';
accountData.errorMessage = '';
await redis.setClaudeAccount(accountId, accountData);
logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId})`);
return {
success: true,
accessToken: access_token,
expiresAt: accountData.expiresAt
};
} else {
throw new Error(`Token refresh failed with status: ${response.status}`);
}
} catch (error) {
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
// 更新错误状态
const accountData = await redis.getClaudeAccount(accountId);
if (accountData) {
accountData.status = 'error';
accountData.errorMessage = error.message;
await redis.setClaudeAccount(accountId, accountData);
}
throw error;
}
}
// 🎯 获取有效的访问token
async getValidAccessToken(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found');
}
if (accountData.isActive !== 'true') {
throw new Error('Account is disabled');
}
// 检查token是否过期
const expiresAt = parseInt(accountData.expiresAt);
const now = Date.now();
if (!expiresAt || now >= (expiresAt - 10000)) { // 10秒提前刷新
logger.info(`🔄 Token expired/expiring for account ${accountId}, refreshing...`);
const refreshResult = await this.refreshAccountToken(accountId);
return refreshResult.accessToken;
}
const accessToken = this._decryptSensitiveData(accountData.accessToken);
if (!accessToken) {
throw new Error('No access token available');
}
// 更新最后使用时间
accountData.lastUsedAt = new Date().toISOString();
await redis.setClaudeAccount(accountId, accountData);
return accessToken;
} catch (error) {
logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error);
throw error;
}
}
// 📋 获取所有Claude账户
async getAllAccounts() {
try {
const accounts = await redis.getAllClaudeAccounts();
// 处理返回数据,移除敏感信息
return accounts.map(account => ({
id: account.id,
name: account.name,
description: account.description,
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
isActive: account.isActive === 'true',
proxy: account.proxy ? JSON.parse(account.proxy) : null,
status: account.status,
errorMessage: account.errorMessage,
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
expiresAt: account.expiresAt
}));
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error);
throw error;
}
}
// 📝 更新Claude账户
async updateAccount(accountId, updates) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found');
}
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive'];
const updatedData = { ...accountData };
for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) {
if (['email', 'password', 'refreshToken'].includes(field)) {
updatedData[field] = this._encryptSensitiveData(value);
} else if (field === 'proxy') {
updatedData[field] = value ? JSON.stringify(value) : '';
} else {
updatedData[field] = value.toString();
}
}
}
updatedData.updatedAt = new Date().toISOString();
await redis.setClaudeAccount(accountId, updatedData);
logger.success(`📝 Updated Claude account: ${accountId}`);
return { success: true };
} catch (error) {
logger.error('❌ Failed to update Claude account:', error);
throw error;
}
}
// 🗑️ 删除Claude账户
async deleteAccount(accountId) {
try {
const result = await redis.deleteClaudeAccount(accountId);
if (result === 0) {
throw new Error('Account not found');
}
logger.success(`🗑️ Deleted Claude account: ${accountId}`);
return { success: true };
} catch (error) {
logger.error('❌ Failed to delete Claude account:', error);
throw error;
}
}
// 🎯 智能选择可用账户
async selectAvailableAccount() {
try {
const accounts = await redis.getAllClaudeAccounts();
const activeAccounts = accounts.filter(account =>
account.isActive === 'true' &&
account.status !== 'error'
);
if (activeAccounts.length === 0) {
throw new Error('No active Claude accounts available');
}
// 优先选择最近刷新过token的账户
const sortedAccounts = activeAccounts.sort((a, b) => {
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
return bLastRefresh - aLastRefresh;
});
return sortedAccounts[0].id;
} catch (error) {
logger.error('❌ Failed to select available account:', error);
throw error;
}
}
// 🌐 创建代理agent
_createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null;
}
try {
const proxy = JSON.parse(proxyConfig);
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
return new SocksProxyAgent(socksUrl);
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
return new HttpsProxyAgent(httpUrl);
}
} catch (error) {
logger.warn('⚠️ Invalid proxy configuration:', error);
}
return null;
}
// 🔐 加密敏感数据
_encryptSensitiveData(data) {
if (!data) return '';
try {
const key = this._generateEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
// 将IV和加密数据一起返回用:分隔
return iv.toString('hex') + ':' + encrypted;
} catch (error) {
logger.error('❌ Encryption error:', error);
return data;
}
}
// 🔓 解密敏感数据
_decryptSensitiveData(encryptedData) {
if (!encryptedData) return '';
try {
// 检查是否是新格式包含IV
if (encryptedData.includes(':')) {
// 新格式iv:encryptedData
const parts = encryptedData.split(':');
if (parts.length === 2) {
const key = this._generateEncryptionKey();
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
// 旧格式或格式错误,尝试旧方式解密(向后兼容)
// 注意在新版本Node.js中这将失败但我们会捕获错误
try {
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (oldError) {
// 如果旧方式也失败,返回原数据
logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message);
return encryptedData;
}
} catch (error) {
logger.error('❌ Decryption error:', error);
return encryptedData;
}
}
// 🔑 生成加密密钥(辅助方法)
_generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32);
}
// 🎭 掩码邮箱地址
_maskEmail(email) {
if (!email || !email.includes('@')) return email;
const [username, domain] = email.split('@');
const maskedUsername = username.length > 2
? `${username.slice(0, 2)}***${username.slice(-1)}`
: `${username.slice(0, 1)}***`;
return `${maskedUsername}@${domain}`;
}
// 🧹 清理错误账户
async cleanupErrorAccounts() {
try {
const accounts = await redis.getAllClaudeAccounts();
let cleanedCount = 0;
for (const account of accounts) {
if (account.status === 'error' && account.lastRefreshAt) {
const lastRefresh = new Date(account.lastRefreshAt);
const now = new Date();
const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60);
// 如果错误状态超过24小时尝试重新激活
if (hoursSinceLastRefresh > 24) {
account.status = 'created';
account.errorMessage = '';
await redis.setClaudeAccount(account.id, account);
cleanedCount++;
}
}
}
if (cleanedCount > 0) {
logger.success(`🧹 Reset ${cleanedCount} error accounts`);
}
return cleanedCount;
} catch (error) {
logger.error('❌ Failed to cleanup error accounts:', error);
return 0;
}
}
}
module.exports = new ClaudeAccountService();

View File

@@ -0,0 +1,526 @@
const https = require('https');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpsProxyAgent } = require('https-proxy-agent');
const claudeAccountService = require('./claudeAccountService');
const logger = require('../utils/logger');
const config = require('../../config/config');
class ClaudeRelayService {
constructor() {
this.claudeApiUrl = config.claude.apiUrl;
this.apiVersion = config.claude.apiVersion;
this.betaHeader = config.claude.betaHeader;
this.systemPrompt = config.claude.systemPrompt;
}
// 🚀 转发请求到Claude API
async relayRequest(requestBody, apiKeyData) {
try {
// 选择可用的Claude账户
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount();
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}`);
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
// 处理请求体
const processedBody = this._processRequestBody(requestBody);
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId);
// 发送请求到Claude API
const response = await this._makeClaudeRequest(processedBody, accessToken, proxyAgent);
// 记录成功的API调用
const inputTokens = requestBody.messages ?
requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算
const outputTokens = response.content ?
response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 : 0;
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
return response;
} catch (error) {
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
throw error;
}
}
// 🔄 处理请求体
_processRequestBody(body) {
if (!body) return body;
// 深拷贝请求体
const processedBody = JSON.parse(JSON.stringify(body));
// 移除cache_control中的ttl字段
this._stripTtlFromCacheControl(processedBody);
// 只有在配置了系统提示时才添加
if (this.systemPrompt && this.systemPrompt.trim()) {
const systemPrompt = {
type: 'text',
text: this.systemPrompt
};
if (processedBody.system) {
if (Array.isArray(processedBody.system)) {
// 如果system数组存在但为空或者没有有效内容则添加系统提示
const hasValidContent = processedBody.system.some(item =>
item && item.text && item.text.trim()
);
if (!hasValidContent) {
processedBody.system = [systemPrompt];
} else {
processedBody.system.unshift(systemPrompt);
}
} else {
throw new Error('system field must be an array');
}
} else {
processedBody.system = [systemPrompt];
}
} else {
// 如果没有配置系统提示且system字段为空则删除它
if (processedBody.system && Array.isArray(processedBody.system)) {
const hasValidContent = processedBody.system.some(item =>
item && item.text && item.text.trim()
);
if (!hasValidContent) {
delete processedBody.system;
}
}
}
return processedBody;
}
// 🧹 移除TTL字段
_stripTtlFromCacheControl(body) {
if (!body || typeof body !== 'object') return;
const processContentArray = (contentArray) => {
if (!Array.isArray(contentArray)) return;
contentArray.forEach(item => {
if (item && typeof item === 'object' && item.cache_control) {
if (item.cache_control.ttl) {
delete item.cache_control.ttl;
logger.debug('🧹 Removed ttl from cache_control');
}
}
});
};
if (Array.isArray(body.system)) {
processContentArray(body.system);
}
if (Array.isArray(body.messages)) {
body.messages.forEach(message => {
if (message && Array.isArray(message.content)) {
processContentArray(message.content);
}
});
}
}
// 🌐 获取代理Agent
async _getProxyAgent(accountId) {
try {
const accountData = await claudeAccountService.getAllAccounts();
const account = accountData.find(acc => acc.id === accountId);
if (!account || !account.proxy) {
return null;
}
const proxy = account.proxy;
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
return new SocksProxyAgent(socksUrl);
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
return new HttpsProxyAgent(httpUrl);
}
} catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error);
}
return null;
}
// 🔗 发送请求到Claude API
async _makeClaudeRequest(body, accessToken, proxyAgent) {
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
'User-Agent': 'claude-relay-service/1.0.0'
},
agent: proxyAgent,
timeout: config.proxy.timeout
};
if (this.betaHeader) {
options.headers['anthropic-beta'] = this.betaHeader;
}
const req = https.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
const response = {
statusCode: res.statusCode,
headers: res.headers,
body: responseData
};
logger.debug(`🔗 Claude API response: ${res.statusCode}`);
resolve(response);
} catch (error) {
logger.error('❌ Failed to parse Claude API response:', error);
reject(error);
}
});
});
req.on('error', (error) => {
logger.error('❌ Claude API request error:', error);
reject(error);
});
req.on('timeout', () => {
req.destroy();
logger.error('❌ Claude API request timeout');
reject(new Error('Request timeout'));
});
// 写入请求体
req.write(JSON.stringify(body));
req.end();
});
}
// 🌊 处理流式响应带usage数据捕获
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, usageCallback) {
try {
// 选择可用的Claude账户
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount();
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}`);
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
// 处理请求体
const processedBody = this._processRequestBody(requestBody);
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId);
// 发送流式请求并捕获usage数据
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, responseStream, usageCallback);
} catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error);
throw error;
}
}
// 🌊 发送流式请求到Claude API带usage数据捕获
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, responseStream, usageCallback) {
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
'User-Agent': 'claude-relay-service/1.0.0'
},
agent: proxyAgent,
timeout: config.proxy.timeout
};
if (this.betaHeader) {
options.headers['anthropic-beta'] = this.betaHeader;
}
const req = https.request(options, (res) => {
// 设置响应头
responseStream.statusCode = res.statusCode;
Object.keys(res.headers).forEach(key => {
responseStream.setHeader(key, res.headers[key]);
});
let buffer = '';
let finalUsageReported = false; // 防止重复统计的标志
let collectedUsageData = {}; // 收集来自不同事件的usage数据
// 监听数据块解析SSE并寻找usage信息
res.on('data', (chunk) => {
const chunkStr = chunk.toString();
// 记录原始SSE数据块
logger.info('📡 Raw SSE chunk received:', {
length: chunkStr.length,
content: chunkStr
});
buffer += chunkStr;
// 处理完整的SSE行
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后的不完整行
// 转发已处理的完整行到客户端
if (lines.length > 0) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '');
responseStream.write(linesToForward);
}
for (const line of lines) {
// 记录每个SSE行
if (line.trim()) {
logger.info('📄 SSE Line:', line);
}
// 解析SSE数据寻找usage信息
if (line.startsWith('data: ') && line.length > 6) {
try {
const jsonStr = line.slice(6);
const data = JSON.parse(jsonStr);
// 收集来自不同事件的usage数据
if (data.type === 'message_start' && data.message && data.message.usage) {
// message_start包含input tokens、cache tokens和模型信息
collectedUsageData.input_tokens = data.message.usage.input_tokens || 0;
collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0;
collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0;
collectedUsageData.model = data.message.model;
logger.info('📊 Collected input/cache data from message_start:', JSON.stringify(collectedUsageData));
}
// message_delta包含最终的output tokens
if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) {
collectedUsageData.output_tokens = data.usage.output_tokens || 0;
logger.info('📊 Collected output data from message_delta:', JSON.stringify(collectedUsageData));
// 如果已经收集到了input数据现在有了output数据可以统计了
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
logger.info('🎯 Complete usage data collected, triggering callback');
usageCallback(collectedUsageData);
finalUsageReported = true;
}
}
} catch (parseError) {
// 忽略JSON解析错误继续处理
logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100));
}
}
}
});
res.on('end', () => {
// 处理缓冲区中剩余的数据
if (buffer.trim()) {
responseStream.write(buffer);
}
responseStream.end();
// 检查是否捕获到usage数据
if (!finalUsageReported) {
logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.');
}
logger.debug('🌊 Claude stream response with usage capture completed');
resolve();
});
});
req.on('error', (error) => {
logger.error('❌ Claude stream request error:', error);
if (!responseStream.headersSent) {
responseStream.writeHead(500, { 'Content-Type': 'application/json' });
}
if (!responseStream.destroyed) {
responseStream.end(JSON.stringify({ error: 'Upstream request failed' }));
}
reject(error);
});
req.on('timeout', () => {
req.destroy();
logger.error('❌ Claude stream request timeout');
if (!responseStream.headersSent) {
responseStream.writeHead(504, { 'Content-Type': 'application/json' });
}
if (!responseStream.destroyed) {
responseStream.end(JSON.stringify({ error: 'Request timeout' }));
}
reject(new Error('Request timeout'));
});
// 处理客户端断开连接
responseStream.on('close', () => {
logger.debug('🔌 Client disconnected, cleaning up stream');
if (!req.destroyed) {
req.destroy();
}
});
// 写入请求体
req.write(JSON.stringify(body));
req.end();
});
}
// 🌊 发送流式请求到Claude API
async _makeClaudeStreamRequest(body, accessToken, proxyAgent, responseStream) {
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': this.apiVersion,
'User-Agent': 'claude-relay-service/1.0.0'
},
agent: proxyAgent,
timeout: config.proxy.timeout
};
if (this.betaHeader) {
options.headers['anthropic-beta'] = this.betaHeader;
}
const req = https.request(options, (res) => {
// 设置响应头
responseStream.statusCode = res.statusCode;
Object.keys(res.headers).forEach(key => {
responseStream.setHeader(key, res.headers[key]);
});
// 管道响应数据
res.pipe(responseStream);
res.on('end', () => {
logger.debug('🌊 Claude stream response completed');
resolve();
});
});
req.on('error', (error) => {
logger.error('❌ Claude stream request error:', error);
if (!responseStream.headersSent) {
responseStream.writeHead(500, { 'Content-Type': 'application/json' });
}
if (!responseStream.destroyed) {
responseStream.end(JSON.stringify({ error: 'Upstream request failed' }));
}
reject(error);
});
req.on('timeout', () => {
req.destroy();
logger.error('❌ Claude stream request timeout');
if (!responseStream.headersSent) {
responseStream.writeHead(504, { 'Content-Type': 'application/json' });
}
if (!responseStream.destroyed) {
responseStream.end(JSON.stringify({ error: 'Request timeout' }));
}
reject(new Error('Request timeout'));
});
// 处理客户端断开连接
responseStream.on('close', () => {
logger.debug('🔌 Client disconnected, cleaning up stream');
if (!req.destroyed) {
req.destroy();
}
});
// 写入请求体
req.write(JSON.stringify(body));
req.end();
});
}
// 🔄 重试逻辑
async _retryRequest(requestFunc, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await requestFunc();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000; // 指数退避
logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// 🎯 健康检查
async healthCheck() {
try {
const accounts = await claudeAccountService.getAllAccounts();
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active');
return {
healthy: activeAccounts.length > 0,
activeAccounts: activeAccounts.length,
totalAccounts: accounts.length,
timestamp: new Date().toISOString()
};
} catch (error) {
logger.error('❌ Health check failed:', error);
return {
healthy: false,
error: error.message,
timestamp: new Date().toISOString()
};
}
}
}
module.exports = new ClaudeRelayService();

View File

@@ -0,0 +1,234 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
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 = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
this.pricingData = null;
this.lastUpdated = null;
this.updateInterval = 24 * 60 * 60 * 1000; // 24小时
}
// 初始化价格服务
async initialize() {
try {
// 确保data目录存在
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true });
logger.info('📁 Created data directory');
}
// 检查是否需要下载或更新价格数据
await this.checkAndUpdatePricing();
// 设置定时更新
setInterval(() => {
this.checkAndUpdatePricing();
}, this.updateInterval);
logger.success('💰 Pricing service initialized successfully');
} catch (error) {
logger.error('❌ Failed to initialize pricing service:', error);
}
}
// 检查并更新价格数据
async checkAndUpdatePricing() {
try {
const needsUpdate = this.needsUpdate();
if (needsUpdate) {
logger.info('🔄 Updating model pricing data...');
await this.downloadPricingData();
} else {
// 如果不需要更新,加载现有数据
await this.loadPricingData();
}
} catch (error) {
logger.error('❌ Failed to check/update pricing:', error);
// 如果更新失败,尝试加载现有数据
await this.loadPricingData();
}
}
// 检查是否需要更新
needsUpdate() {
if (!fs.existsSync(this.pricingFile)) {
logger.info('📋 Pricing file not found, will download');
return true;
}
const stats = fs.statSync(this.pricingFile);
const fileAge = Date.now() - stats.mtime.getTime();
if (fileAge > this.updateInterval) {
logger.info(`📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update`);
return true;
}
return false;
}
// 下载价格数据
downloadPricingData() {
return new Promise((resolve, reject) => {
const request = https.get(this.pricingUrl, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
return;
}
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
try {
const jsonData = JSON.parse(data);
// 保存到文件
fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2));
// 更新内存中的数据
this.pricingData = jsonData;
this.lastUpdated = new Date();
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`);
resolve();
} catch (error) {
reject(new Error(`Failed to parse pricing data: ${error.message}`));
}
});
});
request.on('error', (error) => {
reject(new Error(`Failed to download pricing data: ${error.message}`));
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
// 加载本地价格数据
async loadPricingData() {
try {
if (fs.existsSync(this.pricingFile)) {
const data = fs.readFileSync(this.pricingFile, 'utf8');
this.pricingData = JSON.parse(data);
const stats = fs.statSync(this.pricingFile);
this.lastUpdated = stats.mtime;
logger.info(`💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache`);
} else {
logger.warn('💰 No pricing data file found');
this.pricingData = {};
}
} catch (error) {
logger.error('❌ Failed to load pricing data:', error);
this.pricingData = {};
}
}
// 获取模型价格信息
getModelPricing(modelName) {
if (!this.pricingData || !modelName) {
return null;
}
// 尝试直接匹配
if (this.pricingData[modelName]) {
return this.pricingData[modelName];
}
// 尝试模糊匹配(处理版本号等变化)
const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '');
for (const [key, value] of Object.entries(this.pricingData)) {
const normalizedKey = key.toLowerCase().replace(/[_-]/g, '');
if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) {
logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`);
return value;
}
}
logger.debug(`💰 No pricing found for model: ${modelName}`);
return null;
}
// 计算使用费用
calculateCost(usage, modelName) {
const pricing = this.getModelPricing(modelName);
if (!pricing) {
return {
inputCost: 0,
outputCost: 0,
cacheCreateCost: 0,
cacheReadCost: 0,
totalCost: 0,
hasPricing: false
};
}
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0);
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0);
const cacheCreateCost = (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0);
const cacheReadCost = (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0);
return {
inputCost,
outputCost,
cacheCreateCost,
cacheReadCost,
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
hasPricing: true,
pricing: {
input: pricing.input_cost_per_token || 0,
output: pricing.output_cost_per_token || 0,
cacheCreate: pricing.cache_creation_input_token_cost || 0,
cacheRead: pricing.cache_read_input_token_cost || 0
}
};
}
// 格式化价格显示
formatCost(cost) {
if (cost === 0) return '$0.000000';
if (cost < 0.000001) return `$${cost.toExponential(2)}`;
if (cost < 0.01) return `$${cost.toFixed(6)}`;
if (cost < 1) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(2)}`;
}
// 获取服务状态
getStatus() {
return {
initialized: this.pricingData !== null,
lastUpdated: this.lastUpdated,
modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0,
nextUpdate: this.lastUpdated ? new Date(this.lastUpdated.getTime() + this.updateInterval) : null
};
}
// 强制更新价格数据
async forceUpdate() {
try {
await this.downloadPricingData();
return { success: true, message: 'Pricing data updated successfully' };
} catch (error) {
logger.error('❌ Force update failed:', error);
return { success: false, message: error.message };
}
}
}
module.exports = new PricingService();

224
src/utils/costCalculator.js Normal file
View File

@@ -0,0 +1,224 @@
const pricingService = require('../services/pricingService');
// Claude模型价格配置 (USD per 1M tokens) - 备用定价
const MODEL_PRICING = {
// Claude 3.5 Sonnet
'claude-3-5-sonnet-20241022': {
input: 3.00,
output: 15.00,
cacheWrite: 3.75,
cacheRead: 0.30
},
'claude-sonnet-4-20250514': {
input: 3.00,
output: 15.00,
cacheWrite: 3.75,
cacheRead: 0.30
},
// Claude 3.5 Haiku
'claude-3-5-haiku-20241022': {
input: 0.25,
output: 1.25,
cacheWrite: 0.30,
cacheRead: 0.03
},
// Claude 3 Opus
'claude-3-opus-20240229': {
input: 15.00,
output: 75.00,
cacheWrite: 18.75,
cacheRead: 1.50
},
// Claude 3 Sonnet
'claude-3-sonnet-20240229': {
input: 3.00,
output: 15.00,
cacheWrite: 3.75,
cacheRead: 0.30
},
// Claude 3 Haiku
'claude-3-haiku-20240307': {
input: 0.25,
output: 1.25,
cacheWrite: 0.30,
cacheRead: 0.03
},
// 默认定价(用于未知模型)
'unknown': {
input: 3.00,
output: 15.00,
cacheWrite: 3.75,
cacheRead: 0.30
}
};
class CostCalculator {
/**
* 计算单次请求的费用
* @param {Object} usage - 使用量数据
* @param {number} usage.input_tokens - 输入token数量
* @param {number} usage.output_tokens - 输出token数量
* @param {number} usage.cache_creation_input_tokens - 缓存创建token数量
* @param {number} usage.cache_read_input_tokens - 缓存读取token数量
* @param {string} model - 模型名称
* @returns {Object} 费用详情
*/
static calculateCost(usage, model = 'unknown') {
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const cacheCreateTokens = usage.cache_creation_input_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;
// 优先使用动态价格服务
const pricingData = pricingService.getModelPricing(model);
let pricing;
let usingDynamicPricing = false;
if (pricingData) {
// 转换动态价格格式为内部格式
pricing = {
input: (pricingData.input_cost_per_token || 0) * 1000000, // 转换为per 1M tokens
output: (pricingData.output_cost_per_token || 0) * 1000000,
cacheWrite: (pricingData.cache_creation_input_token_cost || 0) * 1000000,
cacheRead: (pricingData.cache_read_input_token_cost || 0) * 1000000
};
usingDynamicPricing = true;
} else {
// 回退到静态价格
pricing = MODEL_PRICING[model] || MODEL_PRICING['unknown'];
}
// 计算各类型token的费用 (USD)
const inputCost = (inputTokens / 1000000) * pricing.input;
const outputCost = (outputTokens / 1000000) * pricing.output;
const cacheWriteCost = (cacheCreateTokens / 1000000) * pricing.cacheWrite;
const cacheReadCost = (cacheReadTokens / 1000000) * pricing.cacheRead;
const totalCost = inputCost + outputCost + cacheWriteCost + cacheReadCost;
return {
model,
pricing,
usingDynamicPricing,
usage: {
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
},
costs: {
input: inputCost,
output: outputCost,
cacheWrite: cacheWriteCost,
cacheRead: cacheReadCost,
total: totalCost
},
// 格式化的费用字符串
formatted: {
input: this.formatCost(inputCost),
output: this.formatCost(outputCost),
cacheWrite: this.formatCost(cacheWriteCost),
cacheRead: this.formatCost(cacheReadCost),
total: this.formatCost(totalCost)
}
};
}
/**
* 计算聚合使用量的费用
* @param {Object} aggregatedUsage - 聚合使用量数据
* @param {string} model - 模型名称
* @returns {Object} 费用详情
*/
static calculateAggregatedCost(aggregatedUsage, model = 'unknown') {
const usage = {
input_tokens: aggregatedUsage.inputTokens || aggregatedUsage.totalInputTokens || 0,
output_tokens: aggregatedUsage.outputTokens || aggregatedUsage.totalOutputTokens || 0,
cache_creation_input_tokens: aggregatedUsage.cacheCreateTokens || aggregatedUsage.totalCacheCreateTokens || 0,
cache_read_input_tokens: aggregatedUsage.cacheReadTokens || aggregatedUsage.totalCacheReadTokens || 0
};
return this.calculateCost(usage, model);
}
/**
* 获取模型定价信息
* @param {string} model - 模型名称
* @returns {Object} 定价信息
*/
static getModelPricing(model = 'unknown') {
return MODEL_PRICING[model] || MODEL_PRICING['unknown'];
}
/**
* 获取所有支持的模型和定价
* @returns {Object} 所有模型定价
*/
static getAllModelPricing() {
return { ...MODEL_PRICING };
}
/**
* 验证模型是否支持
* @param {string} model - 模型名称
* @returns {boolean} 是否支持
*/
static isModelSupported(model) {
return !!MODEL_PRICING[model];
}
/**
* 格式化费用显示
* @param {number} cost - 费用金额
* @param {number} decimals - 小数位数
* @returns {string} 格式化的费用字符串
*/
static formatCost(cost, decimals = 6) {
if (cost >= 1) {
return `$${cost.toFixed(2)}`;
} else if (cost >= 0.001) {
return `$${cost.toFixed(4)}`;
} else {
return `$${cost.toFixed(decimals)}`;
}
}
/**
* 计算费用节省(使用缓存的节省)
* @param {Object} usage - 使用量数据
* @param {string} model - 模型名称
* @returns {Object} 节省信息
*/
static calculateCacheSavings(usage, model = 'unknown') {
const pricing = this.getModelPricing(model);
const cacheReadTokens = usage.cache_read_input_tokens || 0;
// 如果这些token不使用缓存需要按正常input价格计费
const normalCost = (cacheReadTokens / 1000000) * pricing.input;
const cacheCost = (cacheReadTokens / 1000000) * pricing.cacheRead;
const savings = normalCost - cacheCost;
const savingsPercentage = normalCost > 0 ? (savings / normalCost) * 100 : 0;
return {
normalCost,
cacheCost,
savings,
savingsPercentage,
formatted: {
normalCost: this.formatCost(normalCost),
cacheCost: this.formatCost(cacheCost),
savings: this.formatCost(savings),
savingsPercentage: `${savingsPercentage.toFixed(1)}%`
}
};
}
}
module.exports = CostCalculator;

290
src/utils/logger.js Normal file
View File

@@ -0,0 +1,290 @@
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const config = require('../../config/config');
const path = require('path');
const fs = require('fs');
const os = require('os');
// 📝 增强的日志格式
const createLogFormat = (colorize = false) => {
const formats = [
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] })
];
if (colorize) {
formats.push(winston.format.colorize());
}
formats.push(
winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => {
const emoji = {
error: '❌',
warn: '⚠️ ',
info: ' ',
debug: '🐛',
verbose: '📝'
};
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}`;
// 添加元数据
if (metadata && Object.keys(metadata).length > 0) {
logMessage += ` | ${JSON.stringify(metadata)}`;
}
// 添加其他属性
const additionalData = { ...rest };
delete additionalData.level;
delete additionalData.message;
delete additionalData.timestamp;
delete additionalData.stack;
if (Object.keys(additionalData).length > 0) {
logMessage += ` | ${JSON.stringify(additionalData)}`;
}
return stack ? `${logMessage}\n${stack}` : logMessage;
})
);
return winston.format.combine(...formats);
};
const logFormat = createLogFormat(false);
const consoleFormat = createLogFormat(true);
// 📁 确保日志目录存在并设置权限
if (!fs.existsSync(config.logging.dirname)) {
fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 });
}
// 🔄 增强的日志轮转配置
const createRotateTransport = (filename, level = null) => {
const transport = new DailyRotateFile({
filename: path.join(config.logging.dirname, filename),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: config.logging.maxSize,
maxFiles: config.logging.maxFiles,
auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`),
format: logFormat
});
if (level) {
transport.level = level;
}
// 监听轮转事件
transport.on('rotate', (oldFilename, newFilename) => {
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`);
});
transport.on('new', (newFilename) => {
console.log(`📄 New log file created: ${newFilename}`);
});
transport.on('archive', (zipFilename) => {
console.log(`🗜️ Log archived: ${zipFilename}`);
});
return transport;
};
const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log');
const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error');
// 🔒 创建专门的安全日志记录器
const securityLogger = winston.createLogger({
level: 'warn',
format: logFormat,
transports: [
createRotateTransport('claude-relay-security-%DATE%.log', 'warn')
],
silent: false
});
// 🌟 增强的 Winston logger
const logger = winston.createLogger({
level: config.logging.level,
format: logFormat,
transports: [
// 📄 文件输出
dailyRotateFileTransport,
errorFileTransport,
// 🖥️ 控制台输出
new winston.transports.Console({
format: consoleFormat,
handleExceptions: false,
handleRejections: false
})
],
// 🚨 异常处理
exceptionHandlers: [
new winston.transports.File({
filename: path.join(config.logging.dirname, 'exceptions.log'),
format: logFormat,
maxsize: 10485760, // 10MB
maxFiles: 5
}),
new winston.transports.Console({
format: consoleFormat
})
],
// 🔄 未捕获异常处理
rejectionHandlers: [
new winston.transports.File({
filename: path.join(config.logging.dirname, 'rejections.log'),
format: logFormat,
maxsize: 10485760, // 10MB
maxFiles: 5
}),
new winston.transports.Console({
format: consoleFormat
})
],
// 防止进程退出
exitOnError: false
});
// 🎯 增强的自定义方法
logger.success = (message, metadata = {}) => {
logger.info(`${message}`, { type: 'success', ...metadata });
};
logger.start = (message, metadata = {}) => {
logger.info(`🚀 ${message}`, { type: 'startup', ...metadata });
};
logger.request = (method, url, status, duration, metadata = {}) => {
const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢';
const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info';
logger[level](`${emoji} ${method} ${url} - ${status} (${duration}ms)`, {
type: 'request',
method,
url,
status,
duration,
...metadata
});
};
logger.api = (message, metadata = {}) => {
logger.info(`🔗 ${message}`, { type: 'api', ...metadata });
};
logger.security = (message, metadata = {}) => {
const securityData = {
type: 'security',
timestamp: new Date().toISOString(),
pid: process.pid,
hostname: os.hostname(),
...metadata
};
// 记录到主日志
logger.warn(`🔒 ${message}`, securityData);
// 记录到专门的安全日志文件
try {
securityLogger.warn(`🔒 ${message}`, securityData);
} catch (error) {
// 如果安全日志文件不可用,只记录到主日志
console.warn('Security logger not available:', error.message);
}
};
logger.database = (message, metadata = {}) => {
logger.debug(`💾 ${message}`, { type: 'database', ...metadata });
};
logger.performance = (message, metadata = {}) => {
logger.info(`${message}`, { type: 'performance', ...metadata });
};
logger.audit = (message, metadata = {}) => {
logger.info(`📋 ${message}`, {
type: 'audit',
timestamp: new Date().toISOString(),
pid: process.pid,
...metadata
});
};
// 🔧 性能监控方法
logger.timer = (label) => {
const start = Date.now();
return {
end: (message = '', metadata = {}) => {
const duration = Date.now() - start;
logger.performance(`${label} ${message}`, { duration, ...metadata });
return duration;
}
};
};
// 📊 日志统计
logger.stats = {
requests: 0,
errors: 0,
warnings: 0
};
// 重写原始方法以统计
const originalError = logger.error;
const originalWarn = logger.warn;
const originalInfo = logger.info;
logger.error = function(message, metadata = {}) {
logger.stats.errors++;
return originalError.call(this, message, metadata);
};
logger.warn = function(message, metadata = {}) {
logger.stats.warnings++;
return originalWarn.call(this, message, metadata);
};
logger.info = function(message, metadata = {}) {
if (metadata.type === 'request') {
logger.stats.requests++;
}
return originalInfo.call(this, message, metadata);
};
// 📈 获取日志统计
logger.getStats = () => ({ ...logger.stats });
// 🧹 清理统计
logger.resetStats = () => {
logger.stats.requests = 0;
logger.stats.errors = 0;
logger.stats.warnings = 0;
};
// 📡 健康检查
logger.healthCheck = () => {
try {
const testMessage = 'Logger health check';
logger.debug(testMessage);
return { healthy: true, timestamp: new Date().toISOString() };
} catch (error) {
return { healthy: false, error: error.message, timestamp: new Date().toISOString() };
}
};
// 🎬 启动日志记录系统
logger.start('Logger initialized', {
level: config.logging.level,
directory: config.logging.dirname,
maxSize: config.logging.maxSize,
maxFiles: config.logging.maxFiles
});
module.exports = logger;

307
src/utils/oauthHelper.js Normal file
View File

@@ -0,0 +1,307 @@
/**
* OAuth助手工具
* 基于claude-code-login.js中的OAuth流程实现
*/
const crypto = require('crypto');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpsProxyAgent } = require('https-proxy-agent');
const axios = require('axios');
const logger = require('./logger');
// OAuth 配置常量 - 从claude-code-login.js提取
const OAUTH_CONFIG = {
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference'
};
/**
* 生成随机的 state 参数
* @returns {string} 随机生成的 state (64字符hex)
*/
function generateState() {
return crypto.randomBytes(32).toString('hex');
}
/**
* 生成随机的 code verifierPKCE
* @returns {string} base64url 编码的随机字符串
*/
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/**
* 生成 code challengePKCE
* @param {string} codeVerifier - code verifier 字符串
* @returns {string} SHA256 哈希后的 base64url 编码字符串
*/
function generateCodeChallenge(codeVerifier) {
return crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
}
/**
* 生成授权 URL
* @param {string} codeChallenge - PKCE code challenge
* @param {string} state - state 参数
* @returns {string} 完整的授权 URL
*/
function generateAuthUrl(codeChallenge, state) {
const params = new URLSearchParams({
code: 'true',
client_id: OAUTH_CONFIG.CLIENT_ID,
response_type: 'code',
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
scope: OAUTH_CONFIG.SCOPES,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}`;
}
/**
* 生成OAuth授权URL和相关参数
* @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}}
*/
function generateOAuthParams() {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const authUrl = generateAuthUrl(codeChallenge, state);
return {
authUrl,
codeVerifier,
state,
codeChallenge
};
}
/**
* 创建代理agent
* @param {object|null} proxyConfig - 代理配置对象
* @returns {object|null} 代理agent或null
*/
function createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null;
}
try {
if (proxyConfig.type === 'socks5') {
const auth = proxyConfig.username && proxyConfig.password ? `${proxyConfig.username}:${proxyConfig.password}@` : '';
const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}`;
return new SocksProxyAgent(socksUrl);
} else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') {
const auth = proxyConfig.username && proxyConfig.password ? `${proxyConfig.username}:${proxyConfig.password}@` : '';
const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}`;
return new HttpsProxyAgent(httpUrl);
}
} catch (error) {
console.warn('⚠️ Invalid proxy configuration:', error);
}
return null;
}
/**
* 使用授权码交换访问令牌
* @param {string} authorizationCode - 授权码
* @param {string} codeVerifier - PKCE code verifier
* @param {string} state - state 参数
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<object>} Claude格式的token响应
*/
async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig = null) {
// 清理授权码移除URL片段
const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode;
const params = {
grant_type: 'authorization_code',
client_id: OAUTH_CONFIG.CLIENT_ID,
code: cleanedCode,
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
code_verifier: codeVerifier,
state: state
};
// 创建代理agent
const agent = createProxyAgent(proxyConfig);
try {
logger.debug('🔄 Attempting OAuth token exchange', {
url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length,
codePrefix: cleanedCode.substring(0, 10) + '...',
hasProxy: !!proxyConfig,
proxyType: proxyConfig?.type || 'none'
});
const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://claude.ai/',
'Origin': 'https://claude.ai'
},
httpsAgent: agent,
timeout: 30000
});
logger.success('✅ OAuth token exchange successful', {
status: response.status,
hasAccessToken: !!response.data?.access_token,
hasRefreshToken: !!response.data?.refresh_token,
scopes: response.data?.scope
});
const data = response.data;
// 返回Claude格式的token数据
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000,
scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'],
isMax: true
};
} catch (error) {
// 处理axios错误响应
if (error.response) {
// 服务器返回了错误状态码
const status = error.response.status;
const errorData = error.response.data;
logger.error('❌ OAuth token exchange failed with server error', {
status: status,
statusText: error.response.statusText,
headers: error.response.headers,
data: errorData,
codeLength: cleanedCode.length,
codePrefix: cleanedCode.substring(0, 10) + '...'
});
// 尝试从错误响应中提取有用信息
let errorMessage = `HTTP ${status}`;
if (errorData) {
if (typeof errorData === 'string') {
errorMessage += `: ${errorData}`;
} else if (errorData.error) {
errorMessage += `: ${errorData.error}`;
if (errorData.error_description) {
errorMessage += ` - ${errorData.error_description}`;
}
} else {
errorMessage += `: ${JSON.stringify(errorData)}`;
}
}
throw new Error(`Token exchange failed: ${errorMessage}`);
} else if (error.request) {
// 请求被发送但没有收到响应
logger.error('❌ OAuth token exchange failed with network error', {
message: error.message,
code: error.code,
hasProxy: !!proxyConfig
});
throw new Error('Token exchange failed: No response from server (network error or timeout)');
} else {
// 其他错误
logger.error('❌ OAuth token exchange failed with unknown error', {
message: error.message,
stack: error.stack
});
throw new Error(`Token exchange failed: ${error.message}`);
}
}
}
/**
* 解析回调 URL 或授权码
* @param {string} input - 完整的回调 URL 或直接的授权码
* @returns {string} 授权码
*/
function parseCallbackUrl(input) {
if (!input || typeof input !== 'string') {
throw new Error('请提供有效的授权码或回调 URL');
}
const trimmedInput = input.trim();
// 情况1: 尝试作为完整URL解析
if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) {
try {
const urlObj = new URL(trimmedInput);
const authorizationCode = urlObj.searchParams.get('code');
if (!authorizationCode) {
throw new Error('回调 URL 中未找到授权码 (code 参数)');
}
return authorizationCode;
} catch (error) {
if (error.message.includes('回调 URL 中未找到授权码')) {
throw error;
}
throw new Error('无效的 URL 格式,请检查回调 URL 是否正确');
}
}
// 情况2: 直接的授权码可能包含URL fragments
// 参考claude-code-login.js的处理方式移除URL fragments和参数
const cleanedCode = trimmedInput.split('#')[0]?.split('&')[0] ?? trimmedInput;
// 验证授权码格式Claude的授权码通常是base64url格式
if (!cleanedCode || cleanedCode.length < 10) {
throw new Error('授权码格式无效,请确保复制了完整的 Authorization Code');
}
// 基本格式验证:授权码应该只包含字母、数字、下划线、连字符
const validCodePattern = /^[A-Za-z0-9_-]+$/;
if (!validCodePattern.test(cleanedCode)) {
throw new Error('授权码包含无效字符,请检查是否复制了正确的 Authorization Code');
}
return cleanedCode;
}
/**
* 格式化为Claude标准格式
* @param {object} tokenData - token数据
* @returns {object} claudeAiOauth格式的数据
*/
function formatClaudeCredentials(tokenData) {
return {
claudeAiOauth: {
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: tokenData.expiresAt,
scopes: tokenData.scopes,
isMax: tokenData.isMax
}
};
}
module.exports = {
OAUTH_CONFIG,
generateOAuthParams,
exchangeCodeForTokens,
parseCallbackUrl,
formatClaudeCredentials,
generateState,
generateCodeVerifier,
generateCodeChallenge,
generateAuthUrl,
createProxyAgent
};

1989
web/admin/app.js Normal file

File diff suppressed because it is too large Load Diff

2058
web/admin/index.html Normal file

File diff suppressed because it is too large Load Diff

395
web/admin/style.css Normal file
View File

@@ -0,0 +1,395 @@
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--accent-color: #f093fb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--surface-color: rgba(255, 255, 255, 0.95);
--glass-color: rgba(255, 255, 255, 0.1);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-color: rgba(255, 255, 255, 0.2);
}
/* 通用transition - 仅应用于特定元素 */
body, div, button, input, select, textarea, table, tr, td, th, span, p, h1, h2, h3, h4, h5, h6 {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
background-attachment: fixed;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
.glass {
background: var(--glass-color);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: var(--surface-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.tab-btn {
position: relative;
overflow: hidden;
border-radius: 12px;
font-weight: 500;
letter-spacing: 0.025em;
}
.tab-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.tab-btn:hover::before {
left: 100%;
}
.tab-btn.active {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
transform: translateY(-1px);
}
.card {
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: relative;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
}
.stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 24px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.stat-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.btn {
font-weight: 500;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
letter-spacing: 0.025em;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.3),
0 4px 6px -2px rgba(102, 126, 234, 0.05);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow:
0 20px 25px -5px rgba(102, 126, 234, 0.3),
0 10px 10px -5px rgba(102, 126, 234, 0.1);
}
.btn-success {
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(16, 185, 129, 0.3),
0 4px 6px -2px rgba(16, 185, 129, 0.05);
}
.btn-success:hover {
transform: translateY(-1px);
box-shadow:
0 20px 25px -5px rgba(16, 185, 129, 0.3),
0 10px 10px -5px rgba(16, 185, 129, 0.1);
}
.btn-danger {
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
color: white;
box-shadow:
0 10px 15px -3px rgba(239, 68, 68, 0.3),
0 4px 6px -2px rgba(239, 68, 68, 0.05);
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow:
0 20px 25px -5px rgba(239, 68, 68, 0.3),
0 10px 10px -5px rgba(239, 68, 68, 0.1);
}
.form-input {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 16px;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow:
0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95);
}
.table-container {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
overflow: hidden;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background: rgba(102, 126, 234, 0.05);
transform: scale(1.005);
}
.modal {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.4);
}
.modal-content {
background: rgba(255, 255, 255, 0.95);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
}
.header-title {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
letter-spacing: -0.025em;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 2px solid white;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(30px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.toast {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
min-width: 320px;
max-width: 500px;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
}
.toast.show {
transform: translateX(0);
}
.toast-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.toast-error {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.toast-info {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.toast-warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
border: 1px solid rgba(245, 158, 11, 0.3);
}
[v-cloak] {
display: none;
}
@media (max-width: 768px) {
.glass, .glass-strong {
margin: 16px;
border-radius: 20px;
}
.stat-card {
padding: 16px;
}
.tab-btn {
font-size: 14px;
padding: 12px 8px;
}
}