mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
first commit
This commit is contained in:
55
.env.example
Normal file
55
.env.example
Normal 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
19
.eslintrc.js
Normal 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
229
.gitignore
vendored
Normal 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
181
CLAUDE.md
Normal 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 Key(cr_前缀格式)发送请求
|
||||||
|
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
48
Dockerfile
Normal 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
21
LICENSE
@@ -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
407
README.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# Claude Relay Service
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
[](https://redis.io/)
|
||||||
|
[](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
408
README_EN.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# Claude Relay Service
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
[](https://redis.io/)
|
||||||
|
[](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
772
cli/index.js
Normal 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
90
config/config.example.js
Normal 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
117
docker-compose.yml
Normal 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
7555
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
72
package.json
Normal file
72
package.json
Normal 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
335
scripts/manage.js
Normal 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
107
scripts/setup.js
Normal 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
367
src/app.js
Normal 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
532
src/middleware/auth.js
Normal 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
678
src/models/redis.js
Normal 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
901
src/routes/admin.js
Normal 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
225
src/routes/api.js
Normal 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
202
src/routes/web.js
Normal 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;
|
||||||
271
src/services/apiKeyService.js
Normal file
271
src/services/apiKeyService.js
Normal 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();
|
||||||
452
src/services/claudeAccountService.js
Normal file
452
src/services/claudeAccountService.js
Normal 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();
|
||||||
526
src/services/claudeRelayService.js
Normal file
526
src/services/claudeRelayService.js
Normal 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();
|
||||||
234
src/services/pricingService.js
Normal file
234
src/services/pricingService.js
Normal 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
224
src/utils/costCalculator.js
Normal 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
290
src/utils/logger.js
Normal 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
307
src/utils/oauthHelper.js
Normal 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 verifier(PKCE)
|
||||||
|
* @returns {string} base64url 编码的随机字符串
|
||||||
|
*/
|
||||||
|
function generateCodeVerifier() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 code challenge(PKCE)
|
||||||
|
* @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
1989
web/admin/app.js
Normal file
File diff suppressed because it is too large
Load Diff
2058
web/admin/index.html
Normal file
2058
web/admin/index.html
Normal file
File diff suppressed because it is too large
Load Diff
395
web/admin/style.css
Normal file
395
web/admin/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user