mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2180c42b84 | ||
|
|
0883bb6b39 | ||
|
|
ea6d1f1b36 | ||
|
|
4367fa47da | ||
|
|
55c876fad5 | ||
|
|
f9df276d0c | ||
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
83cbaf7c3e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
24f825f60d | ||
|
|
ac7d28f9ce | ||
|
|
1027a2e3e2 | ||
|
|
cb935ea0f0 | ||
|
|
73a241df1a | ||
|
|
029bdf3719 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
76ecbe18a5 | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
12fd5e1cb4 | ||
|
|
f5e982632d | ||
|
|
90023d1551 | ||
|
|
74e71d0afc | ||
|
|
d8a33f9aa7 | ||
|
|
666b0120b7 | ||
|
|
fba18000e5 | ||
|
|
b4233033a6 | ||
|
|
584fa8c9c1 | ||
|
|
18a493e805 |
10
.env.example
10
.env.example
@@ -114,6 +114,16 @@ PROXY_USE_IPV4=true
|
||||
# ⏱️ 请求超时配置
|
||||
REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟
|
||||
|
||||
# 🔗 HTTP 连接池配置(keep-alive)
|
||||
# 流式请求最大连接数(默认65535)
|
||||
# HTTPS_MAX_SOCKETS_STREAM=65535
|
||||
# 非流式请求最大连接数(默认16384)
|
||||
# HTTPS_MAX_SOCKETS_NON_STREAM=16384
|
||||
# 空闲连接数(默认2048)
|
||||
# HTTPS_MAX_FREE_SOCKETS=2048
|
||||
# 空闲连接超时(毫秒,默认30000)
|
||||
# HTTPS_FREE_SOCKET_TIMEOUT=30000
|
||||
|
||||
# 🔧 请求体大小配置
|
||||
REQUEST_MAX_SIZE_MB=60
|
||||
|
||||
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
626
README_EN.md
626
README_EN.md
@@ -1,5 +1,4 @@
|
||||
# Claude Relay Service (Antigravity Edition)
|
||||
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||
@@ -8,117 +7,606 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
This fork focuses on:
|
||||
- Native compatibility for `claude` (Claude Code CLI)
|
||||
- Antigravity OAuth integration + path-based routing
|
||||
- Better stability for streaming (SSE) workloads
|
||||
- Optional request/response dumps for debugging
|
||||
[](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**
|
||||
|
||||
[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
## ⭐ If You Find It Useful, Please Give It a Star!
|
||||
|
||||
- **Claude Code protocol compatibility**: `thoughtSignature` fallback + cache, tool_result passthrough, and message ordering fixes.
|
||||
- **Antigravity OAuth**: account type `gemini-antigravity` with permission checks.
|
||||
- **Path-based routing (Anthropic Messages API)**:
|
||||
- `/api` -> Claude account pool (default)
|
||||
- `/antigravity/api` -> Antigravity OAuth account pool
|
||||
- `/gemini-cli/api` -> Gemini OAuth account pool
|
||||
- **Stability**:
|
||||
- Zombie stream watchdog (disconnect after 45s without valid data)
|
||||
- Auto retry + account switching for Antigravity `429 Resource Exhausted` (streaming and non-streaming)
|
||||
- **Observability**: JSONL dumps for request/response/tools/upstream (with size limit + rotation)
|
||||
> Open source is not easy, your Star is my motivation to continue updating 🚀
|
||||
> Join [Telegram Channel](https://t.me/claude_relay_service) for the latest updates
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## ⚠️ Important Notice
|
||||
|
||||
### Requirements
|
||||
- Node.js 18+ (or Docker)
|
||||
- Redis 6+/7+
|
||||
**Please read carefully before using this project:**
|
||||
|
||||
### Docker Compose (recommended)
|
||||
🚨 **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 Issues**: Third-party mirror sites often fail and are unstable, affecting efficiency?
|
||||
|
||||
If you have any of these concerns, this project might be suitable 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-party mirrors 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
|
||||
|
||||
**If you're just an ordinary user with low privacy requirements, just want to casually play around and quickly experience Claude, then choosing a mirror site you're familiar with would be more suitable.**
|
||||
|
||||
---
|
||||
|
||||
## 💭 Why Build Your Own?
|
||||
|
||||
### Potential Issues with Existing Mirror Sites
|
||||
|
||||
- 🕵️ **Privacy Risk**: Your conversation content is completely visible to 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, Max $200 package basically allows you to enjoy Opus freely
|
||||
- 💰 **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
|
||||
|
||||
> 📸 **[Click to view interface preview](docs/preview.md)** - See detailed screenshots of the Web management interface
|
||||
|
||||
### 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 sufficient
|
||||
- **Memory**: 512MB (1GB recommended)
|
||||
- **Storage**: 30GB available space
|
||||
- **Network**: Access to Anthropic API (recommend US region servers)
|
||||
- **Recommendation**: 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, $5-10 per month
|
||||
- **Claude Subscription**: Depends on how you share costs
|
||||
- **Others**: Domain name (optional)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Manual Deployment
|
||||
|
||||
### Step 1: Environment Setup
|
||||
|
||||
**Ubuntu/Debian users:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp config/config.example.js config/config.js
|
||||
# Install Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Edit .env at least:
|
||||
# JWT_SECRET=... (random string)
|
||||
# ENCRYPTION_KEY=... (32-char random string)
|
||||
|
||||
docker-compose up -d
|
||||
# Install Redis
|
||||
sudo apt update
|
||||
sudo apt install redis-server
|
||||
sudo systemctl start redis-server
|
||||
```
|
||||
|
||||
### Node (no Docker)
|
||||
**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/Wei-Shaw/claude-relay-service.git
|
||||
cd claude-relay-service
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
cp .env.example .env
|
||||
|
||||
# Copy configuration files (Important!)
|
||||
cp config/config.example.js config/config.js
|
||||
npm run setup
|
||||
npm run service:start:daemon
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Admin UI
|
||||
### Step 3: Configuration File Setup
|
||||
|
||||
- URL: `http://<host>:3000/web`
|
||||
- Initial credentials: generated by `npm run setup` and saved to `data/init.json` (Docker users can also inspect container logs).
|
||||
**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 # Will randomly generate admin account password info, stored in data/init.json
|
||||
|
||||
# Start service
|
||||
npm run service:start:daemon # Run in background (recommended)
|
||||
|
||||
# Check status
|
||||
npm run service:status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using with Claude Code (CLI)
|
||||
## 🎮 Getting Started
|
||||
|
||||
### Antigravity pool (recommended)
|
||||
### 1. Open Management Interface
|
||||
|
||||
Browser visit: `http://your-server-IP:3000/web`
|
||||
|
||||
Default admin account: Look in data/init.json
|
||||
|
||||
### 2. Add Claude Account
|
||||
|
||||
This step is quite important, requires OAuth authorization:
|
||||
|
||||
1. Click "Claude Accounts" tab
|
||||
2. If you're worried about multiple accounts sharing 1 IP getting banned, you can optionally set a static proxy IP
|
||||
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 Claude Code and Gemini CLI
|
||||
|
||||
Now you can replace the official API with your own service:
|
||||
|
||||
**Claude Code Set Environment Variables:**
|
||||
|
||||
Default uses standard Claude account pool:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||
```
|
||||
|
||||
**VSCode Claude Plugin Configuration:**
|
||||
|
||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"primaryApiKey": "crs"
|
||||
}
|
||||
```
|
||||
|
||||
If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`.
|
||||
|
||||
**Gemini CLI Set Environment Variables:**
|
||||
|
||||
**Method 1 (Recommended): Via Gemini Assist API**
|
||||
|
||||
Each account enjoys 1000 requests per day, 60 requests per minute free quota.
|
||||
|
||||
```bash
|
||||
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||
GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend"
|
||||
GOOGLE_GENAI_USE_GCA="true"
|
||||
GEMINI_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage.
|
||||
|
||||
**Method 2: Via Gemini API**
|
||||
|
||||
Very limited free quota, easily triggers 429 errors.
|
||||
|
||||
```bash
|
||||
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||
GEMINI_API_KEY="API key created in the backend"
|
||||
GEMINI_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
**Use Claude Code:**
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://<host>:3000/antigravity/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
claude
|
||||
```
|
||||
|
||||
### Gemini pool
|
||||
**Use Gemini CLI:**
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://<host>:3000/gemini-cli/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
|
||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
claude
|
||||
```
|
||||
|
||||
### Standard Claude pool
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://<host>:3000/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
|
||||
claude
|
||||
gemini
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Antigravity Quota & Models
|
||||
## 🔧 Daily Maintenance
|
||||
|
||||
- Quota display: in Admin UI -> Accounts -> `gemini-antigravity` -> click **Test/Refresh**.
|
||||
- Dynamic models list:
|
||||
- Anthropic/Claude Code routing: `GET /antigravity/api/v1/models` (proxies Antigravity `fetchAvailableModels`)
|
||||
- OpenAI-compatible routing: `GET /openai/gemini/models` (or `GET /openai/gemini/v1/models`)
|
||||
### 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
|
||||
|
||||
### Upgrade Guide
|
||||
|
||||
When a new version is released, follow these steps to upgrade the service:
|
||||
|
||||
```bash
|
||||
# 1. Navigate to project directory
|
||||
cd claude-relay-service
|
||||
|
||||
# 2. Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# If you encounter package-lock.json conflicts, use the remote version
|
||||
git checkout --theirs package-lock.json
|
||||
git add package-lock.json
|
||||
|
||||
# 3. Install new dependencies (if any)
|
||||
npm install
|
||||
|
||||
# 4. Restart service
|
||||
npm run service:restart:daemon
|
||||
|
||||
# 5. Check service status
|
||||
npm run service:status
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Before upgrading, it's recommended to backup important configuration files (.env, config/config.js)
|
||||
- Check the changelog to understand if there are any breaking changes
|
||||
- Database structure changes will be migrated automatically if needed
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Debug Dumps (optional)
|
||||
## 🛠️ Advanced Usage
|
||||
|
||||
See `.env.example` for the full list. Common toggles:
|
||||
### Reverse Proxy Deployment Guide
|
||||
|
||||
- `ANTHROPIC_DEBUG_REQUEST_DUMP=true`
|
||||
- `ANTHROPIC_DEBUG_RESPONSE_DUMP=true`
|
||||
- `ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true`
|
||||
- `ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true`
|
||||
- `DUMP_MAX_FILE_SIZE_BYTES=10485760`
|
||||
For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
## Caddy Solution
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
|
||||
|
||||
**1. Install Caddy**
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt update
|
||||
sudo apt install caddy
|
||||
|
||||
# CentOS/RHEL/Fedora
|
||||
sudo yum install yum-plugin-copr
|
||||
sudo yum copr enable @caddy/caddy
|
||||
sudo yum install caddy
|
||||
```
|
||||
|
||||
**2. Caddy Configuration**
|
||||
|
||||
Edit `/etc/caddy/Caddyfile`:
|
||||
|
||||
```caddy
|
||||
your-domain.com {
|
||||
# Reverse proxy to local service
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
# Support streaming responses or SSE
|
||||
flush_interval -1
|
||||
|
||||
# Pass real IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
|
||||
# Long read/write timeout configuration
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
dial_timeout 30s
|
||||
}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Frame-Options "DENY"
|
||||
X-Content-Type-Options "nosniff"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Start Caddy**
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl start caddy
|
||||
sudo systemctl enable caddy
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
**4. Service Configuration**
|
||||
|
||||
Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
|
||||
|
||||
```javascript
|
||||
// config/config.js
|
||||
module.exports = {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '127.0.0.1' // Listen locally only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Caddy Features**
|
||||
|
||||
* 🔒 Automatic HTTPS with zero-configuration certificate management
|
||||
* 🛡️ Secure default configuration with modern TLS suites
|
||||
* ⚡ HTTP/2 and streaming support
|
||||
* 🔧 Concise configuration files, easy to maintain
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager (NPM) Solution
|
||||
|
||||
Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
|
||||
|
||||
**1. Create a New Proxy Host in NPM**
|
||||
|
||||
Configure the Details as follows:
|
||||
|
||||
| Item | Setting |
|
||||
| --------------------- | ------------------------ |
|
||||
| Domain Names | relay.example.com |
|
||||
| Scheme | http |
|
||||
| Forward Hostname / IP | 192.168.0.1 (docker host IP) |
|
||||
| Forward Port | 3000 |
|
||||
| Block Common Exploits | ☑️ |
|
||||
| Websockets Support | ❌ **Disable** |
|
||||
| Cache Assets | ❌ **Disable** |
|
||||
| Access List | Publicly Accessible |
|
||||
|
||||
> Note:
|
||||
> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections.
|
||||
> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail.
|
||||
|
||||
**2. Custom locations**
|
||||
|
||||
No content needed, keep it empty.
|
||||
|
||||
**3. SSL Settings**
|
||||
|
||||
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate
|
||||
* ☑️ **Force SSL**
|
||||
* ☑️ **HTTP/2 Support**
|
||||
* ☑️ **HSTS Enabled**
|
||||
* ☑️ **HSTS Subdomains**
|
||||
|
||||
**4. Advanced Configuration**
|
||||
|
||||
Add the following to Custom Nginx Configuration:
|
||||
|
||||
```nginx
|
||||
# Pass real user IP
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Support WebSocket / SSE streaming
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
|
||||
# Long connection / timeout settings (for AI chat streaming)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
|
||||
# ---- Security Settings ----
|
||||
# Strict HTTPS policy (HSTS)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Block clickjacking and content sniffing
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Referrer / Permissions restriction policies
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Hide server information (equivalent to Caddy's `-Server`)
|
||||
proxy_hide_header Server;
|
||||
|
||||
# ---- Performance Tuning ----
|
||||
# Disable proxy caching for real-time responses (SSE / Streaming)
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_no_cache $http_upgrade;
|
||||
proxy_request_buffering off;
|
||||
```
|
||||
|
||||
**5. Launch and Verify**
|
||||
|
||||
* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable).
|
||||
* Check Proxy Host status in Dashboard to ensure it shows "Online".
|
||||
* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly.
|
||||
|
||||
**NPM Features**
|
||||
|
||||
* 🔒 Automatic certificate application and renewal
|
||||
* 🔧 Graphical interface for easy multi-service management
|
||||
* ⚡ Native HTTP/2 / HTTPS support
|
||||
* 🚀 Ideal for Docker container deployments
|
||||
|
||||
---
|
||||
|
||||
Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Usage Recommendations
|
||||
|
||||
### Account Management
|
||||
- **Regular Checks**: Check account status weekly, handle exceptions promptly
|
||||
- **Reasonable Allocation**: Can assign different API keys to different people, analyze usage based on different API keys
|
||||
|
||||
### Security Recommendations
|
||||
- **Use HTTPS**: Strongly recommend using Caddy reverse proxy (automatic HTTPS) to ensure secure data transmission
|
||||
- **Regular Backups**: Back up important configurations and data
|
||||
- **Monitor Logs**: Regularly check exception logs
|
||||
- **Update Keys**: Regularly change JWT and encryption keys
|
||||
- **Firewall Settings**: Only open necessary ports (80, 443), hide direct service ports
|
||||
|
||||
---
|
||||
|
||||
## 🆘 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>
|
||||
16
cli/index.js
16
cli/index.js
@@ -103,7 +103,7 @@ program
|
||||
try {
|
||||
const [, apiKeys, accounts] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
apiKeyService.getAllApiKeysFast(),
|
||||
claudeAccountService.getAllAccounts()
|
||||
])
|
||||
|
||||
@@ -284,7 +284,7 @@ async function listApiKeys() {
|
||||
const spinner = ora('正在获取 API Keys...').start()
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`)
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
@@ -314,7 +314,7 @@ async function listApiKeys() {
|
||||
|
||||
tableData.push([
|
||||
key.name,
|
||||
key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-',
|
||||
key.maskedKey || '-',
|
||||
key.isActive ? '🟢 活跃' : '🔴 停用',
|
||||
expiryStatus,
|
||||
`${(key.usage?.total?.tokens || 0).toLocaleString()}`,
|
||||
@@ -333,7 +333,7 @@ async function listApiKeys() {
|
||||
async function updateApiKeyExpiry() {
|
||||
try {
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'))
|
||||
@@ -347,7 +347,7 @@ async function updateApiKeyExpiry() {
|
||||
name: 'selectedKey',
|
||||
message: '选择要修改的 API Key:',
|
||||
choices: apiKeys.map((key) => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`,
|
||||
value: key
|
||||
}))
|
||||
}
|
||||
@@ -463,7 +463,7 @@ async function renewApiKeys() {
|
||||
const spinner = ora('正在查找即将过期的 API Keys...').start()
|
||||
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
const now = new Date()
|
||||
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
@@ -562,7 +562,7 @@ async function renewApiKeys() {
|
||||
|
||||
async function deleteApiKey() {
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
console.log(styles.warning('没有找到任何 API Keys'))
|
||||
@@ -575,7 +575,7 @@ async function deleteApiKey() {
|
||||
name: 'selectedKeys',
|
||||
message: '选择要删除的 API Keys (空格选择,回车确认):',
|
||||
choices: apiKeys.map((key) => ({
|
||||
name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`,
|
||||
name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)})`,
|
||||
value: key.id
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -123,7 +123,8 @@ const config = {
|
||||
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
||||
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8
|
||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8
|
||||
metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟)
|
||||
},
|
||||
|
||||
// 🎨 Web界面配置
|
||||
@@ -220,6 +221,13 @@ const config = {
|
||||
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
|
||||
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
|
||||
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送
|
||||
},
|
||||
|
||||
// 🎫 额度卡兑换上限配置(防盗刷)
|
||||
quotaCardLimits: {
|
||||
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
|
||||
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
|
||||
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
config/models.js
Normal file
64
config/models.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 模型列表配置
|
||||
* 用于前端展示和测试功能
|
||||
*/
|
||||
|
||||
const CLAUDE_MODELS = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }
|
||||
]
|
||||
|
||||
const GEMINI_MODELS = [
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }
|
||||
]
|
||||
|
||||
const OPENAI_MODELS = [
|
||||
{ value: 'gpt-5', label: 'GPT-5' },
|
||||
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||
{ value: 'gpt-5-nano', label: 'GPT-5 Nano' },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'codex-mini', label: 'Codex Mini' }
|
||||
]
|
||||
|
||||
// 其他模型(用于账户编辑的模型映射)
|
||||
const OTHER_MODELS = [
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
{ value: 'Qwen', label: 'Qwen' },
|
||||
{ value: 'Kimi', label: 'Kimi' },
|
||||
{ value: 'GLM', label: 'GLM' }
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
OPENAI_MODELS,
|
||||
OTHER_MODELS,
|
||||
// 按服务分组
|
||||
getModelsByService: (service) => {
|
||||
switch (service) {
|
||||
case 'claude':
|
||||
return CLAUDE_MODELS
|
||||
case 'gemini':
|
||||
return GEMINI_MODELS
|
||||
case 'openai':
|
||||
return OPENAI_MODELS
|
||||
default:
|
||||
return []
|
||||
}
|
||||
},
|
||||
// 获取所有模型(用于账户编辑)
|
||||
getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS]
|
||||
}
|
||||
@@ -2,7 +2,8 @@ const repository =
|
||||
process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
|
||||
const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
|
||||
const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
|
||||
const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||
const hashFileName =
|
||||
process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
|
||||
|
||||
const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||
? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
|
||||
@@ -11,7 +12,6 @@ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
|
||||
module.exports = {
|
||||
pricingFileName,
|
||||
hashFileName,
|
||||
pricingUrl:
|
||||
process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||
pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
|
||||
hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
|
||||
}
|
||||
|
||||
@@ -152,62 +152,110 @@ async function exportUsageStats(keyId) {
|
||||
daily: {},
|
||||
monthly: {},
|
||||
hourly: {},
|
||||
models: {}
|
||||
models: {},
|
||||
// 费用统计(String 类型)
|
||||
costTotal: null,
|
||||
costDaily: {},
|
||||
costMonthly: {},
|
||||
costHourly: {},
|
||||
opusTotal: null,
|
||||
opusWeekly: {}
|
||||
}
|
||||
|
||||
// 导出总统计
|
||||
const totalKey = `usage:${keyId}`
|
||||
const totalData = await redis.client.hgetall(totalKey)
|
||||
// 导出总统计(Hash)
|
||||
const totalData = await redis.client.hgetall(`usage:${keyId}`)
|
||||
if (totalData && Object.keys(totalData).length > 0) {
|
||||
stats.total = totalData
|
||||
}
|
||||
|
||||
// 导出每日统计(最近30天)
|
||||
const today = new Date()
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const dailyKey = `usage:daily:${keyId}:${dateStr}`
|
||||
// 导出费用总统计(String)
|
||||
const costTotal = await redis.client.get(`usage:cost:total:${keyId}`)
|
||||
if (costTotal) {
|
||||
stats.costTotal = costTotal
|
||||
}
|
||||
|
||||
const dailyData = await redis.client.hgetall(dailyKey)
|
||||
if (dailyData && Object.keys(dailyData).length > 0) {
|
||||
stats.daily[dateStr] = dailyData
|
||||
// 导出 Opus 费用总统计(String)
|
||||
const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`)
|
||||
if (opusTotal) {
|
||||
stats.opusTotal = opusTotal
|
||||
}
|
||||
|
||||
// 导出每日统计(扫描现有 key,避免时区问题)
|
||||
const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`)
|
||||
for (const key of dailyKeys) {
|
||||
const date = key.split(':').pop()
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.daily[date] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出每月统计(最近12个月)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today)
|
||||
date.setMonth(date.getMonth() - i)
|
||||
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`
|
||||
|
||||
const monthlyData = await redis.client.hgetall(monthlyKey)
|
||||
if (monthlyData && Object.keys(monthlyData).length > 0) {
|
||||
stats.monthly[monthStr] = monthlyData
|
||||
// 导出每日费用(扫描现有 key)
|
||||
const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`)
|
||||
for (const key of costDailyKeys) {
|
||||
const date = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costDaily[date] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时统计(最近24小时)
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const date = new Date(today)
|
||||
date.setHours(date.getHours() - i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const hourKey = `${dateStr}:${hour}`
|
||||
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`
|
||||
|
||||
const hourlyData = await redis.client.hgetall(hourlyKey)
|
||||
if (hourlyData && Object.keys(hourlyData).length > 0) {
|
||||
stats.hourly[hourKey] = hourlyData
|
||||
// 导出每月统计(扫描现有 key)
|
||||
const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`)
|
||||
for (const key of monthlyKeys) {
|
||||
const month = key.split(':').pop()
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.monthly[month] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出模型统计
|
||||
// 每日模型统计
|
||||
const modelDailyPattern = `usage:${keyId}:model:daily:*`
|
||||
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
||||
// 导出每月费用(扫描现有 key)
|
||||
const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`)
|
||||
for (const key of costMonthlyKeys) {
|
||||
const month = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costMonthly[month] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 Opus 周费用(扫描现有 key)
|
||||
const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`)
|
||||
for (const key of opusWeeklyKeys) {
|
||||
const week = key.split(':').pop()
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.opusWeekly[week] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时统计(扫描现有 key)
|
||||
// key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||
const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`)
|
||||
for (const key of hourlyKeys) {
|
||||
const parts = key.split(':')
|
||||
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
stats.hourly[hourKey] = data
|
||||
}
|
||||
}
|
||||
|
||||
// 导出小时费用(扫描现有 key)
|
||||
// key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH}
|
||||
const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`)
|
||||
for (const key of costHourlyKeys) {
|
||||
const parts = key.split(':')
|
||||
const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH
|
||||
const value = await redis.client.get(key)
|
||||
if (value) {
|
||||
stats.costHourly[hourKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 导出模型统计(每日)
|
||||
const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`)
|
||||
for (const key of modelDailyKeys) {
|
||||
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
@@ -223,9 +271,8 @@ async function exportUsageStats(keyId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 每月模型统计
|
||||
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`
|
||||
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
||||
// 导出模型统计(每月)
|
||||
const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`)
|
||||
for (const key of modelMonthlyKeys) {
|
||||
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (match) {
|
||||
@@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) {
|
||||
const pipeline = redis.client.pipeline()
|
||||
let importCount = 0
|
||||
|
||||
// 导入总统计
|
||||
// 导入总统计(Hash)
|
||||
if (stats.total && Object.keys(stats.total).length > 0) {
|
||||
for (const [field, value] of Object.entries(stats.total)) {
|
||||
pipeline.hset(`usage:${keyId}`, field, value)
|
||||
@@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) {
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入每日统计
|
||||
// 导入费用总统计(String)
|
||||
if (stats.costTotal) {
|
||||
pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal)
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入 Opus 费用总统计(String)
|
||||
if (stats.opusTotal) {
|
||||
pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal)
|
||||
importCount++
|
||||
}
|
||||
|
||||
// 导入每日统计(Hash)
|
||||
if (stats.daily) {
|
||||
for (const [date, data] of Object.entries(stats.daily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -276,7 +335,15 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入每月统计
|
||||
// 导入每日费用(String)
|
||||
if (stats.costDaily) {
|
||||
for (const [date, value] of Object.entries(stats.costDaily)) {
|
||||
pipeline.set(`usage:cost:daily:${keyId}:${date}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入每月统计(Hash)
|
||||
if (stats.monthly) {
|
||||
for (const [month, data] of Object.entries(stats.monthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -286,7 +353,23 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入小时统计
|
||||
// 导入每月费用(String)
|
||||
if (stats.costMonthly) {
|
||||
for (const [month, value] of Object.entries(stats.costMonthly)) {
|
||||
pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入 Opus 周费用(String,不加 TTL 保留历史全量)
|
||||
if (stats.opusWeekly) {
|
||||
for (const [week, value] of Object.entries(stats.opusWeekly)) {
|
||||
pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入小时统计(Hash)
|
||||
if (stats.hourly) {
|
||||
for (const [hour, data] of Object.entries(stats.hourly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -296,10 +379,17 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导入模型统计
|
||||
// 导入小时费用(String)
|
||||
if (stats.costHourly) {
|
||||
for (const [hour, value] of Object.entries(stats.costHourly)) {
|
||||
pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value)
|
||||
importCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 导入模型统计(Hash)
|
||||
if (stats.models) {
|
||||
for (const [model, modelStats] of Object.entries(stats.models)) {
|
||||
// 每日模型统计
|
||||
if (modelStats.daily) {
|
||||
for (const [date, data] of Object.entries(modelStats.daily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) {
|
||||
}
|
||||
}
|
||||
|
||||
// 每月模型统计
|
||||
if (modelStats.monthly) {
|
||||
for (const [month, data] of Object.entries(modelStats.monthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
@@ -547,13 +636,54 @@ async function exportData() {
|
||||
const globalStats = {
|
||||
daily: {},
|
||||
monthly: {},
|
||||
hourly: {}
|
||||
hourly: {},
|
||||
// 新增:索引和全局统计
|
||||
monthlyMonths: [], // usage:model:monthly:months Set
|
||||
globalTotal: null, // usage:global:total Hash
|
||||
globalDaily: {}, // usage:global:daily:* Hash
|
||||
globalMonthly: {} // usage:global:monthly:* Hash
|
||||
}
|
||||
|
||||
// 导出全局每日模型统计
|
||||
const globalDailyPattern = 'usage:model:daily:*'
|
||||
const globalDailyKeys = await redis.client.keys(globalDailyPattern)
|
||||
// 导出月份索引
|
||||
const monthlyMonths = await redis.client.smembers('usage:model:monthly:months')
|
||||
if (monthlyMonths && monthlyMonths.length > 0) {
|
||||
globalStats.monthlyMonths = monthlyMonths
|
||||
logger.info(`📤 Found ${monthlyMonths.length} months in index`)
|
||||
}
|
||||
|
||||
// 导出全局统计
|
||||
const globalTotal = await redis.client.hgetall('usage:global:total')
|
||||
if (globalTotal && Object.keys(globalTotal).length > 0) {
|
||||
globalStats.globalTotal = globalTotal
|
||||
logger.info('📤 Found global total stats')
|
||||
}
|
||||
|
||||
// 导出全局每日统计
|
||||
const globalDailyKeys = await redis.client.keys('usage:global:daily:*')
|
||||
for (const key of globalDailyKeys) {
|
||||
const date = key.replace('usage:global:daily:', '')
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
globalStats.globalDaily[date] = data
|
||||
}
|
||||
}
|
||||
logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`)
|
||||
|
||||
// 导出全局每月统计
|
||||
const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*')
|
||||
for (const key of globalMonthlyKeys) {
|
||||
const month = key.replace('usage:global:monthly:', '')
|
||||
const data = await redis.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
globalStats.globalMonthly[month] = data
|
||||
}
|
||||
}
|
||||
logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`)
|
||||
|
||||
// 导出全局每日模型统计
|
||||
const modelDailyPattern = 'usage:model:daily:*'
|
||||
const modelDailyKeys = await redis.client.keys(modelDailyPattern)
|
||||
for (const key of modelDailyKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const model = match[1]
|
||||
@@ -569,9 +699,9 @@ async function exportData() {
|
||||
}
|
||||
|
||||
// 导出全局每月模型统计
|
||||
const globalMonthlyPattern = 'usage:model:monthly:*'
|
||||
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern)
|
||||
for (const key of globalMonthlyKeys) {
|
||||
const modelMonthlyPattern = 'usage:model:monthly:*'
|
||||
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern)
|
||||
for (const key of modelMonthlyKeys) {
|
||||
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (match) {
|
||||
const model = match[1]
|
||||
@@ -1040,6 +1170,46 @@ async function importData() {
|
||||
const pipeline = redis.client.pipeline()
|
||||
let globalStatCount = 0
|
||||
|
||||
// 导入月份索引
|
||||
if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) {
|
||||
for (const month of globalStats.monthlyMonths) {
|
||||
pipeline.sadd('usage:model:monthly:months', month)
|
||||
}
|
||||
logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`)
|
||||
}
|
||||
|
||||
// 导入全局统计
|
||||
if (globalStats.globalTotal) {
|
||||
for (const [field, value] of Object.entries(globalStats.globalTotal)) {
|
||||
pipeline.hset('usage:global:total', field, value)
|
||||
}
|
||||
logger.info('📥 Importing global total stats')
|
||||
}
|
||||
|
||||
// 导入全局每日统计
|
||||
if (globalStats.globalDaily) {
|
||||
for (const [date, data] of Object.entries(globalStats.globalDaily)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
pipeline.hset(`usage:global:daily:${date}`, field, value)
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入全局每月统计
|
||||
if (globalStats.globalMonthly) {
|
||||
for (const [month, data] of Object.entries(globalStats.globalMonthly)) {
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
pipeline.hset(`usage:global:monthly:${month}`, field, value)
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats`
|
||||
)
|
||||
}
|
||||
|
||||
// 导入每日统计
|
||||
if (globalStats.daily) {
|
||||
for (const [date, models] of Object.entries(globalStats.daily)) {
|
||||
@@ -1061,6 +1231,8 @@ async function importData() {
|
||||
}
|
||||
globalStatCount++
|
||||
}
|
||||
// 同时更新月份索引(兼容旧格式导出文件)
|
||||
pipeline.sadd('usage:model:monthly:months', month)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async function cleanTestData() {
|
||||
logger.info('🧹 Cleaning test data...')
|
||||
|
||||
// 获取所有API Keys
|
||||
const allKeys = await apiKeyService.getAllApiKeys()
|
||||
const allKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找出所有测试 API Keys
|
||||
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const logger = require('../src/utils/logger')
|
||||
const readline = require('readline')
|
||||
|
||||
@@ -51,7 +52,7 @@ async function migrateApiKeys() {
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await redis.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
|
||||
|
||||
// 统计信息
|
||||
|
||||
138
scripts/migrate-usage-index.js
Normal file
138
scripts/migrate-usage-index.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 历史数据索引迁移脚本
|
||||
* 为现有的 usage 数据建立索引,加速查询
|
||||
*/
|
||||
const Redis = require('ioredis')
|
||||
const config = require('../config/config')
|
||||
|
||||
const redis = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
db: config.redis.db || 0
|
||||
})
|
||||
|
||||
async function migrate() {
|
||||
console.log('开始迁移历史数据索引...')
|
||||
console.log('Redis DB:', config.redis.db || 0)
|
||||
|
||||
const stats = {
|
||||
dailyIndex: 0,
|
||||
hourlyIndex: 0,
|
||||
modelDailyIndex: 0,
|
||||
modelHourlyIndex: 0
|
||||
}
|
||||
|
||||
// 1. 迁移 usage:daily:{keyId}:{date} 索引
|
||||
console.log('\n1. 迁移 usage:daily 索引...')
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:daily:{keyId}:{date}
|
||||
const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, date] = match
|
||||
pipeline.sadd(`usage:daily:index:${date}`, keyId)
|
||||
pipeline.expire(`usage:daily:index:${date}`, 86400 * 32)
|
||||
stats.dailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.dailyIndex} 条`)
|
||||
|
||||
// 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引
|
||||
console.log('\n2. 迁移 usage:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:hourly:{keyId}:{date}:{hour}
|
||||
const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, hourKey] = match
|
||||
pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId)
|
||||
pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.hourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.hourlyIndex} 条`)
|
||||
|
||||
// 3. 迁移 usage:model:daily:{model}:{date} 索引
|
||||
console.log('\n3. 迁移 usage:model:daily 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:daily:{model}:{date}
|
||||
const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, date] = match
|
||||
pipeline.sadd(`usage:model:daily:index:${date}`, model)
|
||||
pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32)
|
||||
stats.modelDailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelDailyIndex} 条`)
|
||||
|
||||
// 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引
|
||||
console.log('\n4. 迁移 usage:model:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:model:hourly:*',
|
||||
'COUNT',
|
||||
500
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:hourly:{model}:{date}:{hour}
|
||||
const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, hourKey] = match
|
||||
pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model)
|
||||
pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.modelHourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelHourlyIndex} 条`)
|
||||
|
||||
console.log('\n迁移完成!')
|
||||
console.log('统计:', stats)
|
||||
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('迁移失败:', err)
|
||||
redis.disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
91
src/app.js
91
src/app.js
@@ -11,6 +11,7 @@ const logger = require('./utils/logger')
|
||||
const redis = require('./models/redis')
|
||||
const pricingService = require('./services/pricingService')
|
||||
const cacheMonitor = require('./utils/cacheMonitor')
|
||||
const { getSafeMessage } = require('./utils/errorSanitizer')
|
||||
|
||||
// Import routes
|
||||
const apiRoutes = require('./routes/api')
|
||||
@@ -50,7 +51,38 @@ class Application {
|
||||
// 🔗 连接Redis
|
||||
logger.info('🔄 Connecting to Redis...')
|
||||
await redis.connect()
|
||||
logger.success('✅ Redis connected successfully')
|
||||
logger.success('Redis connected successfully')
|
||||
|
||||
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
|
||||
const { getAppVersion, versionGt } = require('./utils/commonHelper')
|
||||
const currentVersion = getAppVersion()
|
||||
const migratedVersion = await redis.getMigratedVersion()
|
||||
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
|
||||
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
|
||||
try {
|
||||
if (await redis.needsGlobalStatsMigration()) {
|
||||
await redis.migrateGlobalStats()
|
||||
}
|
||||
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
|
||||
} catch (err) {
|
||||
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
|
||||
}
|
||||
await redis.setMigratedVersion(currentVersion)
|
||||
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
|
||||
}
|
||||
|
||||
// 📅 后台检查月份索引完整性(不阻塞启动)
|
||||
redis.ensureMonthlyMonthsIndex().catch((err) => {
|
||||
logger.error('📅 月份索引检查失败:', err.message)
|
||||
})
|
||||
|
||||
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
||||
redis.migrateUsageIndex().catch((err) => {
|
||||
logger.error('📊 Background usage index migration failed:', err)
|
||||
})
|
||||
|
||||
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
|
||||
await redis.migrateAlltimeModelStats()
|
||||
|
||||
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||
try {
|
||||
@@ -94,6 +126,15 @@ class Application {
|
||||
)
|
||||
}
|
||||
|
||||
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||
try {
|
||||
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||
}
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
@@ -104,6 +145,18 @@ class Application {
|
||||
const costRankService = require('./services/costRankService')
|
||||
await costRankService.initialize()
|
||||
|
||||
// 🔍 初始化 API Key 索引服务(用于分页查询优化)
|
||||
logger.info('🔍 Initializing API Key index service...')
|
||||
const apiKeyIndexService = require('./services/apiKeyIndexService')
|
||||
apiKeyIndexService.init(redis)
|
||||
await apiKeyIndexService.checkAndRebuild()
|
||||
|
||||
// 📁 确保账户分组反向索引存在(后台执行,不阻塞启动)
|
||||
const accountGroupService = require('./services/accountGroupService')
|
||||
accountGroupService.ensureReverseIndexes().catch((err) => {
|
||||
logger.error('📁 Account group reverse index migration failed:', err)
|
||||
})
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
@@ -377,7 +430,7 @@ class Application {
|
||||
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
error: getSafeMessage(error),
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
@@ -413,7 +466,7 @@ class Application {
|
||||
// 🚨 错误处理
|
||||
this.app.use(errorHandler)
|
||||
|
||||
logger.success('✅ Application initialized successfully')
|
||||
logger.success('Application initialized successfully')
|
||||
} catch (error) {
|
||||
logger.error('💥 Application initialization failed:', error)
|
||||
throw error
|
||||
@@ -448,7 +501,7 @@ class Application {
|
||||
|
||||
await redis.setSession('admin_credentials', adminCredentials)
|
||||
|
||||
logger.success('✅ Admin credentials loaded from init.json (single source of truth)')
|
||||
logger.success('Admin credentials loaded from init.json (single source of truth)')
|
||||
logger.info(`📋 Admin username: ${adminCredentials.username}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize admin credentials:', {
|
||||
@@ -465,22 +518,24 @@ class Application {
|
||||
const client = redis.getClient()
|
||||
|
||||
// 获取所有 session:* 键
|
||||
const sessionKeys = await client.keys('session:*')
|
||||
const sessionKeys = await redis.scanKeys('session:*')
|
||||
const dataList = await redis.batchHgetallChunked(sessionKeys)
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const key of sessionKeys) {
|
||||
for (let i = 0; i < sessionKeys.length; i++) {
|
||||
const key = sessionKeys[i]
|
||||
// 跳过 admin_credentials(系统凭据)
|
||||
if (key === 'session:admin_credentials') {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionData = await client.hgetall(key)
|
||||
const sessionData = dataList[i]
|
||||
|
||||
// 检查会话完整性:必须有 username 和 loginTime
|
||||
const hasUsername = !!sessionData.username
|
||||
const hasLoginTime = !!sessionData.loginTime
|
||||
const hasUsername = !!sessionData?.username
|
||||
const hasLoginTime = !!sessionData?.loginTime
|
||||
|
||||
if (!hasUsername || !hasLoginTime) {
|
||||
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||
@@ -495,11 +550,11 @@ class Application {
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
`Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
)
|
||||
} catch (error) {
|
||||
// 清理失败不应阻止服务启动
|
||||
@@ -549,9 +604,7 @@ class Application {
|
||||
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.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`)
|
||||
logger.info(
|
||||
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
|
||||
)
|
||||
@@ -606,7 +659,7 @@ class Application {
|
||||
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
||||
}, 5000)
|
||||
|
||||
logger.success('✅ Cache monitoring initialized')
|
||||
logger.success('Cache monitoring initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize cache monitoring:', error)
|
||||
// 不阻止应用启动
|
||||
@@ -655,7 +708,7 @@ class Application {
|
||||
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
const keys = await redis.scanKeys('concurrency:*')
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -837,9 +890,9 @@ class Application {
|
||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||
try {
|
||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||
const keys = await redis.keys('concurrency:*')
|
||||
const keys = await redis.scanKeys('concurrency:*')
|
||||
if (keys.length > 0) {
|
||||
await redis.client.del(...keys)
|
||||
await redis.batchDelChunked(keys)
|
||||
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
||||
} else {
|
||||
logger.info('✅ No concurrency keys to clean')
|
||||
@@ -856,7 +909,7 @@ class Application {
|
||||
logger.error('❌ Error disconnecting Redis:', error)
|
||||
}
|
||||
|
||||
logger.success('✅ Graceful shutdown completed')
|
||||
logger.success('Graceful shutdown completed')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const { parseSSELine } = require('../utils/sseParser')
|
||||
const axios = require('axios')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
|
||||
// ============================================================================
|
||||
@@ -136,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
req.rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
req.apiKey?.id,
|
||||
'gemini'
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -353,7 +357,7 @@ async function handleMessages(req, res) {
|
||||
logger.error('Failed to select Gemini account:', error)
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: error.message || 'No available Gemini accounts',
|
||||
message: getSafeMessage(error) || 'No available Gemini accounts',
|
||||
type: 'service_unavailable'
|
||||
}
|
||||
})
|
||||
@@ -492,7 +496,8 @@ async function handleMessages(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -596,7 +601,8 @@ async function handleMessages(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.then(() => {
|
||||
logger.info(
|
||||
@@ -614,7 +620,7 @@ async function handleMessages(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -662,7 +668,7 @@ async function handleMessages(req, res) {
|
||||
const status = errorStatus || 500
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
}
|
||||
@@ -830,16 +836,18 @@ function handleModelDetails(req, res) {
|
||||
*/
|
||||
async function handleUsage(req, res) {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -858,17 +866,18 @@ async function handleUsage(req, res) {
|
||||
async function handleKeyInfo(req, res) {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
@@ -1188,6 +1197,110 @@ async function handleOnboardUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 retrieveUserQuota 请求
|
||||
* POST /v1internal:retrieveUserQuota
|
||||
*
|
||||
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||
* 请求体:{ "project": "项目ID" }
|
||||
* 响应:{ "buckets": [...] }
|
||||
*/
|
||||
async function handleRetrieveUserQuota(req, res) {
|
||||
try {
|
||||
// 1. 权限检查
|
||||
if (!ensureGeminiPermission(req, res)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 2. 会话哈希
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 3. 账户选择
|
||||
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
const { accountId, accountType } = schedulerResult
|
||||
|
||||
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||
if (accountType === 'gemini-api') {
|
||||
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message:
|
||||
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||
type: 'invalid_account_type'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 获取账户
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Gemini account not found',
|
||||
type: 'account_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
const { accessToken, refreshToken, projectId } = account
|
||||
|
||||
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||
const requestProject = req.body.project
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||
requestedProject: requestProject || null,
|
||||
accountProject: projectId || null,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 7. 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
// 8. 获取OAuth客户端
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||
const effectiveProject = projectId || requestProject || null
|
||||
|
||||
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||
accountProjectId: projectId,
|
||||
requestProject,
|
||||
effectiveProject,
|
||||
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 10. 构建请求体(注入 effectiveProject)
|
||||
const requestBody = { ...req.body }
|
||||
if (effectiveProject) {
|
||||
requestBody.project = effectiveProject
|
||||
}
|
||||
|
||||
// 11. 调用底层服务转发请求
|
||||
const response = await geminiAccountService.forwardToCodeAssist(
|
||||
client,
|
||||
'retrieveUserQuota',
|
||||
requestBody,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
res.json(response)
|
||||
} catch (error) {
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||
error: error.message
|
||||
})
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 countTokens 请求
|
||||
*/
|
||||
@@ -1304,7 +1417,7 @@ async function handleCountTokens(req, res) {
|
||||
logger.error(`Error in countTokens endpoint (${version})`, { error: error.message })
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1490,7 +1603,8 @@ async function handleGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
@@ -1526,7 +1640,7 @@ async function handleGenerateContent(req, res) {
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1810,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
),
|
||||
applyRateLimitTracking(
|
||||
req,
|
||||
@@ -1847,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -1857,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'stream_error',
|
||||
code: error.code
|
||||
}
|
||||
@@ -1886,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2147,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
@@ -2169,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) {
|
||||
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2576,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.then(() => {
|
||||
logger.info(
|
||||
@@ -2604,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
@@ -2614,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
message: getSafeMessage(error) || 'Stream error',
|
||||
type: 'stream_error',
|
||||
code: error.code
|
||||
}
|
||||
@@ -2698,6 +2815,7 @@ module.exports = {
|
||||
handleSimpleEndpoint,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleRetrieveUserQuota,
|
||||
handleCountTokens,
|
||||
handleGenerateContent,
|
||||
handleStreamGenerateContent,
|
||||
|
||||
@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
// 工具函数
|
||||
function sleep(ms) {
|
||||
@@ -451,7 +452,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing API key attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message:
|
||||
@@ -461,7 +462,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
|
||||
// 基本API Key格式验证
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid API key format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
@@ -473,7 +474,7 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`)
|
||||
logger.security(`Invalid API key attempt: ${validation.error} from ${clientIP}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
@@ -1195,12 +1196,16 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Daily cost limit exceeded',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
code: 'daily_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: dailyCost,
|
||||
costLimit: dailyCostLimit,
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1224,9 +1229,13 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
|
||||
)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Total cost limit exceeded',
|
||||
message: `已达到总费用限制 ($${totalCostLimit})`,
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到总费用限制 ($${totalCostLimit})`,
|
||||
code: 'total_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: totalCost,
|
||||
costLimit: totalCostLimit
|
||||
})
|
||||
@@ -1239,20 +1248,20 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
||||
// 检查 Claude 周费用限制
|
||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
// 从请求中获取模型信息
|
||||
const requestBody = req.body || {}
|
||||
const model = requestBody.model || ''
|
||||
|
||||
// 判断是否为 Opus 模型
|
||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
||||
// 判断是否为 Claude 模型
|
||||
if (isClaudeFamilyModel(model)) {
|
||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||
|
||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||
logger.security(
|
||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1265,18 +1274,22 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
resetDate.setDate(now.getDate() + daysUntilMonday)
|
||||
resetDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Weekly Opus cost limit exceeded',
|
||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||
return res.status(402).json({
|
||||
error: {
|
||||
type: 'insufficient_quota',
|
||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||
code: 'weekly_opus_cost_limit_exceeded'
|
||||
},
|
||||
currentCost: weeklyOpusCost,
|
||||
costLimit: weeklyOpusCostLimit,
|
||||
resetAt: resetDate.toISOString() // 下周一重置
|
||||
resetAt: resetDate.toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 记录当前 Opus 费用使用情况
|
||||
// 记录当前 Claude 费用使用情况
|
||||
logger.api(
|
||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
||||
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1306,10 +1319,8 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
totalCostLimit: validation.keyData.totalCostLimit,
|
||||
totalCost: validation.keyData.totalCost,
|
||||
usage: validation.keyData.usage
|
||||
totalCost: validation.keyData.totalCost
|
||||
}
|
||||
req.usage = validation.keyData.usage
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
const userAgent = req.headers['user-agent'] || 'No User-Agent'
|
||||
@@ -1357,7 +1368,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
req.headers['x-admin-token']
|
||||
|
||||
if (!token) {
|
||||
logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing admin token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing admin token',
|
||||
message: 'Please provide an admin token'
|
||||
@@ -1366,7 +1377,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
|
||||
// 基本token格式验证
|
||||
if (typeof token !== 'string' || token.length < 32 || token.length > 512) {
|
||||
logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid admin token format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid admin token format',
|
||||
message: 'Admin token format is invalid'
|
||||
@@ -1382,7 +1393,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
])
|
||||
|
||||
if (!adminSession || Object.keys(adminSession).length === 0) {
|
||||
logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid admin token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid admin token',
|
||||
message: 'Invalid or expired admin session'
|
||||
@@ -1440,7 +1451,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
|
||||
return next()
|
||||
} catch (error) {
|
||||
@@ -1471,7 +1482,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
req.headers['x-user-token']
|
||||
|
||||
if (!sessionToken) {
|
||||
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Missing user session token',
|
||||
message: 'Please login to access this resource'
|
||||
@@ -1480,7 +1491,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
|
||||
// 基本token格式验证
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) {
|
||||
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid user session token format from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token format',
|
||||
message: 'Session token format is invalid'
|
||||
@@ -1491,7 +1502,7 @@ const authenticateUser = async (req, res, next) => {
|
||||
const sessionValidation = await userService.validateUserSession(sessionToken)
|
||||
|
||||
if (!sessionValidation) {
|
||||
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid user session token attempt from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session token',
|
||||
message: 'Invalid or expired user session'
|
||||
@@ -1582,7 +1593,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
req.userType = 'admin'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -1623,7 +1634,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// 如果都失败了,返回未授权
|
||||
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Authentication failed from ${req.ip || 'unknown'}`)
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Please login as user or admin to access this resource'
|
||||
|
||||
1765
src/models/redis.js
1765
src/models/redis.js
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,27 @@ function validatePermissions(permissions) {
|
||||
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 serviceRates 格式
|
||||
* @param {any} serviceRates - 服务倍率对象
|
||||
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||
*/
|
||||
function validateServiceRates(serviceRates) {
|
||||
if (serviceRates === undefined || serviceRates === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) {
|
||||
return 'Service rates must be an object'
|
||||
}
|
||||
for (const [service, rate] of Object.entries(serviceRates)) {
|
||||
const numRate = Number(rate)
|
||||
if (!Number.isFinite(numRate) || numRate < 0) {
|
||||
return `Invalid rate for service "${service}": must be a non-negative number`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 👥 用户管理 (用于API Key分配)
|
||||
|
||||
// 获取所有用户列表(用于API Key分配)
|
||||
@@ -116,14 +137,14 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
||||
const costStats = await redis.getCostStats(keyId)
|
||||
const dailyCost = await redis.getDailyCost(keyId)
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取所有相关的Redis键
|
||||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`)
|
||||
const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`)
|
||||
const costValues = await redis.batchGetChunked(costKeys)
|
||||
const keyValues = {}
|
||||
|
||||
for (const key of costKeys) {
|
||||
keyValues[key] = await client.get(key)
|
||||
for (let i = 0; i < costKeys.length; i++) {
|
||||
keyValues[costKeys[i]] = costValues[i]
|
||||
}
|
||||
|
||||
return res.json({
|
||||
@@ -324,20 +345,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 为每个API Key添加owner的displayName
|
||||
for (const apiKey of result.items) {
|
||||
if (apiKey.userId) {
|
||||
try {
|
||||
const user = await userService.getUserById(apiKey.userId, false)
|
||||
if (user) {
|
||||
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||||
} else {
|
||||
apiKey.ownerDisplayName = 'Unknown User'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error)
|
||||
apiKey.ownerDisplayName = 'Unknown User'
|
||||
// 为每个API Key添加owner的displayName(批量获取优化)
|
||||
const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))]
|
||||
const userMap = new Map()
|
||||
|
||||
if (userIdsToFetch.length > 0) {
|
||||
// 批量获取用户信息
|
||||
const users = await Promise.all(
|
||||
userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null))
|
||||
)
|
||||
userIdsToFetch.forEach((id, i) => {
|
||||
if (users[i]) {
|
||||
userMap.set(id, users[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const apiKey of result.items) {
|
||||
if (apiKey.userId && userMap.has(apiKey.userId)) {
|
||||
const user = userMap.get(apiKey.userId)
|
||||
apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User'
|
||||
} else if (apiKey.userId) {
|
||||
apiKey.ownerDisplayName = 'Unknown User'
|
||||
} else {
|
||||
apiKey.ownerDisplayName =
|
||||
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
||||
@@ -608,6 +637,56 @@ router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// 获取 API Key 索引状态
|
||||
router.get('/api-keys/index-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||
const status = await apiKeyIndexService.getStatus()
|
||||
return res.json({ success: true, data: status })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API Key index status:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get index status',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重建 API Key 索引
|
||||
router.post('/api-keys/index-rebuild', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const apiKeyIndexService = require('../../services/apiKeyIndexService')
|
||||
const status = await apiKeyIndexService.getStatus()
|
||||
|
||||
if (status.building) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'INDEX_BUILDING',
|
||||
message: '索引正在重建中,请稍后再试',
|
||||
progress: status.progress
|
||||
})
|
||||
}
|
||||
|
||||
// 异步重建,不等待完成
|
||||
apiKeyIndexService.rebuildIndexes().catch((err) => {
|
||||
logger.error('❌ Failed to rebuild API Key index:', err)
|
||||
})
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'API Key 索引重建已开始'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to trigger API Key index rebuild:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to trigger rebuild',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 强制刷新费用排序索引
|
||||
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -673,22 +752,7 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
||||
// 获取已存在的标签列表
|
||||
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const tagSet = new Set()
|
||||
|
||||
// 收集所有API Keys的标签
|
||||
for (const apiKey of apiKeys) {
|
||||
if (apiKey.tags && Array.isArray(apiKey.tags)) {
|
||||
apiKey.tags.forEach((tag) => {
|
||||
if (tag && tag.trim()) {
|
||||
tagSet.add(tag.trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为数组并排序
|
||||
const tags = Array.from(tagSet).sort()
|
||||
const tags = await apiKeyService.getAllTags()
|
||||
|
||||
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`)
|
||||
return res.json({ success: true, data: tags })
|
||||
@@ -698,6 +762,93 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 获取标签详情(含使用数量)
|
||||
router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const tagDetails = await apiKeyService.getTagsWithCount()
|
||||
logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`)
|
||||
return res.json({ success: true, data: tagDetails })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get tag details:', error)
|
||||
return res.status(500).json({ error: 'Failed to get tag details', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新标签
|
||||
router.post('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: '标签名称不能为空' })
|
||||
}
|
||||
|
||||
const result = await apiKeyService.createTag(name.trim())
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error })
|
||||
}
|
||||
|
||||
logger.info(`🏷️ Created new tag: ${name}`)
|
||||
return res.json({ success: true, message: '标签创建成功' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to create tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除标签(从所有 API Key 中移除)
|
||||
router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { tagName } = req.params
|
||||
if (!tagName) {
|
||||
return res.status(400).json({ error: 'Tag name is required' })
|
||||
}
|
||||
|
||||
const decodedTagName = decodeURIComponent(tagName)
|
||||
const result = await apiKeyService.removeTagFromAllKeys(decodedTagName)
|
||||
|
||||
logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`,
|
||||
affectedCount: result.affectedCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to delete tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 重命名标签
|
||||
router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { tagName } = req.params
|
||||
const { newName } = req.body
|
||||
if (!tagName || !newName || !newName.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name and new name are required' })
|
||||
}
|
||||
|
||||
const decodedTagName = decodeURIComponent(tagName)
|
||||
const trimmedNewName = newName.trim()
|
||||
const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName)
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).json({ error: result.error })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`
|
||||
)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Tag renamed in ${result.affectedCount} API keys`,
|
||||
affectedCount: result.affectedCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to rename tag:', error)
|
||||
return res.status(500).json({ error: 'Failed to rename tag', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取账户绑定的 API Key 数量统计
|
||||
* GET /admin/accounts/binding-counts
|
||||
@@ -1298,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
activationDays, // 新增:激活后有效天数
|
||||
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||
expirationMode, // 新增:过期模式
|
||||
icon // 新增:图标
|
||||
icon, // 新增:图标
|
||||
serviceRates // API Key 级别服务倍率
|
||||
} = req.body
|
||||
|
||||
// 输入验证
|
||||
@@ -1425,6 +1577,12 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: permissionsError })
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
const serviceRatesError = validateServiceRates(serviceRates)
|
||||
if (serviceRatesError) {
|
||||
return res.status(400).json({ error: serviceRatesError })
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name,
|
||||
description,
|
||||
@@ -1452,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
})
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`)
|
||||
@@ -1494,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
} = req.body
|
||||
|
||||
// 输入验证
|
||||
@@ -1518,6 +1678,12 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: batchPermissionsError })
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
const batchServiceRatesError = validateServiceRates(serviceRates)
|
||||
if (batchServiceRatesError) {
|
||||
return res.status(400).json({ error: batchServiceRatesError })
|
||||
}
|
||||
|
||||
// 生成批量API Keys
|
||||
const createdKeys = []
|
||||
const errors = []
|
||||
@@ -1552,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
activationDays,
|
||||
activationUnit,
|
||||
expirationMode,
|
||||
icon
|
||||
icon,
|
||||
serviceRates
|
||||
})
|
||||
|
||||
// 保留原始 API Key 供返回
|
||||
@@ -1626,6 +1793,14 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证服务倍率
|
||||
if (updates.serviceRates !== undefined) {
|
||||
const updateServiceRatesError = validateServiceRates(updates.serviceRates)
|
||||
if (updateServiceRatesError) {
|
||||
return res.status(400).json({ error: updateServiceRatesError })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||||
)
|
||||
@@ -1694,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
if (updates.enabled !== undefined) {
|
||||
finalUpdates.enabled = updates.enabled
|
||||
}
|
||||
if (updates.serviceRates !== undefined) {
|
||||
finalUpdates.serviceRates = updates.serviceRates
|
||||
}
|
||||
|
||||
// 处理账户绑定
|
||||
if (updates.claudeAccountId !== undefined) {
|
||||
@@ -1750,7 +1928,7 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
// 执行更新
|
||||
await apiKeyService.updateApiKey(keyId, finalUpdates)
|
||||
results.successCount++
|
||||
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
|
||||
logger.success(`Batch edit: API key ${keyId} updated successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
|
||||
@@ -1811,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
totalCostLimit,
|
||||
weeklyOpusCostLimit,
|
||||
tags,
|
||||
ownerId // 新增:所有者ID字段
|
||||
ownerId, // 新增:所有者ID字段
|
||||
serviceRates // API Key 级别服务倍率
|
||||
} = req.body
|
||||
|
||||
// 只允许更新指定字段
|
||||
@@ -1997,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.tags = tags
|
||||
}
|
||||
|
||||
// 处理服务倍率
|
||||
if (serviceRates !== undefined) {
|
||||
const singleServiceRatesError = validateServiceRates(serviceRates)
|
||||
if (singleServiceRatesError) {
|
||||
return res.status(400).json({ error: singleServiceRatesError })
|
||||
}
|
||||
updates.serviceRates = serviceRates
|
||||
}
|
||||
|
||||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||||
if (isActive !== undefined) {
|
||||
if (typeof isActive !== 'boolean') {
|
||||
@@ -2200,7 +2388,7 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
await apiKeyService.deleteApiKey(keyId)
|
||||
results.successCount++
|
||||
|
||||
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
|
||||
logger.success(`Batch delete: API key ${keyId} deleted successfully`)
|
||||
} catch (error) {
|
||||
results.failedCount++
|
||||
results.errors.push({
|
||||
@@ -2255,13 +2443,13 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
// 📋 获取已删除的API Keys
|
||||
router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted
|
||||
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true')
|
||||
const deletedApiKeys = await apiKeyService.getAllApiKeysFast(true) // Include deleted
|
||||
const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === true)
|
||||
|
||||
// Add additional metadata for deleted keys
|
||||
const enrichedKeys = onlyDeleted.map((key) => ({
|
||||
...key,
|
||||
isDeleted: key.isDeleted === 'true',
|
||||
isDeleted: key.isDeleted === true,
|
||||
deletedAt: key.deletedAt,
|
||||
deletedBy: key.deletedBy,
|
||||
deletedByType: key.deletedByType,
|
||||
@@ -2288,7 +2476,7 @@ router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
|
||||
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`)
|
||||
logger.success(`Admin ${adminUsername} restored API key: ${keyId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'API Key 已成功恢复',
|
||||
|
||||
@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Azure OpenAI 账户连通性
|
||||
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
const payload = createOpenAITestPayload(deploymentName)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': apiKey
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model: deploymentName,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
description,
|
||||
region,
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority,
|
||||
accountType,
|
||||
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证credentialType的有效性
|
||||
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
||||
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
description: description || '',
|
||||
region: region || 'us-east-1',
|
||||
awsCredentials,
|
||||
bearerToken,
|
||||
defaultModel,
|
||||
priority: priority || 50,
|
||||
accountType: accountType || 'shared',
|
||||
credentialType: credentialType || 'default'
|
||||
credentialType: credentialType || 'access_key'
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
||||
// 验证credentialType的有效性
|
||||
if (
|
||||
mappedUpdates.credentialType &&
|
||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
||||
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试Bedrock账户连接
|
||||
// 测试Bedrock账户连接(SSE 流式)
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await bedrockAccountService.testAccount(accountId)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
||||
}
|
||||
|
||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
||||
return res.json({ success: true, data: result.data })
|
||||
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to test Bedrock account:', error)
|
||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
||||
// 错误已在服务层处理,这里仅做日志记录
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@ router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
await ccrAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||
@@ -390,7 +390,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
||||
logger.success(`Admin reset status for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account status:', error)
|
||||
@@ -403,7 +403,7 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await ccrAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
||||
logger.success('Admin manually reset daily usage for all CCR accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 CCR 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的凭据
|
||||
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
|
||||
if (!credentials) {
|
||||
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
|
||||
const apiUrl = `${baseUrl}/v1/messages`
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': credentials.apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -36,7 +36,7 @@ router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||
})
|
||||
|
||||
logger.success('🔗 Generated OAuth authorization URL with proxy support')
|
||||
logger.success('Generated OAuth authorization URL with proxy support')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -152,7 +152,7 @@ router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, asyn
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
|
||||
})
|
||||
|
||||
logger.success('🔗 Generated Setup Token authorization URL with proxy support')
|
||||
logger.success('Generated Setup Token authorization URL with proxy support')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -786,7 +786,7 @@ router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, asy
|
||||
|
||||
const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId)
|
||||
|
||||
logger.success(`✅ Updated profile for Claude account: ${accountId}`)
|
||||
logger.success(`Updated profile for Claude account: ${accountId}`)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Account profile updated successfully',
|
||||
@@ -805,7 +805,7 @@ router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (re
|
||||
try {
|
||||
const result = await claudeAccountService.updateAllAccountProfiles()
|
||||
|
||||
logger.success('✅ Batch profile update completed')
|
||||
logger.success('Batch profile update completed')
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Batch profile update completed',
|
||||
@@ -841,7 +841,7 @@ router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async
|
||||
|
||||
const result = await claudeAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for Claude account: ${accountId}`)
|
||||
logger.success(`Admin reset status for Claude account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude account status:', error)
|
||||
|
||||
@@ -441,7 +441,7 @@ router.post(
|
||||
const { accountId } = req.params
|
||||
await claudeConsoleAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||
logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account daily usage:', error)
|
||||
@@ -458,7 +458,7 @@ router.post(
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||
logger.success(`Admin reset status for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||
@@ -472,7 +472,7 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
|
||||
try {
|
||||
await claudeConsoleAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
|
||||
logger.success('Admin manually reset daily usage for all Claude Console accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
|
||||
|
||||
@@ -20,9 +20,14 @@ const router = express.Router()
|
||||
// 获取系统概览
|
||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
// 先检查是否有全局预聚合数据
|
||||
const globalStats = await redis.getGlobalStats()
|
||||
|
||||
// 根据是否有全局统计决定查询策略
|
||||
let apiKeys = null
|
||||
let apiKeyCount = null
|
||||
|
||||
const [
|
||||
,
|
||||
apiKeys,
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
@@ -35,8 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
@@ -50,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
redis.getRealtimeSystemMetrics()
|
||||
])
|
||||
|
||||
// 有全局统计时只获取计数,否则拉全量
|
||||
if (globalStats) {
|
||||
apiKeyCount = await redis.getApiKeyCount()
|
||||
} else {
|
||||
apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
}
|
||||
|
||||
// 处理Bedrock账户数据
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
@@ -66,250 +76,118 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
normalizeBoolean(acc.schedulable) &&
|
||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
const abnormalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
!normalizeBoolean(acc.schedulable) &&
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
||||
isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
// 通用账户统计函数 - 单次遍历完成所有统计
|
||||
const countAccountStats = (accounts, opts = {}) => {
|
||||
const { isStringType = false, checkGeminiRateLimit = false } = opts
|
||||
let normal = 0,
|
||||
abnormal = 0,
|
||||
paused = 0,
|
||||
rateLimited = 0
|
||||
|
||||
// 计算使用统计(统一使用allTokens)
|
||||
const totalTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalRequestsUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.requests || 0),
|
||||
0
|
||||
)
|
||||
const totalInputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.inputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalOutputTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.outputTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheCreateTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalCacheReadTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0),
|
||||
0
|
||||
)
|
||||
const totalAllTokensUsed = apiKeys.reduce(
|
||||
(sum, key) => sum + (key.usage?.total?.allTokens || 0),
|
||||
0
|
||||
)
|
||||
for (const acc of accounts) {
|
||||
const isActive = isStringType
|
||||
? acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
|
||||
: acc.isActive
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
const isSchedulable = isStringType
|
||||
? acc.schedulable !== 'false' && acc.schedulable !== false
|
||||
: acc.schedulable !== false
|
||||
const isRateLimited = checkGeminiRateLimit
|
||||
? acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
|
||||
const activeApiKeys = apiKeys.filter((key) => key.isActive).length
|
||||
if (!isActive || isBlocked) {
|
||||
abnormal++
|
||||
} else if (!isSchedulable) {
|
||||
paused++
|
||||
} else if (isRateLimited) {
|
||||
rateLimited++
|
||||
} else {
|
||||
normal++
|
||||
}
|
||||
}
|
||||
return { normal, abnormal, paused, rateLimited }
|
||||
}
|
||||
|
||||
// Claude账户统计 - 根据账户管理页面的判断逻辑
|
||||
const normalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
// Droid 账户统计(特殊逻辑)
|
||||
let normalDroidAccounts = 0,
|
||||
abnormalDroidAccounts = 0,
|
||||
pausedDroidAccounts = 0,
|
||||
rateLimitedDroidAccounts = 0
|
||||
for (const acc of droidAccounts) {
|
||||
const isActive = normalizeBoolean(acc.isActive)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
const isSchedulable = normalizeBoolean(acc.schedulable)
|
||||
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
|
||||
|
||||
// Claude Console账户统计
|
||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
if (!isActive || isBlocked) {
|
||||
abnormalDroidAccounts++
|
||||
} else if (!isSchedulable) {
|
||||
pausedDroidAccounts++
|
||||
} else if (isRateLimited) {
|
||||
rateLimitedDroidAccounts++
|
||||
} else {
|
||||
normalDroidAccounts++
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini账户统计
|
||||
const normalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
)
|
||||
).length
|
||||
const abnormalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
// 计算使用统计
|
||||
let totalTokensUsed = 0,
|
||||
totalRequestsUsed = 0,
|
||||
totalInputTokensUsed = 0,
|
||||
totalOutputTokensUsed = 0,
|
||||
totalCacheCreateTokensUsed = 0,
|
||||
totalCacheReadTokensUsed = 0,
|
||||
totalAllTokensUsed = 0,
|
||||
activeApiKeys = 0,
|
||||
totalApiKeys = 0
|
||||
|
||||
// Bedrock账户统计
|
||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
if (globalStats) {
|
||||
// 使用预聚合数据(快速路径)
|
||||
totalRequestsUsed = globalStats.requests
|
||||
totalInputTokensUsed = globalStats.inputTokens
|
||||
totalOutputTokensUsed = globalStats.outputTokens
|
||||
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
|
||||
totalCacheReadTokensUsed = globalStats.cacheReadTokens
|
||||
totalAllTokensUsed = globalStats.allTokens
|
||||
totalTokensUsed = totalAllTokensUsed
|
||||
totalApiKeys = apiKeyCount.total
|
||||
activeApiKeys = apiKeyCount.active
|
||||
} else {
|
||||
// 回退到遍历(兼容旧数据)
|
||||
totalApiKeys = apiKeys.length
|
||||
for (const key of apiKeys) {
|
||||
const usage = key.usage?.total
|
||||
if (usage) {
|
||||
totalTokensUsed += usage.allTokens || 0
|
||||
totalRequestsUsed += usage.requests || 0
|
||||
totalInputTokensUsed += usage.inputTokens || 0
|
||||
totalOutputTokensUsed += usage.outputTokens || 0
|
||||
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||
totalAllTokensUsed += usage.allTokens || 0
|
||||
}
|
||||
if (key.isActive) {
|
||||
activeApiKeys++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI账户统计
|
||||
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
||||
const normalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false && // 包括'true'、true和undefined
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// CCR账户统计
|
||||
const normalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// OpenAI-Responses账户统计
|
||||
// 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型
|
||||
const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
// 各平台账户统计(单次遍历)
|
||||
const claudeStats = countAccountStats(claudeAccounts)
|
||||
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts)
|
||||
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true })
|
||||
const bedrockStats = countAccountStats(bedrockAccounts)
|
||||
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true })
|
||||
const ccrStats = countAccountStats(ccrAccounts)
|
||||
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true })
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
totalApiKeys,
|
||||
activeApiKeys,
|
||||
// 总账户统计(所有平台)
|
||||
totalAccounts:
|
||||
@@ -321,90 +199,90 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
openaiResponsesAccounts.length +
|
||||
ccrAccounts.length,
|
||||
normalAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts,
|
||||
claudeStats.normal +
|
||||
claudeConsoleStats.normal +
|
||||
geminiStats.normal +
|
||||
bedrockStats.normal +
|
||||
openaiStats.normal +
|
||||
openaiResponsesStats.normal +
|
||||
ccrStats.normal,
|
||||
abnormalAccounts:
|
||||
abnormalClaudeAccounts +
|
||||
abnormalClaudeConsoleAccounts +
|
||||
abnormalGeminiAccounts +
|
||||
abnormalBedrockAccounts +
|
||||
abnormalOpenAIAccounts +
|
||||
abnormalOpenAIResponsesAccounts +
|
||||
abnormalCcrAccounts +
|
||||
claudeStats.abnormal +
|
||||
claudeConsoleStats.abnormal +
|
||||
geminiStats.abnormal +
|
||||
bedrockStats.abnormal +
|
||||
openaiStats.abnormal +
|
||||
openaiResponsesStats.abnormal +
|
||||
ccrStats.abnormal +
|
||||
abnormalDroidAccounts,
|
||||
pausedAccounts:
|
||||
pausedClaudeAccounts +
|
||||
pausedClaudeConsoleAccounts +
|
||||
pausedGeminiAccounts +
|
||||
pausedBedrockAccounts +
|
||||
pausedOpenAIAccounts +
|
||||
pausedOpenAIResponsesAccounts +
|
||||
pausedCcrAccounts +
|
||||
claudeStats.paused +
|
||||
claudeConsoleStats.paused +
|
||||
geminiStats.paused +
|
||||
bedrockStats.paused +
|
||||
openaiStats.paused +
|
||||
openaiResponsesStats.paused +
|
||||
ccrStats.paused +
|
||||
pausedDroidAccounts,
|
||||
rateLimitedAccounts:
|
||||
rateLimitedClaudeAccounts +
|
||||
rateLimitedClaudeConsoleAccounts +
|
||||
rateLimitedGeminiAccounts +
|
||||
rateLimitedBedrockAccounts +
|
||||
rateLimitedOpenAIAccounts +
|
||||
rateLimitedOpenAIResponsesAccounts +
|
||||
rateLimitedCcrAccounts +
|
||||
claudeStats.rateLimited +
|
||||
claudeConsoleStats.rateLimited +
|
||||
geminiStats.rateLimited +
|
||||
bedrockStats.rateLimited +
|
||||
openaiStats.rateLimited +
|
||||
openaiResponsesStats.rateLimited +
|
||||
ccrStats.rateLimited +
|
||||
rateLimitedDroidAccounts,
|
||||
// 各平台详细统计
|
||||
accountsByPlatform: {
|
||||
claude: {
|
||||
total: claudeAccounts.length,
|
||||
normal: normalClaudeAccounts,
|
||||
abnormal: abnormalClaudeAccounts,
|
||||
paused: pausedClaudeAccounts,
|
||||
rateLimited: rateLimitedClaudeAccounts
|
||||
normal: claudeStats.normal,
|
||||
abnormal: claudeStats.abnormal,
|
||||
paused: claudeStats.paused,
|
||||
rateLimited: claudeStats.rateLimited
|
||||
},
|
||||
'claude-console': {
|
||||
total: claudeConsoleAccounts.length,
|
||||
normal: normalClaudeConsoleAccounts,
|
||||
abnormal: abnormalClaudeConsoleAccounts,
|
||||
paused: pausedClaudeConsoleAccounts,
|
||||
rateLimited: rateLimitedClaudeConsoleAccounts
|
||||
normal: claudeConsoleStats.normal,
|
||||
abnormal: claudeConsoleStats.abnormal,
|
||||
paused: claudeConsoleStats.paused,
|
||||
rateLimited: claudeConsoleStats.rateLimited
|
||||
},
|
||||
gemini: {
|
||||
total: geminiAccounts.length,
|
||||
normal: normalGeminiAccounts,
|
||||
abnormal: abnormalGeminiAccounts,
|
||||
paused: pausedGeminiAccounts,
|
||||
rateLimited: rateLimitedGeminiAccounts
|
||||
normal: geminiStats.normal,
|
||||
abnormal: geminiStats.abnormal,
|
||||
paused: geminiStats.paused,
|
||||
rateLimited: geminiStats.rateLimited
|
||||
},
|
||||
bedrock: {
|
||||
total: bedrockAccounts.length,
|
||||
normal: normalBedrockAccounts,
|
||||
abnormal: abnormalBedrockAccounts,
|
||||
paused: pausedBedrockAccounts,
|
||||
rateLimited: rateLimitedBedrockAccounts
|
||||
normal: bedrockStats.normal,
|
||||
abnormal: bedrockStats.abnormal,
|
||||
paused: bedrockStats.paused,
|
||||
rateLimited: bedrockStats.rateLimited
|
||||
},
|
||||
openai: {
|
||||
total: openaiAccounts.length,
|
||||
normal: normalOpenAIAccounts,
|
||||
abnormal: abnormalOpenAIAccounts,
|
||||
paused: pausedOpenAIAccounts,
|
||||
rateLimited: rateLimitedOpenAIAccounts
|
||||
normal: openaiStats.normal,
|
||||
abnormal: openaiStats.abnormal,
|
||||
paused: openaiStats.paused,
|
||||
rateLimited: openaiStats.rateLimited
|
||||
},
|
||||
ccr: {
|
||||
total: ccrAccounts.length,
|
||||
normal: normalCcrAccounts,
|
||||
abnormal: abnormalCcrAccounts,
|
||||
paused: pausedCcrAccounts,
|
||||
rateLimited: rateLimitedCcrAccounts
|
||||
normal: ccrStats.normal,
|
||||
abnormal: ccrStats.abnormal,
|
||||
paused: ccrStats.paused,
|
||||
rateLimited: ccrStats.rateLimited
|
||||
},
|
||||
'openai-responses': {
|
||||
total: openaiResponsesAccounts.length,
|
||||
normal: normalOpenAIResponsesAccounts,
|
||||
abnormal: abnormalOpenAIResponsesAccounts,
|
||||
paused: pausedOpenAIResponsesAccounts,
|
||||
rateLimited: rateLimitedOpenAIResponsesAccounts
|
||||
normal: openaiResponsesStats.normal,
|
||||
abnormal: openaiResponsesStats.abnormal,
|
||||
paused: openaiResponsesStats.paused,
|
||||
rateLimited: openaiResponsesStats.rateLimited
|
||||
},
|
||||
droid: {
|
||||
total: droidAccounts.length,
|
||||
@@ -416,20 +294,20 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
// 保留旧字段以兼容
|
||||
activeAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts +
|
||||
normalOpenAIResponsesAccounts +
|
||||
normalCcrAccounts +
|
||||
claudeStats.normal +
|
||||
claudeConsoleStats.normal +
|
||||
geminiStats.normal +
|
||||
bedrockStats.normal +
|
||||
openaiStats.normal +
|
||||
openaiResponsesStats.normal +
|
||||
ccrStats.normal +
|
||||
normalDroidAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
|
||||
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
|
||||
totalGeminiAccounts: geminiAccounts.length,
|
||||
activeGeminiAccounts: normalGeminiAccounts,
|
||||
rateLimitedGeminiAccounts,
|
||||
activeGeminiAccounts: geminiStats.normal,
|
||||
rateLimitedGeminiAccounts: geminiStats.rateLimited,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
@@ -459,8 +337,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
systemHealth: {
|
||||
redisConnected: redis.isConnected,
|
||||
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
||||
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
|
||||
geminiAccountsHealthy: geminiStats.normal > 0,
|
||||
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||
uptime: process.uptime()
|
||||
},
|
||||
@@ -480,7 +358,7 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
const { period = 'daily' } = req.query // daily, monthly
|
||||
|
||||
// 获取基础API Key统计
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
const stats = apiKeys.map((key) => ({
|
||||
keyId: key.id,
|
||||
@@ -510,55 +388,48 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
|
||||
)
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 获取所有模型的统计数据
|
||||
let searchPatterns = []
|
||||
// 收集所有需要扫描的日期
|
||||
const datePatterns = []
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 自定义日期范围,生成多个日期的搜索模式
|
||||
// 自定义日期范围
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
// 确保日期范围有效
|
||||
if (start > end) {
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内所有日期的搜索模式
|
||||
const currentDate = new Date(start)
|
||||
while (currentDate <= end) {
|
||||
const dateStr = redis.getDateStringInTimezone(currentDate)
|
||||
searchPatterns.push(`usage:model:daily:*:${dateStr}`)
|
||||
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
}
|
||||
|
||||
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`)
|
||||
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`)
|
||||
} else {
|
||||
// 使用默认的period
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:model:daily:*:${today}`
|
||||
: `usage:model:monthly:*:${currentMonth}`
|
||||
searchPatterns = [pattern]
|
||||
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
|
||||
}
|
||||
|
||||
logger.info('📊 Searching patterns:', searchPatterns)
|
||||
|
||||
// 获取所有匹配的keys
|
||||
const allKeys = []
|
||||
for (const pattern of searchPatterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
allKeys.push(...keys)
|
||||
// 按日期集合扫描,串行避免并行触发多次全库 SCAN
|
||||
const allResults = []
|
||||
for (const { pattern } of datePatterns) {
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
allResults.push(...results)
|
||||
}
|
||||
|
||||
logger.info(`📊 Found ${allKeys.length} matching keys in total`)
|
||||
logger.info(`📊 Found ${allResults.length} matching keys in total`)
|
||||
|
||||
// 模型名标准化函数(与redis.js保持一致)
|
||||
const normalizeModelName = (model) => {
|
||||
@@ -568,23 +439,23 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
|
||||
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '')
|
||||
normalized = normalized.replace('anthropic.', '')
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '')
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 对于其他模型,去掉常见的版本后缀
|
||||
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||
}
|
||||
|
||||
// 聚合相同模型的数据
|
||||
const modelStatsMap = new Map()
|
||||
|
||||
for (const key of allKeys) {
|
||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||
for (const { key, data } of allResults) {
|
||||
// 支持 daily 和 monthly 两种格式
|
||||
const match =
|
||||
key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
|
||||
key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/)
|
||||
|
||||
if (!match) {
|
||||
logger.warn(`📊 Pattern mismatch for key: ${key}`)
|
||||
@@ -593,7 +464,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const rawModel = match[1]
|
||||
const normalizedModel = normalizeModelName(rawModel)
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const stats = modelStatsMap.get(normalizedModel) || {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -142,67 +143,143 @@ router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res)
|
||||
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await droidAccountService.getAllAccounts()
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 添加使用统计
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||
let groupInfos = []
|
||||
try {
|
||||
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
} catch (groupError) {
|
||||
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
||||
groupInfos = []
|
||||
}
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'),
|
||||
redis.batchGetAccountDailyCost(accountIds)
|
||||
])
|
||||
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
const binding = key.droidAccountId
|
||||
if (!binding) {
|
||||
return count
|
||||
}
|
||||
if (binding === account.id) {
|
||||
return count + 1
|
||||
}
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
if (groupIds.includes(groupId)) {
|
||||
return count + 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, 0)
|
||||
// 构建绑定数映射(droid 需要展开 group 绑定)
|
||||
// 1. 先构建 groupId -> accountIds 映射
|
||||
const groupToAccountIds = new Map()
|
||||
for (const [accountId, groups] of allGroupInfosMap) {
|
||||
for (const group of groups) {
|
||||
if (!groupToAccountIds.has(group.id)) {
|
||||
groupToAccountIds.set(group.id, [])
|
||||
}
|
||||
groupToAccountIds.get(group.id).push(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
schedulable: account.schedulable === 'true',
|
||||
boundApiKeysCount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
boundApiKeysCount: 0,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
// 2. 单次遍历构建绑定数
|
||||
const directBindingCount = new Map()
|
||||
const groupBindingCount = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.droidAccountId
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)
|
||||
} else {
|
||||
directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取使用统计
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
|
||||
// 构建 accountId -> createdAt 映射用于计算 averages
|
||||
const accountCreatedAtMap = new Map()
|
||||
for (const account of accounts) {
|
||||
accountCreatedAtMap.set(
|
||||
account.id,
|
||||
account.createdAt ? new Date(account.createdAt) : new Date()
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const totalData = errTotal ? {} : parseUsage(total)
|
||||
const totalTokens = totalData.tokens || 0
|
||||
const totalRequests = totalData.requests || 0
|
||||
|
||||
// 计算 averages
|
||||
const createdAt = accountCreatedAtMap.get(accountId)
|
||||
const now = new Date()
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: totalData,
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly),
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
|
||||
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 处理账户数据
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
monthly: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
|
||||
}
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
|
||||
// 计算绑定数:直接绑定 + 通过 group 绑定
|
||||
let boundApiKeysCount = directBindingCount.get(account.id) || 0
|
||||
for (const group of groupInfos) {
|
||||
boundApiKeysCount += groupBindingCount.get(group.id) || 0
|
||||
}
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
schedulable: account.schedulable === 'true',
|
||||
boundApiKeysCount,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -434,7 +511,7 @@ router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 获取绑定的 API Key 数量
|
||||
const allApiKeys = await redis.getAllApiKeys()
|
||||
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
const groupIds = groupInfos.map((group) => group.id)
|
||||
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||
const binding = key.droidAccountId
|
||||
@@ -524,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Droid 账户连通性
|
||||
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await droidAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await droidAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const { accessToken } = tokenResult
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = 'https://api.factory.ai/v1/messages'
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -74,7 +74,7 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
const result = await geminiAccountService.pollAuthorizationStatus(sessionId)
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||
logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`)
|
||||
return res.json({ success: true, data: { tokens: result.tokens } })
|
||||
} else {
|
||||
return res.json({ success: false, error: result.error })
|
||||
@@ -143,7 +143,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||
logger.success('Successfully exchanged Gemini authorization code')
|
||||
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||
@@ -498,7 +498,7 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const result = await geminiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini account: ${id}`)
|
||||
logger.success(`Admin reset status for Gemini account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini account status:', error)
|
||||
@@ -506,4 +506,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Gemini 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gemini-2.5-flash' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const { accessToken } = tokenResult
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||
const payload = createGeminiTestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||
responseText = response.data.candidates[0].content.parts[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -31,53 +31,108 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
// 检查并清除过期的限流状态
|
||||
await geminiApiAccountService.checkAndClearRateLimit(account.id)
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'gemini-api')
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to get usage stats for Gemini-API account ${account.id}:`, error)
|
||||
usageStats = {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'),
|
||||
redis.batchGetAccountDailyCost(accountIds),
|
||||
// 批量清除限流状态
|
||||
Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id)))
|
||||
])
|
||||
|
||||
// 计算绑定的API Key数量(支持 api: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||
const bindingCountMap = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.geminiAccountId
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
// 处理 api: 前缀
|
||||
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
|
||||
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key.geminiAccountId) {
|
||||
// 检查是否绑定了此 Gemini-API 账户(支持 api: 前缀)
|
||||
if (key.geminiAccountId === `api:${account.id}`) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
// 批量获取使用统计
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
return {
|
||||
...account,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages || usageStats.monthly
|
||||
},
|
||||
boundApiKeys: boundCount
|
||||
}
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
)
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: errTotal ? {} : parseUsage(total),
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理账户数据
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
const boundCount = bindingCountMap.get(account.id) || 0
|
||||
|
||||
// 计算 averages(rpm/tpm)
|
||||
const createdAt = account.createdAt ? new Date(account.createdAt) : new Date()
|
||||
const daysSinceCreated = Math.max(
|
||||
1,
|
||||
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
const totalMinutes = daysSinceCreated * 24 * 60
|
||||
const totalRequests = usageStats.total.requests || 0
|
||||
const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0
|
||||
|
||||
return {
|
||||
...account,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
|
||||
}
|
||||
},
|
||||
boundApiKeys: boundCount
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -275,7 +330,7 @@ router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) =>
|
||||
message += `,${unboundCount} 个 API Key 已切换为共享池模式`
|
||||
}
|
||||
|
||||
logger.success(`✅ ${message}`)
|
||||
logger.success(`${message}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -389,7 +444,7 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
|
||||
|
||||
const result = await geminiApiAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for Gemini-API account: ${id}`)
|
||||
logger.success(`Admin reset status for Gemini-API account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Gemini-API account status:', error)
|
||||
|
||||
@@ -26,6 +26,8 @@ const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
const serviceRatesRoutes = require('./serviceRates')
|
||||
const quotaCardsRoutes = require('./quotaCards')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -43,6 +45,8 @@ router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
router.use('/', serviceRatesRoutes)
|
||||
router.use('/', quotaCardsRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
@@ -80,7 +80,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}`
|
||||
|
||||
logger.success('🔗 Generated OpenAI OAuth authorization URL')
|
||||
logger.success('Generated OpenAI OAuth authorization URL')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -191,7 +191,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
// 清理 Redis 会话
|
||||
await redis.deleteOAuthSession(sessionId)
|
||||
|
||||
logger.success('✅ OpenAI OAuth token exchange successful')
|
||||
logger.success('OpenAI OAuth token exchange successful')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -386,7 +386,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
delete refreshedAccount.accessToken
|
||||
delete refreshedAccount.refreshToken
|
||||
|
||||
logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||
logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -450,7 +450,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -541,7 +541,7 @@ router.put('/:id', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(`✅ Token 验证成功,继续更新账户信息`)
|
||||
logger.success(`Token 验证成功,继续更新账户信息`)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,恢复原始 token
|
||||
logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`)
|
||||
@@ -755,7 +755,7 @@ router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||
logger.success(`Admin reset status for OpenAI account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||
|
||||
@@ -39,92 +39,97 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
// 检查是否需要重置额度
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
// 今天还没重置过,需要重置
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: today,
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
account.dailyUsage = '0'
|
||||
account.lastResetDate = today
|
||||
account.quotaStoppedAt = ''
|
||||
}
|
||||
const accountIds = accounts.map((a) => a.id)
|
||||
|
||||
// 检查并清除过期的限流状态
|
||||
await openaiResponsesAccountService.checkAndClearRateLimit(account.id)
|
||||
// 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态
|
||||
const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysLite(),
|
||||
accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'),
|
||||
redis.batchGetAccountDailyCost(accountIds),
|
||||
// 批量清理限流状态
|
||||
Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id)))
|
||||
])
|
||||
|
||||
// 获取使用统计信息
|
||||
let usageStats
|
||||
try {
|
||||
usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses')
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to get usage stats for OpenAI-Responses account ${account.id}:`,
|
||||
error
|
||||
)
|
||||
usageStats = {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
// 单次遍历构建绑定数映射(只算直连,不算 group)
|
||||
const bindingCountMap = new Map()
|
||||
for (const key of allApiKeys) {
|
||||
const binding = key.openaiAccountId
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
// 处理 responses: 前缀
|
||||
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
|
||||
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)
|
||||
}
|
||||
|
||||
// 计算绑定的API Key数量(支持 responses: 前缀)
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
let boundCount = 0
|
||||
// 批量获取使用统计(不含 daily cost,已单独获取)
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
for (const key of allKeys) {
|
||||
// 检查是否绑定了该账户(包括 responses: 前缀)
|
||||
if (
|
||||
key.openaiAccountId === account.id ||
|
||||
key.openaiAccountId === `responses:${account.id}`
|
||||
) {
|
||||
boundCount++
|
||||
}
|
||||
}
|
||||
const statsPipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
statsPipeline.hgetall(`account_usage:${accountId}`)
|
||||
statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`)
|
||||
statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`)
|
||||
}
|
||||
const statsResults = await statsPipeline.exec()
|
||||
|
||||
// 调试日志:检查绑定计数
|
||||
if (boundCount > 0) {
|
||||
logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`)
|
||||
}
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
// 获取分组信息
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
boundApiKeysCount: boundCount,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error)
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos: [],
|
||||
boundApiKeysCount: 0,
|
||||
usage: {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
)
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: errTotal ? {} : parseUsage(total),
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理额度信息、使用统计和绑定的 API Key 数量
|
||||
const accountsWithStats = accounts.map((account) => {
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||
monthly: { requests: 0, tokens: 0, allTokens: 0 }
|
||||
}
|
||||
|
||||
const groupInfos = allGroupInfosMap.get(account.id) || []
|
||||
const boundCount = bindingCountMap.get(account.id) || 0
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
|
||||
const formattedAccount = formatAccountExpiry(account)
|
||||
return {
|
||||
...formattedAccount,
|
||||
groupInfos,
|
||||
boundApiKeysCount: boundCount,
|
||||
usage: {
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
@@ -413,7 +418,7 @@ router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, as
|
||||
|
||||
const result = await openaiResponsesAccountService.resetAccountStatus(id)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`)
|
||||
logger.success(`Admin reset status for OpenAI-Responses account: ${id}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI-Responses account status:', error)
|
||||
@@ -432,7 +437,7 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||
logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -447,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 OpenAI-Responses 账户连通性
|
||||
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gpt-4o-mini' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||
const payload = createOpenAITestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
242
src/routes/admin/quotaCards.js
Normal file
242
src/routes/admin/quotaCards.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 额度卡/时间卡管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const quotaCardService = require('../../services/quotaCardService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取额度卡上限配置
|
||||
router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await quotaCardService.getLimitsConfig()
|
||||
res.json({ success: true, data: config })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card limits:', error)
|
||||
res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新额度卡上限配置
|
||||
router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body
|
||||
const config = await quotaCardService.saveLimitsConfig({
|
||||
enabled,
|
||||
maxExpiryDays,
|
||||
maxTotalCostLimit
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to save quota card limits:', error)
|
||||
res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取额度卡列表
|
||||
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getAllCards({
|
||||
status,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota cards:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取额度卡统计
|
||||
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await quotaCardService.getCardStats()
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card stats:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个额度卡详情
|
||||
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const card = await quotaCardService.getCardById(req.params.id)
|
||||
if (!card) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Card not found'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: card
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建额度卡
|
||||
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
|
||||
|
||||
if (!type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'type is required'
|
||||
})
|
||||
}
|
||||
|
||||
const createdBy = req.session?.username || 'admin'
|
||||
const options = {
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
expiresAt,
|
||||
note,
|
||||
createdBy
|
||||
}
|
||||
|
||||
let result
|
||||
if (count > 1) {
|
||||
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
|
||||
} else {
|
||||
result = await quotaCardService.createCard(options)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除未使用的额度卡
|
||||
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await quotaCardService.deleteCard(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 核销记录管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取核销记录列表
|
||||
router.get('/redemptions', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId,
|
||||
apiKeyId,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 撤销核销
|
||||
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { reason } = req.body
|
||||
const revokedBy = req.session?.username || 'admin'
|
||||
|
||||
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 延长有效期
|
||||
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { amount, unit = 'days' } = req.body
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'amount must be a positive number'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend expiry:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
72
src/routes/admin/serviceRates.js
Normal file
72
src/routes/admin/serviceRates.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 服务倍率配置管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const serviceRatesService = require('../../services/serviceRatesService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// 获取服务倍率配置
|
||||
router.get('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新服务倍率配置
|
||||
router.put('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { rates, baseService } = req.body
|
||||
|
||||
if (!rates || typeof rates !== 'object') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'rates is required and must be an object'
|
||||
})
|
||||
}
|
||||
|
||||
const updatedBy = req.session?.username || 'admin'
|
||||
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可用服务列表
|
||||
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const services = await serviceRatesService.getAvailableServices()
|
||||
res.json({
|
||||
success: true,
|
||||
data: services
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get available services:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -288,10 +288,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const openaiIds = await redis.getAllIdsByIndex(
|
||||
'openai:account:index',
|
||||
'openai:account:*',
|
||||
/^openai:account:(.+)$/
|
||||
)
|
||||
for (const id of openaiIds) {
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
@@ -390,10 +392,12 @@ router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const openaiResponseIds = await redis.getAllIdsByIndex(
|
||||
'openai_responses_account:index',
|
||||
'openai_responses_account:*',
|
||||
/^openai_responses_account:(.+)$/
|
||||
)
|
||||
for (const id of openaiResponseIds) {
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
|
||||
@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
apiStatsNotice: {
|
||||
enabled: false,
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => {
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
apiStatsNotice: {
|
||||
enabled: apiStatsNotice?.enabled === true,
|
||||
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||||
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,21 @@ const {
|
||||
} = require('../services/anthropicGeminiBridgeService')
|
||||
const router = express.Router()
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
function queueRateLimitUpdate(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return Promise.resolve({ totalTokens: 0, totalCost: 0 })
|
||||
}
|
||||
|
||||
const label = context ? ` (${context})` : ''
|
||||
|
||||
return updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
||||
return updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
|
||||
.then(({ totalTokens, totalCost }) => {
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -416,11 +423,18 @@ async function handleMessagesRequest(req, res) {
|
||||
// 根据账号类型选择对应的转发服务并调用
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
|
||||
const _apiKeyId = req.apiKey.id
|
||||
const _rateLimitInfo = req.rateLimitInfo
|
||||
const _requestBody = req.body // 传递后清除引用
|
||||
const _apiKey = req.apiKey
|
||||
const _headers = req.headers
|
||||
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBody,
|
||||
_apiKey,
|
||||
res,
|
||||
req.headers,
|
||||
_headers,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -470,13 +484,13 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
||||
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfo,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -484,7 +498,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-stream'
|
||||
'claude-stream',
|
||||
_apiKeyId,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -501,11 +517,18 @@ async function handleMessagesRequest(req, res) {
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdConsole = req.apiKey.id
|
||||
const _rateLimitInfoConsole = req.rateLimitInfo
|
||||
const _requestBodyConsole = req.body
|
||||
const _apiKeyConsole = req.apiKey
|
||||
const _headersConsole = req.headers
|
||||
|
||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBodyConsole,
|
||||
_apiKeyConsole,
|
||||
res,
|
||||
req.headers,
|
||||
_headersConsole,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -556,7 +579,7 @@ async function handleMessagesRequest(req, res) {
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
req.apiKey.id,
|
||||
_apiKeyIdConsole,
|
||||
usageObject,
|
||||
model,
|
||||
usageAccountId,
|
||||
@@ -567,7 +590,7 @@ async function handleMessagesRequest(req, res) {
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoConsole,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -575,7 +598,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-console-stream'
|
||||
'claude-console-stream',
|
||||
_apiKeyIdConsole,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -593,6 +618,11 @@ async function handleMessagesRequest(req, res) {
|
||||
)
|
||||
} else if (accountType === 'bedrock') {
|
||||
// Bedrock账号使用Bedrock转发服务
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdBedrock = req.apiKey.id
|
||||
const _rateLimitInfoBedrock = req.rateLimitInfo
|
||||
const _requestBodyBedrock = req.body
|
||||
|
||||
try {
|
||||
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
||||
if (!bedrockAccountResult.success) {
|
||||
@@ -600,7 +630,7 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
const result = await bedrockRelayService.handleStreamRequest(
|
||||
req.body,
|
||||
_requestBodyBedrock,
|
||||
bedrockAccountResult.data,
|
||||
res
|
||||
)
|
||||
@@ -611,13 +641,22 @@ async function handleMessagesRequest(req, res) {
|
||||
const outputTokens = result.usage.output_tokens || 0
|
||||
|
||||
apiKeyService
|
||||
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
|
||||
.recordUsage(
|
||||
_apiKeyIdBedrock,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
0,
|
||||
0,
|
||||
result.model,
|
||||
accountId,
|
||||
'bedrock'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoBedrock,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -625,7 +664,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens: 0
|
||||
},
|
||||
result.model,
|
||||
'bedrock-stream'
|
||||
'bedrock-stream',
|
||||
_apiKeyIdBedrock,
|
||||
'bedrock'
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -642,11 +683,18 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||
// 🧹 内存优化:提取需要的值
|
||||
const _apiKeyIdCcr = req.apiKey.id
|
||||
const _rateLimitInfoCcr = req.rateLimitInfo
|
||||
const _requestBodyCcr = req.body
|
||||
const _apiKeyCcr = req.apiKey
|
||||
const _headersCcr = req.headers
|
||||
|
||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
_requestBodyCcr,
|
||||
_apiKeyCcr,
|
||||
res,
|
||||
req.headers,
|
||||
_headersCcr,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
@@ -696,13 +744,13 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
||||
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoCcr,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -710,7 +758,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'ccr-stream'
|
||||
'ccr-stream',
|
||||
_apiKeyIdCcr,
|
||||
'ccr'
|
||||
)
|
||||
|
||||
usageDataCaptured = true
|
||||
@@ -737,18 +787,26 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
}, 1000) // 1秒后检查
|
||||
} else {
|
||||
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
|
||||
const _apiKeyIdNonStream = req.apiKey.id
|
||||
const _apiKeyNameNonStream = req.apiKey.name
|
||||
const _rateLimitInfoNonStream = req.rateLimitInfo
|
||||
const _requestBodyNonStream = req.body
|
||||
const _apiKeyNonStream = req.apiKey
|
||||
const _headersNonStream = req.headers
|
||||
|
||||
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||
logger.warn(
|
||||
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
|
||||
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 非流式响应 - 只使用官方真实usage数据
|
||||
logger.info('📄 Starting non-streaming request', {
|
||||
apiKeyId: req.apiKey.id,
|
||||
apiKeyName: req.apiKey.name
|
||||
apiKeyId: _apiKeyIdNonStream,
|
||||
apiKeyName: _apiKeyNameNonStream
|
||||
})
|
||||
|
||||
// 📊 监听 socket 事件以追踪连接状态变化
|
||||
@@ -919,11 +977,11 @@ async function handleMessagesRequest(req, res) {
|
||||
? await claudeAccountService.getAccount(accountId)
|
||||
: await claudeConsoleAccountService.getAccount(accountId)
|
||||
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
|
||||
logger.api(
|
||||
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||
)
|
||||
return res.json(buildMockWarmupResponse(req.body.model))
|
||||
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,11 +994,11 @@ async function handleMessagesRequest(req, res) {
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务
|
||||
response = await claudeRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 用于断开检测,保留但服务层已优化
|
||||
res,
|
||||
req.headers
|
||||
_headersNonStream
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
// Claude Console账号使用Console转发服务
|
||||
@@ -948,11 +1006,11 @@ async function handleMessagesRequest(req, res) {
|
||||
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
||||
)
|
||||
response = await claudeConsoleRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 保留用于断开检测
|
||||
res,
|
||||
req.headers,
|
||||
_headersNonStream,
|
||||
accountId
|
||||
)
|
||||
} else if (accountType === 'bedrock') {
|
||||
@@ -964,9 +1022,9 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
const result = await bedrockRelayService.handleNonStreamRequest(
|
||||
req.body,
|
||||
_requestBodyNonStream,
|
||||
bedrockAccountResult.data,
|
||||
req.headers
|
||||
_headersNonStream
|
||||
)
|
||||
|
||||
// 构建标准响应格式
|
||||
@@ -996,11 +1054,11 @@ async function handleMessagesRequest(req, res) {
|
||||
// CCR账号使用CCR转发服务
|
||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||
response = await ccrRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
_requestBodyNonStream,
|
||||
_apiKeyNonStream,
|
||||
req, // clientRequest 保留用于断开检测
|
||||
res,
|
||||
req.headers,
|
||||
_headersNonStream,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
@@ -1049,24 +1107,25 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
|
||||
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = usageBaseModel || rawModel
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: responseAccountId } = response
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
_apiKeyIdNonStream,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
responseAccountId
|
||||
responseAccountId,
|
||||
accountType
|
||||
)
|
||||
|
||||
await queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
_rateLimitInfoNonStream,
|
||||
{
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -1074,7 +1133,9 @@ async function handleMessagesRequest(req, res) {
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
'claude-non-stream'
|
||||
'claude-non-stream',
|
||||
_apiKeyIdNonStream,
|
||||
accountType
|
||||
)
|
||||
|
||||
usageRecorded = true
|
||||
|
||||
@@ -5,10 +5,39 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const modelsConfig = require('../../config/models')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 📋 获取可用模型列表(公开接口)
|
||||
router.get('/models', (req, res) => {
|
||||
const { service } = req.query
|
||||
|
||||
if (service) {
|
||||
// 返回指定服务的模型
|
||||
const models = modelsConfig.getModelsByService(service)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: models
|
||||
})
|
||||
}
|
||||
|
||||
// 返回所有模型(按服务分组)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
claude: modelsConfig.CLAUDE_MODELS,
|
||||
gemini: modelsConfig.GEMINI_MODELS,
|
||||
openai: modelsConfig.OPENAI_MODELS,
|
||||
other: modelsConfig.OTHER_MODELS,
|
||||
all: modelsConfig.getAllModels()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 🏠 重定向页面请求到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(301, '/admin-next/api-stats')
|
||||
@@ -39,7 +68,7 @@ router.post('/api/get-key-id', async (req, res) => {
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||||
logger.security(`Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
@@ -87,7 +116,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
@@ -155,7 +184,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
@@ -166,7 +195,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
} else if (apiKey) {
|
||||
// 通过 apiKey 查询(保持向后兼容)
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
@@ -191,7 +220,7 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
keyData = validatedKeyData
|
||||
keyId = keyData.id
|
||||
} else {
|
||||
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||||
return res.status(400).json({
|
||||
error: 'API Key or ID is required',
|
||||
message: 'Please provide your API Key or API ID'
|
||||
@@ -224,17 +253,16 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
|
||||
} else {
|
||||
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
|
||||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||||
const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`)
|
||||
const modelUsageMap = new Map()
|
||||
|
||||
for (const key of allModelKeys) {
|
||||
for (const { key, data } of allModelResults) {
|
||||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (!modelMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = modelMatch[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
@@ -475,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
restrictedModels: fullKeyData.restrictedModels || [],
|
||||
enableClientRestriction: fullKeyData.enableClientRestriction || false,
|
||||
allowedClients: fullKeyData.allowedClients || []
|
||||
}
|
||||
},
|
||||
|
||||
// Key 级别的服务倍率
|
||||
serviceRates: (() => {
|
||||
try {
|
||||
return fullKeyData.serviceRates
|
||||
? typeof fullKeyData.serviceRates === 'string'
|
||||
? JSON.parse(fullKeyData.serviceRates)
|
||||
: fullKeyData.serviceRates
|
||||
: {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return res.json({
|
||||
@@ -598,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => {
|
||||
...usage.monthly,
|
||||
cost: costStats.monthly
|
||||
},
|
||||
totalCost: costStats.total
|
||||
totalCost: costStats.total,
|
||||
serviceRates: (() => {
|
||||
try {
|
||||
return keyData.serviceRates
|
||||
? typeof keyData.serviceRates === 'string'
|
||||
? JSON.parse(keyData.serviceRates)
|
||||
: keyData.serviceRates
|
||||
: {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -702,7 +754,7 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const _client = redis.getClientSafe()
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
@@ -717,9 +769,9 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
? `usage:${apiId}:model:daily:*:${today}`
|
||||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
for (const { key, data } of results) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
@@ -731,7 +783,6 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!modelUsageMap.has(model)) {
|
||||
@@ -741,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
allTokens: 0,
|
||||
realCostMicro: 0,
|
||||
ratedCostMicro: 0,
|
||||
hasStoredCost: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -752,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||
modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0
|
||||
modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0
|
||||
// 检查 Redis 数据是否包含成本字段
|
||||
if ('realCostMicro' in data || 'ratedCostMicro' in data) {
|
||||
modelUsage.hasStoredCost = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 转换为数组并计算费用
|
||||
// 转换为数组并处理费用
|
||||
const modelStats = []
|
||||
for (const [model, usage] of modelUsageMap) {
|
||||
const usageData = {
|
||||
@@ -767,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
cache_read_input_tokens: usage.cacheReadTokens
|
||||
}
|
||||
|
||||
// 优先使用存储的费用,否则回退到重新计算
|
||||
const { hasStoredCost } = usage
|
||||
const costData = CostCalculator.calculateCost(usageData, model)
|
||||
|
||||
// 如果有存储的费用,覆盖计算的费用
|
||||
if (hasStoredCost) {
|
||||
costData.costs.real = (usage.realCostMicro || 0) / 1000000
|
||||
costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000
|
||||
costData.costs.total = costData.costs.real // 保持兼容
|
||||
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||
}
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: usage.requests,
|
||||
@@ -779,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
allTokens: usage.allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
pricing: costData.pricing,
|
||||
isLegacy: !hasStoredCost
|
||||
})
|
||||
}
|
||||
|
||||
@@ -802,13 +873,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// maxTokens 白名单
|
||||
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
|
||||
const sanitizeMaxTokens = (value) =>
|
||||
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
|
||||
|
||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||
router.post('/api-key/test', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
@@ -841,7 +918,7 @@ router.post('/api-key/test', async (req, res) => {
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
@@ -851,13 +928,317 @@ router.post('/api-key/test', async (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message || 'Internal server error'
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 Gemini API Key 端点测试接口
|
||||
router.post('/api-key/test-gemini', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 Gemini 权限
|
||||
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have Gemini permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// Gemini 格式: candidates[0].content.parts[0].text
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
if (text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Gemini API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 OpenAI/Codex API Key 端点测试接口
|
||||
router.post('/api-key/test-openai', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 OpenAI 权限
|
||||
if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have OpenAI permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'User-Agent': 'codex_cli_rs/1.0.0'
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// OpenAI Responses 格式: output[].content[].text 或 delta
|
||||
if (data.type === 'response.output_text.delta' && data.delta) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
|
||||
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: getSafeMessage(error)
|
||||
})
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
@@ -886,7 +1267,7 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
keyData = await redis.getApiKey(apiId)
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
@@ -942,33 +1323,37 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
)
|
||||
|
||||
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
||||
const client = redis.getClientSafe()
|
||||
const _client = redis.getClientSafe()
|
||||
// 使用与管理页面相同的时区处理逻辑
|
||||
const tzDate = redis.getDateInTimezone()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:${keyId}:model:daily:*:${today}`
|
||||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
let pattern
|
||||
let matchRegex
|
||||
if (period === 'daily') {
|
||||
pattern = `usage:${keyId}:model:daily:*:${today}`
|
||||
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
} else if (period === 'alltime') {
|
||||
pattern = `usage:${keyId}:model:alltime:*`
|
||||
matchRegex = /usage:.+:model:alltime:(.+)$/
|
||||
} else {
|
||||
// monthly
|
||||
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
}
|
||||
|
||||
const keys = await client.keys(pattern)
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
const modelStats = []
|
||||
|
||||
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}$/
|
||||
)
|
||||
for (const { key, data } of results) {
|
||||
const match = key.match(matchRegex)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = match[1]
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const usage = {
|
||||
@@ -978,8 +1363,30 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
||||
}
|
||||
|
||||
// 优先使用存储的费用,否则回退到重新计算
|
||||
// 检查字段是否存在(而非 > 0),以支持真正的零成本场景
|
||||
const realCostMicro = parseInt(data.realCostMicro) || 0
|
||||
const ratedCostMicro = parseInt(data.ratedCostMicro) || 0
|
||||
const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data
|
||||
const costData = CostCalculator.calculateCost(usage, model)
|
||||
|
||||
// 如果有存储的费用,覆盖计算的费用
|
||||
if (hasStoredCost) {
|
||||
costData.costs.real = realCostMicro / 1000000
|
||||
costData.costs.rated = ratedCostMicro / 1000000
|
||||
costData.costs.total = costData.costs.real
|
||||
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||
}
|
||||
|
||||
// alltime 键不存储 allTokens,需要计算
|
||||
const allTokens =
|
||||
period === 'alltime'
|
||||
? usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
: parseInt(data.allTokens) || 0
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: parseInt(data.requests) || 0,
|
||||
@@ -987,10 +1394,11 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
allTokens: parseInt(data.allTokens) || 0,
|
||||
allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
pricing: costData.pricing,
|
||||
isLegacy: !hasStoredCost
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1018,4 +1426,170 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取服务倍率配置(公开接口)
|
||||
router.get('/service-rates', async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve service rates'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份)
|
||||
router.post('/api/redeem-card', async (req, res) => {
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
try {
|
||||
const { apiId, code } = req.body
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
const hour = new Date().toISOString().slice(0, 13)
|
||||
|
||||
// 防暴力破解:检查失败锁定
|
||||
const failKey = `redeem_card:fail:${clientIP}`
|
||||
const failCount = parseInt((await redis.client.get(failKey)) || '0')
|
||||
if (failCount >= 5) {
|
||||
logger.security(`🔒 Card redemption locked for IP: ${clientIP}`)
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '失败次数过多,请1小时后再试'
|
||||
})
|
||||
}
|
||||
|
||||
// 防暴力破解:检查 IP 速率限制
|
||||
const ipKey = `redeem_card:ip:${clientIP}:${hour}`
|
||||
const ipCount = await redis.client.incr(ipKey)
|
||||
await redis.client.expire(ipKey, 3600)
|
||||
if (ipCount > 10) {
|
||||
logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`)
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
error: '请求过于频繁,请稍后再试'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiId || !code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '请输入卡号'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 apiId 格式
|
||||
if (
|
||||
typeof apiId !== 'string' ||
|
||||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'API ID 格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 存在且有效
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API Key 不存在'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.isActive !== 'true') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'API Key 已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 调用兑换服务
|
||||
const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats')
|
||||
|
||||
// 成功时清除失败计数(静默处理,不影响成功响应)
|
||||
redis.client.del(failKey).catch(() => {})
|
||||
|
||||
logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
// 失败时增加失败计数(静默处理,不影响错误响应)
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
const failKey = `redeem_card:fail:${clientIP}`
|
||||
redis.client
|
||||
.incr(failKey)
|
||||
.then(() => redis.client.expire(failKey, 3600))
|
||||
.catch(() => {})
|
||||
|
||||
logger.error('❌ Failed to redeem card:', error)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📋 公开的兑换记录查询接口(通过 apiId 验证身份)
|
||||
router.get('/api/redemption-history', async (req, res) => {
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
try {
|
||||
const { apiId, limit = 50, offset = 0 } = req.query
|
||||
|
||||
if (!apiId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少 API ID'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 apiId 格式
|
||||
if (
|
||||
typeof apiId !== 'string' ||
|
||||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'API ID 格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 存在
|
||||
const keyData = await redis.getApiKey(apiId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API Key 不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取该 API Key 的兑换记录
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
apiKeyId: apiId,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemption history:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -86,7 +86,8 @@ class AtomicUsageReporter {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
accountId,
|
||||
'azure-openai'
|
||||
)
|
||||
|
||||
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
handleStreamGenerateContent,
|
||||
handleLoadCodeAssist,
|
||||
handleOnboardUser,
|
||||
handleRetrieveUserQuota,
|
||||
handleCountTokens,
|
||||
handleStandardGenerateContent,
|
||||
handleStandardStreamGenerateContent,
|
||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||
|
||||
// ============================================================================
|
||||
// v1internal 独有路由(listExperiments)
|
||||
// v1internal 独有路由
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,12 @@ router.post(
|
||||
handleSimpleEndpoint('listExperiments')
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /v1internal:retrieveUserQuota
|
||||
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||
*/
|
||||
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/:modelName:listExperiments
|
||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||
|
||||
@@ -8,10 +8,12 @@ const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const pricingService = require('../services/pricingService')
|
||||
@@ -19,18 +21,24 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
return permissions === 'all' || permissions === requiredPermission
|
||||
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||
}
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
function queueRateLimitUpdate(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = context ? ` (${context})` : ''
|
||||
|
||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model)
|
||||
updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType)
|
||||
.then(({ totalTokens, totalCost }) => {
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -235,7 +243,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { accountId } = accountSelection
|
||||
const { accountId, accountType } = accountSelection
|
||||
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
@@ -265,72 +273,107 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
}
|
||||
})
|
||||
|
||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
(usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||
// 创建 usage 回调函数
|
||||
const usageCallback = (usage) => {
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const model = usage.model || claudeRequest.model
|
||||
const cacheCreateTokens =
|
||||
(usage.cache_creation && typeof usage.cache_creation === 'object'
|
||||
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
|
||||
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
|
||||
: usage.cache_creation_input_tokens || 0) || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
accountId
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
model,
|
||||
'openai-claude-stream'
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
},
|
||||
// 流转换器
|
||||
(() => {
|
||||
// 为每个请求创建独立的会话ID
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
})(),
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
})
|
||||
|
||||
queueRateLimitUpdate(
|
||||
req.rateLimitInfo,
|
||||
{
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens
|
||||
},
|
||||
model,
|
||||
`openai-${accountType}-stream`,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 创建流转换器
|
||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
const streamTransformer = (chunk) =>
|
||||
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||
|
||||
// 根据账户类型选择转发服务
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
accountId,
|
||||
streamTransformer
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
{
|
||||
betaHeader:
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 非流式请求
|
||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||||
|
||||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||||
const claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
// 根据账户类型选择转发服务
|
||||
let claudeResponse
|
||||
if (accountType === 'claude-console') {
|
||||
// Claude Console 账户使用 Console 转发服务
|
||||
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
accountId
|
||||
)
|
||||
} else {
|
||||
// Claude Official 账户使用标准转发服务
|
||||
claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
claudeCodeHeaders,
|
||||
{ betaHeader: 'oauth-2025-04-20' }
|
||||
)
|
||||
}
|
||||
|
||||
// 解析 Claude 响应
|
||||
let claudeData
|
||||
@@ -376,7 +419,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
apiKeyData.id,
|
||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
@@ -391,7 +435,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
cacheReadTokens
|
||||
},
|
||||
claudeRequest.model,
|
||||
'openai-claude-non-stream'
|
||||
`openai-${accountType}-non-stream`,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
}
|
||||
|
||||
@@ -418,7 +464,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
const status = error.status || 500
|
||||
res.status(status).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
message: getSafeMessage(error),
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
|
||||
@@ -539,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
@@ -640,7 +641,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
account.id,
|
||||
'gemini'
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`
|
||||
|
||||
@@ -9,9 +9,12 @@ const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const { IncrementalSSEParser } = require('../utils/sseParser')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
|
||||
// 创建代理 Agent(使用统一的代理工具)
|
||||
function createProxyAgent(proxy) {
|
||||
@@ -67,7 +70,7 @@ function extractCodexUsageHeaders(headers) {
|
||||
return hasData ? snapshot : null
|
||||
}
|
||||
|
||||
async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) {
|
||||
if (!req.rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -78,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
req.rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
req.apiKey?.id,
|
||||
accountType
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -274,7 +279,9 @@ const handleResponses = async (req, res) => {
|
||||
'text_formatting',
|
||||
'truncation',
|
||||
'text',
|
||||
'service_tier'
|
||||
'service_tier',
|
||||
'prompt_cache_retention',
|
||||
'safety_identifier'
|
||||
]
|
||||
fieldsToRemove.forEach((field) => {
|
||||
delete req.body[field]
|
||||
@@ -575,7 +582,6 @@ const handleResponses = async (req, res) => {
|
||||
}
|
||||
|
||||
// 处理响应并捕获 usage 数据和真实的 model
|
||||
let buffer = ''
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let usageReported = false
|
||||
@@ -611,7 +617,8 @@ const handleResponses = async (req, res) => {
|
||||
0, // OpenAI没有cache_creation_tokens
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
accountId
|
||||
accountId,
|
||||
'openai'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -627,7 +634,8 @@ const handleResponses = async (req, res) => {
|
||||
cacheReadTokens
|
||||
},
|
||||
actualModel,
|
||||
'openai-non-stream'
|
||||
'openai-non-stream',
|
||||
'openai'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,74 +651,50 @@ const handleResponses = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据和 model
|
||||
const parseSSEForUsage = (data) => {
|
||||
const lines = data.split('\n')
|
||||
// 使用增量 SSE 解析器
|
||||
const sseParser = new IncrementalSSEParser()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: response.completed')) {
|
||||
// 下一行应该是数据
|
||||
continue
|
||||
// 处理解析出的事件
|
||||
const processSSEEvent = (eventData) => {
|
||||
// 检查是否是 response.completed 事件
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
// 从响应中获取真实的 model
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
logger.debug(`📊 Captured actual model: ${actualModel}`)
|
||||
}
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
// 获取 usage 数据
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是 response.completed 事件
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
// 从响应中获取真实的 model
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
logger.debug(`📊 Captured actual model: ${actualModel}`)
|
||||
}
|
||||
|
||||
// 获取 usage 数据
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||
rateLimitDetected = true
|
||||
if (eventData.error.resets_in_seconds) {
|
||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
// 检查是否有限流错误
|
||||
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||
rateLimitDetected = true
|
||||
if (eventData.error.resets_in_seconds) {
|
||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstream.data.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 转发数据给客户端
|
||||
if (!res.destroyed) {
|
||||
res.write(chunk)
|
||||
}
|
||||
|
||||
// 同时解析数据以捕获 usage 信息
|
||||
buffer += chunkStr
|
||||
|
||||
// 处理完整的 SSE 事件
|
||||
if (buffer.includes('\n\n')) {
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
|
||||
|
||||
for (const event of events) {
|
||||
if (event.trim()) {
|
||||
parseSSEForUsage(event)
|
||||
}
|
||||
// 使用增量解析器处理数据
|
||||
const events = sseParser.feed(chunk.toString())
|
||||
for (const event of events) {
|
||||
if (event.type === 'data' && event.data) {
|
||||
processSSEEvent(event.data)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -720,8 +704,14 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
upstream.data.on('end', async () => {
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim()) {
|
||||
parseSSEForUsage(buffer)
|
||||
const remaining = sseParser.getRemaining()
|
||||
if (remaining.trim()) {
|
||||
const events = sseParser.feed('\n\n') // 强制刷新剩余内容
|
||||
for (const event of events) {
|
||||
if (event.type === 'data' && event.data) {
|
||||
processSSEEvent(event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录使用统计
|
||||
@@ -743,7 +733,8 @@ const handleResponses = async (req, res) => {
|
||||
0, // OpenAI没有cache_creation_tokens
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
accountId,
|
||||
'openai'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -760,7 +751,8 @@ const handleResponses = async (req, res) => {
|
||||
cacheReadTokens
|
||||
},
|
||||
modelToRecord,
|
||||
'openai-stream'
|
||||
'openai-stream',
|
||||
'openai'
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record OpenAI usage:', error)
|
||||
@@ -850,13 +842,15 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
let responsePayload = error.response?.data
|
||||
if (!responsePayload) {
|
||||
responsePayload = { error: { message: error.message || 'Internal server error' } }
|
||||
responsePayload = { error: { message: getSafeMessage(error) } }
|
||||
} else if (typeof responsePayload === 'string') {
|
||||
responsePayload = { error: { message: responsePayload } }
|
||||
responsePayload = { error: { message: getSafeMessage(responsePayload) } }
|
||||
} else if (typeof responsePayload === 'object' && !responsePayload.error) {
|
||||
responsePayload = {
|
||||
error: { message: responsePayload.message || error.message || 'Internal server error' }
|
||||
error: { message: getSafeMessage(responsePayload.message || error) }
|
||||
}
|
||||
} else if (responsePayload.error?.message) {
|
||||
responsePayload.error.message = getSafeMessage(responsePayload.error.message)
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
@@ -874,16 +868,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -900,25 +896,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: keyData.permissions,
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
usage: {
|
||||
total: keyData.usage.total,
|
||||
daily: keyData.usage.daily,
|
||||
monthly: keyData.usage.monthly
|
||||
total: usage?.total || {},
|
||||
daily: usage?.daily || {},
|
||||
monthly: usage?.monthly || {}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡核销相关路由
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
// 🎫 核销额度卡
|
||||
router.post('/redeem-card', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { code, apiKeyId } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing card code',
|
||||
message: 'Card code is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only redeem cards to your own API keys'
|
||||
})
|
||||
}
|
||||
|
||||
// 执行核销
|
||||
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
|
||||
|
||||
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Redeem card error:', error)
|
||||
res.status(400).json({
|
||||
error: 'Redeem failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📋 获取用户的核销历史
|
||||
router.get('/redemption-history', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0 } = req.query
|
||||
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId: req.user.id,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get redemption history error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get redemption history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户的额度信息
|
||||
router.get('/quota-info', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.query
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only view your own API key quota'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否为聚合 Key
|
||||
if (keyData.isAggregated !== 'true') {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: false,
|
||||
message: 'This is a traditional API key, not using quota system'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 解析聚合 Key 数据
|
||||
let permissions = []
|
||||
let serviceQuotaLimits = {}
|
||||
let serviceQuotaUsed = {}
|
||||
|
||||
try {
|
||||
permissions = JSON.parse(keyData.permissions || '[]')
|
||||
} catch (e) {
|
||||
permissions = [keyData.permissions]
|
||||
}
|
||||
|
||||
try {
|
||||
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
|
||||
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||
} catch (e) {
|
||||
// 解析失败使用默认值
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: true,
|
||||
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
|
||||
permissions,
|
||||
serviceQuotaLimits,
|
||||
serviceQuotaUsed,
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get quota info error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get quota info',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -74,7 +74,7 @@ router.post('/auth/login', async (req, res) => {
|
||||
const isValidPassword = await bcrypt.compare(password, adminData.passwordHash)
|
||||
|
||||
if (!isValidUsername || !isValidPassword) {
|
||||
logger.security(`🔒 Failed login attempt for username: ${username}`)
|
||||
logger.security(`Failed login attempt for username: ${username}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid credentials',
|
||||
message: 'Invalid username or password'
|
||||
@@ -96,7 +96,7 @@ router.post('/auth/login', async (req, res) => {
|
||||
// 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存
|
||||
// init.json 是唯一真实数据源
|
||||
|
||||
logger.success(`🔐 Admin login successful: ${username}`)
|
||||
logger.success(`Admin login successful: ${username}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -197,7 +197,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 验证当前密码
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash)
|
||||
if (!isValidPassword) {
|
||||
logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`)
|
||||
logger.security(`Invalid current password attempt for user: ${sessionData.username}`)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid current password',
|
||||
message: 'Current password is incorrect'
|
||||
@@ -253,7 +253,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 清除当前会话(强制用户重新登录)
|
||||
await redis.deleteSession(token)
|
||||
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`)
|
||||
logger.success(`Admin password changed successfully for user: ${updatedUsername}`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -294,7 +294,7 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
@@ -352,7 +352,7 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
logger.security(`Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
|
||||
@@ -226,7 +226,15 @@ class AccountBalanceService {
|
||||
return null
|
||||
}
|
||||
|
||||
return await service.getAccount(accountId)
|
||||
const result = await service.getAccount(accountId)
|
||||
|
||||
// 处理不同服务返回格式的差异
|
||||
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getAllAccountsByPlatform(platform) {
|
||||
@@ -275,10 +283,27 @@ class AccountBalanceService {
|
||||
|
||||
const accountId = account?.id
|
||||
if (!accountId) {
|
||||
throw new Error('账户缺少 id')
|
||||
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||
return this._buildResponse(
|
||||
{
|
||||
status: 'error',
|
||||
errorMessage: '账户数据异常',
|
||||
balance: null,
|
||||
currency: 'USD',
|
||||
quota: null,
|
||||
statistics: {},
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
},
|
||||
'unknown',
|
||||
platform,
|
||||
'local',
|
||||
null,
|
||||
{ scriptEnabled: false, scriptConfigured: false }
|
||||
)
|
||||
}
|
||||
|
||||
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
|
||||
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||
let scriptConfig = null
|
||||
let scriptConfigured = false
|
||||
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||
|
||||
@@ -7,6 +7,62 @@ class AccountGroupService {
|
||||
this.GROUPS_KEY = 'account_groups'
|
||||
this.GROUP_PREFIX = 'account_group:'
|
||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
|
||||
this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:'
|
||||
this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated'
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保反向索引存在(启动时自动调用)
|
||||
* 检查是否已迁移,如果没有则自动回填
|
||||
*/
|
||||
async ensureReverseIndexes() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已迁移
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated === 'true') {
|
||||
logger.debug('📁 账户分组反向索引已存在,跳过回填')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('📁 开始回填账户分组反向索引...')
|
||||
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
if (allGroupIds.length === 0) {
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
return
|
||||
}
|
||||
|
||||
let totalOperations = 0
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!group || !group.platform) {
|
||||
continue
|
||||
}
|
||||
|
||||
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
if (members.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of members) {
|
||||
pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId)
|
||||
}
|
||||
await pipeline.exec()
|
||||
totalOperations += members.length
|
||||
}
|
||||
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations} 条`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 账户分组反向索引回填失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +106,7 @@ class AccountGroupService {
|
||||
// 添加到分组集合
|
||||
await client.sadd(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`)
|
||||
logger.success(`创建账户分组成功: ${name} (${platform})`)
|
||||
|
||||
return group
|
||||
} catch (error) {
|
||||
@@ -101,7 +157,7 @@ class AccountGroupService {
|
||||
// 返回更新后的完整数据
|
||||
const updatedGroup = await client.hgetall(groupKey)
|
||||
|
||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`)
|
||||
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
|
||||
|
||||
return updatedGroup
|
||||
} catch (error) {
|
||||
@@ -143,7 +199,7 @@ class AccountGroupService {
|
||||
// 从分组集合中移除
|
||||
await client.srem(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 删除账户分组成功: ${group.name}`)
|
||||
logger.success(`删除账户分组成功: ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 删除账户分组失败:', error)
|
||||
throw error
|
||||
@@ -234,7 +290,10 @@ class AccountGroupService {
|
||||
// 添加到分组成员集合
|
||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
// 维护反向索引
|
||||
await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId)
|
||||
|
||||
logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 添加账户到分组失败:', error)
|
||||
throw error
|
||||
@@ -245,15 +304,26 @@ class AccountGroupService {
|
||||
* 从分组移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {string} platform - 平台(可选,如果不传则从分组获取)
|
||||
*/
|
||||
async removeAccountFromGroup(accountId, groupId) {
|
||||
async removeAccountFromGroup(accountId, groupId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从分组成员集合中移除
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`)
|
||||
// 维护反向索引
|
||||
let groupPlatform = platform
|
||||
if (!groupPlatform) {
|
||||
const group = await this.getGroup(groupId)
|
||||
groupPlatform = group?.platform
|
||||
}
|
||||
if (groupPlatform) {
|
||||
await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId)
|
||||
}
|
||||
|
||||
logger.success(`从分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从分组移除账户失败:', error)
|
||||
throw error
|
||||
@@ -399,7 +469,7 @@ class AccountGroupService {
|
||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||
}
|
||||
|
||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量设置账户分组失败:', error)
|
||||
throw error
|
||||
@@ -409,8 +479,9 @@ class AccountGroupService {
|
||||
/**
|
||||
* 从所有分组中移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台(可选,用于清理反向索引)
|
||||
*/
|
||||
async removeAccountFromAllGroups(accountId) {
|
||||
async removeAccountFromAllGroups(accountId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
@@ -419,12 +490,155 @@ class AccountGroupService {
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
}
|
||||
|
||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
||||
// 清理反向索引
|
||||
if (platform) {
|
||||
await client.del(`account_groups_reverse:${platform}:${accountId}`)
|
||||
} else {
|
||||
// 如果没有指定平台,清理所有可能的平台
|
||||
const platforms = ['claude', 'gemini', 'openai', 'droid']
|
||||
const pipeline = client.pipeline()
|
||||
for (const p of platforms) {
|
||||
pipeline.del(`account_groups_reverse:${p}:${accountId}`)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.success(`从所有分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个账户的分组信息(性能优化版本,使用反向索引)
|
||||
* @param {Array<string>} accountIds - 账户ID数组
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {Object} options - 选项
|
||||
* @param {boolean} options.skipMemberCount - 是否跳过 memberCount(默认 true)
|
||||
* @returns {Map<string, Array>} accountId -> 分组信息数组的映射
|
||||
*/
|
||||
async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) {
|
||||
const { skipMemberCount = true } = options
|
||||
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// Pipeline 批量获取所有账户的分组ID
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`)
|
||||
}
|
||||
const groupIdResults = await pipeline.exec()
|
||||
|
||||
// 收集所有需要的分组ID
|
||||
const uniqueGroupIds = new Set()
|
||||
const accountGroupIdsMap = new Map()
|
||||
let hasAnyGroups = false
|
||||
accountIds.forEach((accountId, i) => {
|
||||
const [err, groupIds] = groupIdResults[i]
|
||||
const ids = err ? [] : groupIds || []
|
||||
accountGroupIdsMap.set(accountId, ids)
|
||||
ids.forEach((id) => {
|
||||
uniqueGroupIds.add(id)
|
||||
hasAnyGroups = true
|
||||
})
|
||||
})
|
||||
|
||||
// 如果反向索引全空,回退到原方法(兼容未迁移的数据)
|
||||
if (!hasAnyGroups) {
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated !== 'true') {
|
||||
logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups')
|
||||
const result = new Map()
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
result.set(accountId, groups)
|
||||
} catch {
|
||||
result.set(accountId, [])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
|
||||
const emptyIndexAccountIds = []
|
||||
for (const accountId of accountIds) {
|
||||
const ids = accountGroupIdsMap.get(accountId) || []
|
||||
if (ids.length === 0) {
|
||||
emptyIndexAccountIds.push(accountId)
|
||||
}
|
||||
}
|
||||
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
|
||||
// 部分账户索引缺失,逐个查询并补建
|
||||
for (const accountId of emptyIndexAccountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
if (groups.length > 0) {
|
||||
const groupIds = groups.map((g) => g.id)
|
||||
accountGroupIdsMap.set(accountId, groupIds)
|
||||
groupIds.forEach((id) => uniqueGroupIds.add(id))
|
||||
// 异步补建反向索引
|
||||
client
|
||||
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,保持空数组
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取分组详情
|
||||
const groupDetailsMap = new Map()
|
||||
if (uniqueGroupIds.size > 0) {
|
||||
const detailPipeline = client.pipeline()
|
||||
const groupIdArray = Array.from(uniqueGroupIds)
|
||||
for (const groupId of groupIdArray) {
|
||||
detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!skipMemberCount) {
|
||||
detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
}
|
||||
}
|
||||
const detailResults = await detailPipeline.exec()
|
||||
|
||||
const step = skipMemberCount ? 1 : 2
|
||||
for (let i = 0; i < groupIdArray.length; i++) {
|
||||
const groupId = groupIdArray[i]
|
||||
const [err1, groupData] = detailResults[i * step]
|
||||
if (!err1 && groupData && Object.keys(groupData).length > 0) {
|
||||
const group = { ...groupData }
|
||||
if (!skipMemberCount) {
|
||||
const [err2, memberCount] = detailResults[i * step + 1]
|
||||
group.memberCount = err2 ? 0 : memberCount || 0
|
||||
}
|
||||
groupDetailsMap.set(groupId, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建最终结果
|
||||
const result = new Map()
|
||||
for (const [accountId, groupIds] of accountGroupIdsMap) {
|
||||
const groups = groupIds
|
||||
.map((gid) => groupDetailsMap.get(gid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
result.set(accountId, groups)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量获取账户分组失败:', error)
|
||||
return new Map(accountIds.map((id) => [id, []]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService()
|
||||
|
||||
@@ -80,6 +80,15 @@ const ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT =
|
||||
// 工具报错时注入的 system prompt,提示模型不要中断
|
||||
const TOOL_ERROR_CONTINUE_PROMPT =
|
||||
'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.'
|
||||
// Antigravity 账号前置注入的系统提示词
|
||||
const ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX = `<identity>
|
||||
You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.
|
||||
You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
|
||||
The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is.
|
||||
This information may or may not be relevant to the coding task, it is up for you to decide.
|
||||
</identity>
|
||||
<communication_style>
|
||||
- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.</communication_style>`
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数:基础工具
|
||||
@@ -1362,9 +1371,12 @@ function buildGeminiRequestFromAnthropic(
|
||||
generationConfig
|
||||
}
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
geminiRequestBody.systemInstruction =
|
||||
vendor === 'antigravity' ? { role: 'user', parts: systemParts } : { parts: systemParts }
|
||||
// antigravity: 前置注入系统提示词
|
||||
if (vendor === 'antigravity') {
|
||||
const allParts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX }, ...systemParts]
|
||||
geminiRequestBody.systemInstruction = { role: 'user', parts: allParts }
|
||||
} else if (systemParts.length > 0) {
|
||||
geminiRequestBody.systemInstruction = { parts: systemParts }
|
||||
}
|
||||
|
||||
const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor })
|
||||
@@ -1788,7 +1800,13 @@ function dumpToolsPayload({ vendor, model, tools, toolChoice }) {
|
||||
* 更新速率限制计数器
|
||||
* 跟踪 token 使用量和成本
|
||||
*/
|
||||
async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
|
||||
async function applyRateLimitTracking(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
context = '',
|
||||
keyId = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -1799,7 +1817,9 @@ async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, contex
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
keyId,
|
||||
'gemini'
|
||||
)
|
||||
if (totalTokens > 0) {
|
||||
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`)
|
||||
@@ -2122,13 +2142,15 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
|
||||
0,
|
||||
0,
|
||||
effectiveModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
await applyRateLimitTracking(
|
||||
req.rateLimitInfo,
|
||||
{ inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 },
|
||||
effectiveModel,
|
||||
'anthropic-messages'
|
||||
'anthropic-messages',
|
||||
req.apiKey?.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2660,7 +2682,8 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel })
|
||||
0,
|
||||
0,
|
||||
effectiveModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
await applyRateLimitTracking(
|
||||
req.rateLimitInfo,
|
||||
|
||||
@@ -64,7 +64,8 @@ function getAntigravityHeaders(accessToken, baseUrl) {
|
||||
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'gzip'
|
||||
'Accept-Encoding': 'gzip',
|
||||
requestType: 'agent'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
usageRecorded = true
|
||||
}
|
||||
@@ -103,7 +104,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +160,8 @@ async function sendAntigravityRequest({
|
||||
0,
|
||||
0,
|
||||
requestedModel,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
654
src/services/apiKeyIndexService.js
Normal file
654
src/services/apiKeyIndexService.js
Normal file
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* API Key 索引服务
|
||||
* 维护 Sorted Set 索引以支持高效分页查询
|
||||
*/
|
||||
|
||||
const { randomUUID } = require('crypto')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ApiKeyIndexService {
|
||||
constructor() {
|
||||
this.redis = null
|
||||
this.INDEX_VERSION_KEY = 'apikey:index:version'
|
||||
this.CURRENT_VERSION = 2 // 版本升级,触发重建
|
||||
this.isBuilding = false
|
||||
this.buildProgress = { current: 0, total: 0 }
|
||||
|
||||
// 索引键名
|
||||
this.INDEX_KEYS = {
|
||||
CREATED_AT: 'apikey:idx:createdAt',
|
||||
LAST_USED_AT: 'apikey:idx:lastUsedAt',
|
||||
NAME: 'apikey:idx:name',
|
||||
ACTIVE_SET: 'apikey:set:active',
|
||||
DELETED_SET: 'apikey:set:deleted',
|
||||
ALL_SET: 'apikey:idx:all',
|
||||
TAGS_ALL: 'apikey:tags:all' // 所有标签的集合
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务
|
||||
*/
|
||||
init(redis) {
|
||||
this.redis = redis
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时检查并重建索引
|
||||
*/
|
||||
async checkAndRebuild() {
|
||||
if (!this.redis) {
|
||||
logger.warn('⚠️ ApiKeyIndexService: Redis not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 始终检查并回填 hash_map(幂等操作,确保升级兼容)
|
||||
this.rebuildHashMap().catch((err) => {
|
||||
logger.error('❌ API Key hash_map 回填失败:', err)
|
||||
})
|
||||
|
||||
if (parseInt(version) >= this.CURRENT_VERSION) {
|
||||
logger.info('✅ API Key 索引已是最新版本')
|
||||
return
|
||||
}
|
||||
|
||||
// 后台异步重建,不阻塞启动
|
||||
this.rebuildIndexes().catch((err) => {
|
||||
logger.error('❌ API Key 索引重建失败:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ 检查 API Key 索引版本失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填 apikey:hash_map(升级兼容)
|
||||
* 扫描所有 API Key,确保 hash -> keyId 映射存在
|
||||
*/
|
||||
async rebuildHashMap() {
|
||||
if (!this.redis) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
|
||||
let rebuilt = 0
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 批量获取 API Key 数据
|
||||
for (const keyId of batch) {
|
||||
pipeline.hgetall(`apikey:${keyId}`)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 检查并回填缺失的映射
|
||||
const fillPipeline = client.pipeline()
|
||||
let needFill = false
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const keyData = results[j]?.[1]
|
||||
if (keyData && keyData.apiKey) {
|
||||
// keyData.apiKey 存储的是哈希值
|
||||
const exists = await client.hexists('apikey:hash_map', keyData.apiKey)
|
||||
if (!exists) {
|
||||
fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j])
|
||||
rebuilt++
|
||||
needFill = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needFill) {
|
||||
await fillPipeline.exec()
|
||||
}
|
||||
}
|
||||
|
||||
if (rebuilt > 0) {
|
||||
logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 回填 hash_map 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查索引是否可用
|
||||
*/
|
||||
async isIndexReady() {
|
||||
if (!this.redis || this.isBuilding) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
return parseInt(version) >= this.CURRENT_VERSION
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建所有索引
|
||||
*/
|
||||
async rebuildIndexes() {
|
||||
if (this.isBuilding) {
|
||||
logger.warn('⚠️ API Key 索引正在重建中,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
this.isBuilding = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
logger.info('🔨 开始重建 API Key 索引...')
|
||||
|
||||
// 0. 先删除版本号,让 _checkIndexReady 返回 false,查询回退到 SCAN
|
||||
await client.del(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 1. 清除旧索引
|
||||
const indexKeys = Object.values(this.INDEX_KEYS)
|
||||
for (const key of indexKeys) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清除标签索引(用 SCAN 避免阻塞)
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
// 2. 扫描所有 API Key
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
this.buildProgress = { current: 0, total: keyIds.length }
|
||||
|
||||
logger.info(`📊 发现 ${keyIds.length} 个 API Key,开始建立索引...`)
|
||||
|
||||
// 3. 批量处理(每批 500 个)
|
||||
const BATCH_SIZE = 500
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const apiKeys = await this.redis.batchGetApiKeys(batch)
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
if (!apiKey || !apiKey.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
// 创建时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
|
||||
// 最后使用时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
|
||||
// 名称索引(用于排序,存储格式:name\0keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
|
||||
// 全部集合
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
// 状态集合
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length)
|
||||
|
||||
// 每批次后短暂让出 CPU
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
// 4. 更新版本号
|
||||
await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION)
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`)
|
||||
} catch (error) {
|
||||
logger.error('❌ API Key 索引重建失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.isBuilding = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单个 API Key 到索引
|
||||
*/
|
||||
async addToIndex(apiKey) {
|
||||
if (!this.redis || !apiKey || !apiKey.id) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now()
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
} catch (error) {
|
||||
logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新索引(状态、名称、标签变化时调用)
|
||||
*/
|
||||
async updateIndex(keyId, updates, oldData = {}) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 更新名称索引
|
||||
if (updates.name !== undefined) {
|
||||
const oldName = (oldData.name || '').toLowerCase()
|
||||
const newName = (updates.name || '').toLowerCase()
|
||||
if (oldName !== newName) {
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间索引
|
||||
if (updates.lastUsedAt !== undefined) {
|
||||
const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
}
|
||||
|
||||
// 更新状态集合
|
||||
if (updates.isActive !== undefined || updates.isDeleted !== undefined) {
|
||||
const isActive = updates.isActive ?? oldData.isActive
|
||||
const isDeleted = updates.isDeleted ?? oldData.isDeleted
|
||||
|
||||
if (isDeleted === true || isDeleted === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive === true || isActive === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签索引
|
||||
const removedTags = []
|
||||
if (updates.tags !== undefined) {
|
||||
const oldTags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
const newTags = Array.isArray(updates.tags) ? updates.tags : []
|
||||
|
||||
// 移除旧标签
|
||||
for (const tag of oldTags) {
|
||||
if (tag && !newTags.includes(tag)) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
removedTags.push(tag)
|
||||
}
|
||||
}
|
||||
// 添加新标签
|
||||
for (const tag of newTags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查被移除的标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of removedTags) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从索引中移除 API Key
|
||||
*/
|
||||
async removeFromIndex(keyId, oldData = {}) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
const name = (oldData.name || '').toLowerCase()
|
||||
|
||||
pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`)
|
||||
pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
|
||||
// 移除标签索引
|
||||
const tags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用索引进行分页查询
|
||||
* 使用 ZINTERSTORE 优化,避免全量拉回内存
|
||||
*/
|
||||
async queryWithIndex(options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
isActive,
|
||||
tag,
|
||||
excludeDeleted = true
|
||||
} = options
|
||||
|
||||
const client = this.redis.getClientSafe()
|
||||
const tempSets = []
|
||||
|
||||
try {
|
||||
// 1. 构建筛选集合
|
||||
let filterSet = this.INDEX_KEYS.ALL_SET
|
||||
|
||||
// 状态筛选
|
||||
if (isActive === true || isActive === 'true') {
|
||||
// 筛选活跃的
|
||||
filterSet = this.INDEX_KEYS.ACTIVE_SET
|
||||
} else if (isActive === false || isActive === 'false') {
|
||||
// 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted)
|
||||
const tempKey = `apikey:tmp:inactive:${randomUUID()}`
|
||||
if (excludeDeleted) {
|
||||
await client.sdiffstore(
|
||||
tempKey,
|
||||
this.INDEX_KEYS.ALL_SET,
|
||||
this.INDEX_KEYS.ACTIVE_SET,
|
||||
this.INDEX_KEYS.DELETED_SET
|
||||
)
|
||||
} else {
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET)
|
||||
}
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
} else if (excludeDeleted) {
|
||||
// 排除已删除:ALL - DELETED
|
||||
const tempKey = `apikey:tmp:notdeleted:${randomUUID()}`
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (tag) {
|
||||
const tagSet = `apikey:tag:${tag}`
|
||||
const tempKey = `apikey:tmp:tag:${randomUUID()}`
|
||||
await client.sinterstore(tempKey, filterSet, tagSet)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 2. 获取筛选后的 keyId 集合
|
||||
const filterMembers = await client.smembers(filterSet)
|
||||
if (filterMembers.length === 0) {
|
||||
// 没有匹配的数据
|
||||
return {
|
||||
items: [],
|
||||
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||||
availableTags: await this._getAvailableTags(client)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 排序
|
||||
let sortedKeyIds
|
||||
|
||||
if (sortBy === 'name') {
|
||||
// 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of filterMembers) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'name')
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 组装并排序
|
||||
const items = filterMembers.map((keyId, i) => ({
|
||||
keyId,
|
||||
name: (results[i]?.[1] || '').toLowerCase()
|
||||
}))
|
||||
items.sort((a, b) =>
|
||||
sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||
)
|
||||
sortedKeyIds = items.map((item) => item.keyId)
|
||||
} else {
|
||||
// createdAt / lastUsedAt 索引成员是 keyId,可以用 ZINTERSTORE
|
||||
const sortIndex = this._getSortIndex(sortBy)
|
||||
const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}`
|
||||
tempSets.push(tempSortedKey)
|
||||
|
||||
// 将 filterSet 转换为 Sorted Set(所有分数为 0)
|
||||
const filterZsetKey = `apikey:tmp:filter:${randomUUID()}`
|
||||
tempSets.push(filterZsetKey)
|
||||
|
||||
const zaddArgs = []
|
||||
for (const member of filterMembers) {
|
||||
zaddArgs.push(0, member)
|
||||
}
|
||||
await client.zadd(filterZsetKey, ...zaddArgs)
|
||||
await client.expire(filterZsetKey, 60)
|
||||
|
||||
// ZINTERSTORE:取交集,使用排序索引的分数(WEIGHTS 0 1)
|
||||
await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1)
|
||||
await client.expire(tempSortedKey, 60)
|
||||
|
||||
// 获取排序后的 keyId
|
||||
sortedKeyIds =
|
||||
sortOrder === 'desc'
|
||||
? await client.zrevrange(tempSortedKey, 0, -1)
|
||||
: await client.zrange(tempSortedKey, 0, -1)
|
||||
}
|
||||
|
||||
// 4. 分页
|
||||
const total = sortedKeyIds.length
|
||||
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
|
||||
const validPage = Math.min(Math.max(1, page), totalPages)
|
||||
const start = (validPage - 1) * pageSize
|
||||
const pageKeyIds = sortedKeyIds.slice(start, start + pageSize)
|
||||
|
||||
// 5. 获取数据
|
||||
const items = await this.redis.batchGetApiKeys(pageKeyIds)
|
||||
|
||||
// 6. 获取所有标签
|
||||
const availableTags = await this._getAvailableTags(client)
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: validPage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages
|
||||
},
|
||||
availableTags
|
||||
}
|
||||
} finally {
|
||||
// 7. 清理临时集合
|
||||
for (const tempKey of tempSets) {
|
||||
client.del(tempKey).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排序索引键名
|
||||
*/
|
||||
_getSortIndex(sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'createdAt':
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
case 'lastUsedAt':
|
||||
return this.INDEX_KEYS.LAST_USED_AT
|
||||
case 'name':
|
||||
return this.INDEX_KEYS.NAME
|
||||
default:
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用标签(从 tags:all 集合)
|
||||
*/
|
||||
async _getAvailableTags(client) {
|
||||
try {
|
||||
const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL)
|
||||
return tags.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 lastUsedAt 索引(供 recordUsage 调用)
|
||||
*/
|
||||
async updateLastUsedAt(keyId, lastUsedAt) {
|
||||
if (!this.redis || !keyId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now()
|
||||
await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引状态
|
||||
*/
|
||||
async getStatus() {
|
||||
if (!this.redis) {
|
||||
return { ready: false, building: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET)
|
||||
|
||||
return {
|
||||
ready: parseInt(version) >= this.CURRENT_VERSION,
|
||||
building: this.isBuilding,
|
||||
progress: this.buildProgress,
|
||||
version: parseInt(version) || 0,
|
||||
currentVersion: this.CURRENT_VERSION,
|
||||
totalIndexed: totalCount
|
||||
}
|
||||
} catch {
|
||||
return { ready: false, building: this.isBuilding }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例
|
||||
const apiKeyIndexService = new ApiKeyIndexService()
|
||||
|
||||
module.exports = apiKeyIndexService
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
|
||||
// 从Redis中删除账户数据
|
||||
await client.del(accountKey)
|
||||
|
||||
// 从索引中移除
|
||||
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'azure_openai:account:index',
|
||||
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^azure_openai:account:(.+)$/
|
||||
)
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 不返回敏感数据给前端
|
||||
delete accountData.apiKey
|
||||
|
||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
||||
description = '',
|
||||
region = process.env.AWS_REGION || 'us-east-1',
|
||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
schedulable = true, // 是否可被调度
|
||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
||||
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -71,8 +72,14 @@ class BedrockAccountService {
|
||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||
}
|
||||
|
||||
// 加密存储 Bearer Token
|
||||
if (bearerToken) {
|
||||
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||
await redis.addToIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
|
||||
|
||||
@@ -106,9 +113,85 @@ class BedrockAccountService {
|
||||
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
// 解密AWS凭证用于内部使用
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
// 根据凭证类型解密对应的凭证
|
||||
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||
try {
|
||||
let accessKeyDecrypted = false
|
||||
let bearerTokenDecrypted = false
|
||||
|
||||
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||
// Access Key 模式:解密 AWS 凭证
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||
// Bearer Token 模式:解密 Bearer Token
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.debug(
|
||||
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||
)
|
||||
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
}
|
||||
logger.debug(
|
||||
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||
)
|
||||
}
|
||||
|
||||
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.warn(
|
||||
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||
)
|
||||
if (account.awsCredentials) {
|
||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||
accessKeyDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||
)
|
||||
}
|
||||
if (account.bearerToken) {
|
||||
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||
account.bearerToken = decrypted.token
|
||||
bearerTokenDecrypted = true
|
||||
logger.warn(
|
||||
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证至少解密了一种凭证
|
||||
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||
logger.error(
|
||||
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found in account data'
|
||||
}
|
||||
}
|
||||
} catch (decryptError) {
|
||||
logger.error(
|
||||
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||
decryptError
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: `Credentials decryption failed: ${decryptError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
@@ -126,12 +209,18 @@ class BedrockAccountService {
|
||||
// 📋 获取所有账户列表
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys('bedrock_account:*')
|
||||
const _client = redis.getClientSafe()
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'bedrock_account:index',
|
||||
'bedrock_account:*',
|
||||
/^bedrock_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `bedrock_account:${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData) {
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
@@ -155,7 +244,11 @@ class BedrockAccountService {
|
||||
updatedAt: account.updatedAt,
|
||||
type: 'bedrock',
|
||||
platform: 'bedrock',
|
||||
hasCredentials: !!account.awsCredentials
|
||||
// 根据凭证类型判断是否有凭证
|
||||
hasCredentials:
|
||||
account.credentialType === 'bearer_token'
|
||||
? !!account.bearerToken
|
||||
: !!account.awsCredentials
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -235,6 +328,15 @@ class BedrockAccountService {
|
||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||
}
|
||||
|
||||
// 更新 Bearer Token
|
||||
if (updates.bearerToken !== undefined) {
|
||||
if (updates.bearerToken) {
|
||||
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||
} else {
|
||||
delete account.bearerToken
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||
if (updates.subscriptionExpiresAt !== undefined) {
|
||||
@@ -280,6 +382,7 @@ class BedrockAccountService {
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(`bedrock_account:${accountId}`)
|
||||
await redis.removeFromIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
|
||||
|
||||
@@ -345,13 +448,45 @@ class BedrockAccountService {
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
||||
logger.info(
|
||||
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 尝试获取模型列表来测试连接
|
||||
// 验证凭证是否已解密
|
||||
const hasValidCredentials =
|
||||
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||
|
||||
if (!hasValidCredentials) {
|
||||
logger.error(
|
||||
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid credentials found after decryption'
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||
try {
|
||||
bedrockRelayService._getBedrockClient(account.region, account)
|
||||
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||
} catch (clientError) {
|
||||
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||
const models = await bedrockRelayService.getAvailableModels(account)
|
||||
|
||||
if (models && models.length > 0) {
|
||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
||||
logger.info(
|
||||
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -376,6 +511,135 @@ class BedrockAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {Object} res - Express response 对象
|
||||
* @param {string} model - 测试使用的模型
|
||||
*/
|
||||
async testAccountConnection(accountId, res, model = null) {
|
||||
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const accountResult = await this.getAccount(accountId)
|
||||
if (!accountResult.success) {
|
||||
throw new Error(accountResult.error || 'Account not found')
|
||||
}
|
||||
|
||||
const account = accountResult.data
|
||||
|
||||
// 根据账户类型选择合适的测试模型
|
||||
if (!model) {
|
||||
// Access Key 模式使用 Haiku(更快更便宜)
|
||||
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
res.status(200)
|
||||
|
||||
// 发送 test_start 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||
|
||||
// 构造测试请求体(Bedrock 格式)
|
||||
const bedrockPayload = {
|
||||
anthropic_version: 'bedrock-2023-05-31',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 获取 Bedrock 客户端
|
||||
const region = account.region || bedrockRelayService.defaultRegion
|
||||
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||
|
||||
// 创建流式调用命令
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: model,
|
||||
body: JSON.stringify(bedrockPayload),
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json'
|
||||
})
|
||||
|
||||
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||
|
||||
const startTime = Date.now()
|
||||
const response = await client.send(command)
|
||||
|
||||
// 处理流式响应
|
||||
// let responseText = ''
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk) {
|
||||
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||
|
||||
// 提取文本内容
|
||||
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||
const { text } = chunkData.delta
|
||||
// responseText += text
|
||||
|
||||
// 发送 content 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
|
||||
// 检测错误
|
||||
if (chunkData.type === 'error') {
|
||||
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||
|
||||
// 发送 message_stop 事件(前端兼容)
|
||||
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||
|
||||
// 发送 test_complete 事件
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
|
||||
// 结束响应
|
||||
res.end()
|
||||
|
||||
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||
|
||||
// 发送错误事件给前端
|
||||
try {
|
||||
// 检查响应流是否仍然可写
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.status(200)
|
||||
}
|
||||
const errorMsg = error.message || '测试失败'
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
} catch (writeError) {
|
||||
logger.error('Failed to write error to response stream:', writeError)
|
||||
}
|
||||
|
||||
// 不再重新抛出错误,避免路由层再次处理
|
||||
// throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户订阅是否过期
|
||||
* @param {Object} account - 账户对象
|
||||
|
||||
@@ -48,13 +48,17 @@ class BedrockRelayService {
|
||||
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
||||
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
||||
}
|
||||
} else if (bedrockAccount?.bearerToken) {
|
||||
// Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量
|
||||
clientConfig.token = { token: bedrockAccount.bearerToken }
|
||||
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
|
||||
} else {
|
||||
// 检查是否有环境变量凭证
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
clientConfig.credentials = fromEnv()
|
||||
} else {
|
||||
throw new Error(
|
||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -339,8 +343,8 @@ class BedrockRelayService {
|
||||
res.write(`event: ${claudeEvent.type}\n`)
|
||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||
|
||||
// 提取使用统计
|
||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
||||
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||
totalUsage = claudeEvent.data.usage
|
||||
}
|
||||
|
||||
@@ -431,6 +435,18 @@ class BedrockRelayService {
|
||||
_mapToBedrockModel(modelName) {
|
||||
// 标准Claude模型名到Bedrock模型名的映射表
|
||||
const modelMapping = {
|
||||
// Claude 4.5 Opus
|
||||
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
|
||||
// Claude 4.5 Sonnet
|
||||
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
|
||||
// Claude 4.5 Haiku
|
||||
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
|
||||
// Claude Sonnet 4
|
||||
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
@@ -560,14 +576,28 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'message_start',
|
||||
data: {
|
||||
type: 'message',
|
||||
id: `msg_${Date.now()}_bedrock`,
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: this.defaultModel,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: `msg_${Date.now()}_bedrock`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: this.defaultModel,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'content_block_start') {
|
||||
return {
|
||||
type: 'content_block_start',
|
||||
data: {
|
||||
type: 'content_block_start',
|
||||
index: bedrockChunk.index || 0,
|
||||
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,16 +606,28 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: bedrockChunk.index || 0,
|
||||
delta: bedrockChunk.delta || {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'content_block_stop') {
|
||||
return {
|
||||
type: 'content_block_stop',
|
||||
data: {
|
||||
type: 'content_block_stop',
|
||||
index: bedrockChunk.index || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bedrockChunk.type === 'message_delta') {
|
||||
return {
|
||||
type: 'message_delta',
|
||||
data: {
|
||||
type: 'message_delta',
|
||||
delta: bedrockChunk.delta || {},
|
||||
usage: bedrockChunk.usage || {}
|
||||
}
|
||||
@@ -596,7 +638,7 @@ class BedrockRelayService {
|
||||
return {
|
||||
type: 'message_stop',
|
||||
data: {
|
||||
usage: bedrockChunk.usage || {}
|
||||
type: 'message_stop'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ class BillingEventPublisher {
|
||||
// MKSTREAM: 如果 stream 不存在则创建
|
||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||
|
||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
||||
logger.success(`Created consumer group: ${groupName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('ccr-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -106,6 +96,7 @@ class CcrAccountService {
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('ccr_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -139,12 +130,17 @@ class CcrAccountService {
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'ccr_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^ccr_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
@@ -331,6 +327,9 @@ class CcrAccountService {
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('ccr_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
@@ -403,7 +402,7 @@ class CcrAccountService {
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
logger.success(`Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
@@ -488,7 +487,7 @@ class CcrAccountService {
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
logger.success(`Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
@@ -606,70 +605,12 @@ class CcrAccountService {
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_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')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
return this._encryptor.encrypt(data)
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
return this._encryptor.decrypt(encryptedData)
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
@@ -843,7 +784,7 @@ class CcrAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
@@ -915,7 +856,7 @@ class CcrAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
logger.success(`Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
|
||||
@@ -1570,7 +1570,7 @@ class ClaudeAccountService {
|
||||
'rateLimitAutoStopped'
|
||||
)
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -2242,7 +2242,7 @@ class ClaudeAccountService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
@@ -2310,11 +2310,11 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success('✅ Session window initialization completed:')
|
||||
logger.success(` 📊 Total accounts: ${accounts.length}`)
|
||||
logger.success(` ✅ Valid windows: ${validWindowCount}`)
|
||||
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` 📭 No windows: ${noWindowCount}`)
|
||||
logger.success('Session window initialization completed:')
|
||||
logger.success(` Total accounts: ${accounts.length}`)
|
||||
logger.success(` Valid windows: ${validWindowCount}`)
|
||||
logger.success(` Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` No windows: ${noWindowCount}`)
|
||||
|
||||
return {
|
||||
total: accounts.length,
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
getCachedConfig,
|
||||
setCachedConfig,
|
||||
deleteCachedConfig
|
||||
} = require('../utils/performanceOptimizer')
|
||||
|
||||
class ClaudeCodeHeadersService {
|
||||
constructor() {
|
||||
@@ -41,6 +46,9 @@ class ClaudeCodeHeadersService {
|
||||
'sec-fetch-mode',
|
||||
'accept-encoding'
|
||||
]
|
||||
|
||||
// Headers 缓存 TTL(60秒)
|
||||
this.headersCacheTtl = 60000
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +155,9 @@ class ClaudeCodeHeadersService {
|
||||
|
||||
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
|
||||
|
||||
// 更新内存缓存,避免延迟
|
||||
setCachedConfig(key, extractedHeaders, this.headersCacheTtl)
|
||||
|
||||
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
|
||||
@@ -154,18 +165,27 @@ class ClaudeCodeHeadersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号的 Claude Code headers
|
||||
* 获取账号的 Claude Code headers(带内存缓存)
|
||||
*/
|
||||
async getAccountHeaders(accountId) {
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
|
||||
// 检查内存缓存
|
||||
const cached = getCachedConfig(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
const data = await redis.getClient().get(key)
|
||||
const data = await redis.getClient().get(cacheKey)
|
||||
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data)
|
||||
logger.debug(
|
||||
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
|
||||
)
|
||||
// 缓存到内存
|
||||
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
|
||||
return parsed.headers
|
||||
}
|
||||
|
||||
@@ -183,8 +203,10 @@ class ClaudeCodeHeadersService {
|
||||
*/
|
||||
async clearAccountHeaders(accountId) {
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(key)
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(cacheKey)
|
||||
// 删除内存缓存
|
||||
deleteCachedConfig(cacheKey)
|
||||
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
|
||||
@@ -192,12 +214,12 @@ class ClaudeCodeHeadersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有账号的 headers 信息
|
||||
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys)
|
||||
*/
|
||||
async getAllAccountHeaders() {
|
||||
try {
|
||||
const pattern = 'claude_code_headers:*'
|
||||
const keys = await redis.getClient().keys(pattern)
|
||||
const keys = await redis.scanKeys(pattern)
|
||||
|
||||
const results = {}
|
||||
for (const key of keys) {
|
||||
|
||||
@@ -129,6 +129,7 @@ class ClaudeConsoleAccountService {
|
||||
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -167,11 +168,18 @@ class ClaudeConsoleAccountService {
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'claude_console_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^claude_console_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
if (!accountData.id) {
|
||||
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
||||
@@ -449,6 +457,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 从Redis删除
|
||||
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redis.removeFromIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -577,7 +586,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
||||
@@ -585,7 +594,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -858,7 +867,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
||||
@@ -866,7 +875,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -967,7 +976,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
||||
|
||||
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -1416,7 +1425,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset all daily usage:', error)
|
||||
}
|
||||
@@ -1489,7 +1498,7 @@ class ClaudeConsoleAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||
logger.success(`Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
|
||||
@@ -18,8 +18,8 @@ const DEFAULT_CONFIG = {
|
||||
// 用户消息队列配置
|
||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长TTL
|
||||
userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
|
||||
userMessageQueueLockTtlMs: 120000, // 锁TTL(毫秒)
|
||||
// 并发请求排队配置
|
||||
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
|
||||
concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const https = require('https')
|
||||
const zlib = require('zlib')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
@@ -17,55 +16,64 @@ const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getPricingData
|
||||
} = require('../utils/performanceOptimizer')
|
||||
|
||||
// structuredClone polyfill for Node < 17
|
||||
const safeClone =
|
||||
typeof structuredClone === 'function' ? structuredClone : (obj) => JSON.parse(JSON.stringify(obj))
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
|
||||
this.bodyStore = new Map()
|
||||
this._bodyStoreIdCounter = 0
|
||||
this.apiVersion = config.claude.apiVersion
|
||||
this.betaHeader = config.claude.betaHeader
|
||||
this.systemPrompt = config.claude.systemPrompt
|
||||
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
this.toolNameSuffix = null
|
||||
this.toolNameSuffixGeneratedAt = 0
|
||||
this.toolNameSuffixTtlMs = 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
|
||||
// 规则:
|
||||
// 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20
|
||||
// 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
|
||||
// 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要
|
||||
_getBetaHeader(modelId, clientBetaHeader) {
|
||||
const OAUTH_BETA = 'oauth-2025-04-20'
|
||||
const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
||||
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
|
||||
const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
|
||||
|
||||
// 如果客户端传递了 anthropic-beta
|
||||
if (clientBetaHeader) {
|
||||
// 检查是否已包含 oauth-2025-04-20
|
||||
if (clientBetaHeader.includes(OAUTH_BETA)) {
|
||||
return clientBetaHeader
|
||||
}
|
||||
|
||||
// 需要添加 oauth-2025-04-20
|
||||
const parts = clientBetaHeader.split(',').map((p) => p.trim())
|
||||
|
||||
// 找到 claude-code-20250219 的位置
|
||||
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
|
||||
|
||||
if (claudeCodeIndex !== -1) {
|
||||
// 在 claude-code-20250219 后面插入
|
||||
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
|
||||
} else {
|
||||
// 放在第一位
|
||||
parts.unshift(OAUTH_BETA)
|
||||
}
|
||||
|
||||
return parts.join(',')
|
||||
}
|
||||
|
||||
// 客户端没有传递,根据模型判断
|
||||
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
|
||||
if (isHaikuModel) {
|
||||
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14'
|
||||
const baseBetas = isHaikuModel
|
||||
? [OAUTH_BETA, INTERLEAVED_THINKING_BETA]
|
||||
: [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA]
|
||||
|
||||
const betaList = []
|
||||
const seen = new Set()
|
||||
const addBeta = (beta) => {
|
||||
if (!beta || seen.has(beta)) {
|
||||
return
|
||||
}
|
||||
seen.add(beta)
|
||||
betaList.push(beta)
|
||||
}
|
||||
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||
|
||||
baseBetas.forEach(addBeta)
|
||||
|
||||
if (clientBetaHeader) {
|
||||
clientBetaHeader
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(addBeta)
|
||||
}
|
||||
|
||||
return betaList.join(',')
|
||||
}
|
||||
|
||||
_buildStandardRateLimitMessage(resetTime) {
|
||||
@@ -140,6 +148,235 @@ class ClaudeRelayService {
|
||||
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
|
||||
}
|
||||
|
||||
_isClaudeCodeUserAgent(clientHeaders) {
|
||||
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
|
||||
return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent)
|
||||
}
|
||||
|
||||
_isActualClaudeCodeRequest(requestBody, clientHeaders) {
|
||||
return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders)
|
||||
}
|
||||
|
||||
_getHeaderValueCaseInsensitive(headers, key) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const lowerKey = key.toLowerCase()
|
||||
for (const candidate of Object.keys(headers)) {
|
||||
if (candidate.toLowerCase() === lowerKey) {
|
||||
return headers[candidate]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
_isClaudeCodeCredentialError(body) {
|
||||
const message = this._extractErrorMessage(body)
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lower.includes('only authorized for use with claude code') ||
|
||||
lower.includes('cannot be used for other api requests')
|
||||
)
|
||||
}
|
||||
|
||||
_toPascalCaseToolName(name) {
|
||||
const parts = name.split(/[_-]/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return name
|
||||
}
|
||||
const pascal = parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('')
|
||||
return `${pascal}_tool`
|
||||
}
|
||||
|
||||
_getToolNameSuffix() {
|
||||
const now = Date.now()
|
||||
if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) {
|
||||
this.toolNameSuffix = Math.random().toString(36).substring(2, 8)
|
||||
this.toolNameSuffixGeneratedAt = now
|
||||
}
|
||||
return this.toolNameSuffix
|
||||
}
|
||||
|
||||
_toRandomizedToolName(name) {
|
||||
const suffix = this._getToolNameSuffix()
|
||||
return `${name}_${suffix}`
|
||||
}
|
||||
|
||||
_transformToolNamesInRequestBody(body, options = {}) {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const useRandomized = options.useRandomizedToolNames === true
|
||||
const forwardMap = new Map()
|
||||
const reverseMap = new Map()
|
||||
|
||||
const transformName = (name) => {
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
return name
|
||||
}
|
||||
if (forwardMap.has(name)) {
|
||||
return forwardMap.get(name)
|
||||
}
|
||||
const transformed = useRandomized
|
||||
? this._toRandomizedToolName(name)
|
||||
: this._toPascalCaseToolName(name)
|
||||
if (transformed !== name) {
|
||||
forwardMap.set(name, transformed)
|
||||
reverseMap.set(transformed, name)
|
||||
}
|
||||
return transformed
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tools)) {
|
||||
body.tools.forEach((tool) => {
|
||||
if (tool && typeof tool.name === 'string') {
|
||||
tool.name = transformName(tool.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (body.tool_choice && typeof body.tool_choice === 'object') {
|
||||
if (typeof body.tool_choice.name === 'string') {
|
||||
body.tool_choice.name = transformName(body.tool_choice.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages.forEach((message) => {
|
||||
const content = message?.content
|
||||
if (Array.isArray(content)) {
|
||||
content.forEach((block) => {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
block.name = transformName(block.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return reverseMap.size > 0 ? reverseMap : null
|
||||
}
|
||||
|
||||
_restoreToolName(name, toolNameMap) {
|
||||
if (!toolNameMap || toolNameMap.size === 0) {
|
||||
return name
|
||||
}
|
||||
return toolNameMap.get(name) || name
|
||||
}
|
||||
|
||||
_restoreToolNamesInContentBlocks(content, toolNameMap) {
|
||||
if (!Array.isArray(content)) {
|
||||
return
|
||||
}
|
||||
|
||||
content.forEach((block) => {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
block.name = this._restoreToolName(block.name, toolNameMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_restoreToolNamesInResponseObject(responseBody, toolNameMap) {
|
||||
if (!responseBody || typeof responseBody !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(responseBody.content)) {
|
||||
this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap)
|
||||
}
|
||||
|
||||
if (responseBody.message && Array.isArray(responseBody.message.content)) {
|
||||
this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
_restoreToolNamesInResponseBody(responseBody, toolNameMap) {
|
||||
if (!responseBody || !toolNameMap || toolNameMap.size === 0) {
|
||||
return responseBody
|
||||
}
|
||||
|
||||
if (typeof responseBody === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody)
|
||||
this._restoreToolNamesInResponseObject(parsed, toolNameMap)
|
||||
return JSON.stringify(parsed)
|
||||
} catch (error) {
|
||||
return responseBody
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof responseBody === 'object') {
|
||||
this._restoreToolNamesInResponseObject(responseBody, toolNameMap)
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}
|
||||
|
||||
_restoreToolNamesInStreamEvent(event, toolNameMap) {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.content_block && event.content_block.type === 'tool_use') {
|
||||
if (typeof event.content_block.name === 'string') {
|
||||
event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.delta && event.delta.type === 'tool_use') {
|
||||
if (typeof event.delta.name === 'string') {
|
||||
event.delta.name = this._restoreToolName(event.delta.name, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message && Array.isArray(event.message.content)) {
|
||||
this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap)
|
||||
}
|
||||
|
||||
if (Array.isArray(event.content)) {
|
||||
this._restoreToolNamesInContentBlocks(event.content, toolNameMap)
|
||||
}
|
||||
}
|
||||
|
||||
_createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) {
|
||||
if (!toolNameMap || toolNameMap.size === 0) {
|
||||
return streamTransformer
|
||||
}
|
||||
|
||||
return (payload) => {
|
||||
const transformed = streamTransformer ? streamTransformer(payload) : payload
|
||||
if (!transformed || typeof transformed !== 'string') {
|
||||
return transformed
|
||||
}
|
||||
|
||||
const lines = transformed.split('\n')
|
||||
const updated = lines.map((line) => {
|
||||
if (!line.startsWith('data:')) {
|
||||
return line
|
||||
}
|
||||
const jsonStr = line.slice(5).trimStart()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
return line
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
this._restoreToolNamesInStreamEvent(data, toolNameMap)
|
||||
return `data: ${JSON.stringify(data)}`
|
||||
} catch (error) {
|
||||
return line
|
||||
}
|
||||
})
|
||||
|
||||
return updated.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 转发请求到Claude API
|
||||
async relayRequest(
|
||||
requestBody,
|
||||
@@ -153,6 +390,7 @@ class ClaudeRelayService {
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
let selectedAccountId = null
|
||||
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
|
||||
|
||||
try {
|
||||
// 调试日志:查看API Key数据
|
||||
@@ -311,7 +549,12 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, account)
|
||||
// 🧹 内存优化:存储到 bodyStore,避免闭包捕获
|
||||
const originalBodyString = JSON.stringify(processedBody)
|
||||
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
|
||||
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -332,36 +575,59 @@ class ClaudeRelayService {
|
||||
clientResponse.once('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
const makeRequestWithRetries = async (requestOptions) => {
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
|
||||
do {
|
||||
response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// 检查是否需要重试 403
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
do {
|
||||
// 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获
|
||||
let retryRequestBody
|
||||
try {
|
||||
retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for retry: ${parseError.message}`)
|
||||
throw new Error(`Request body parse failed: ${parseError.message}`)
|
||||
}
|
||||
response = await this._makeClaudeRequest(
|
||||
retryRequestBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
{
|
||||
...requestOptions,
|
||||
isRealClaudeCodeRequest
|
||||
}
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
return { response, retryCount }
|
||||
}
|
||||
|
||||
let requestOptions = options
|
||||
let { response, retryCount } = await makeRequestWithRetries(requestOptions)
|
||||
|
||||
if (
|
||||
this._isClaudeCodeCredentialError(response.body) &&
|
||||
requestOptions.useRandomizedToolNames !== true
|
||||
) {
|
||||
requestOptions = { ...requestOptions, useRandomizedToolNames: true }
|
||||
;({ response, retryCount } = await makeRequestWithRetries(requestOptions))
|
||||
}
|
||||
|
||||
// 如果进行了重试,记录最终结果
|
||||
if (retryCount > 0) {
|
||||
@@ -661,6 +927,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
throw error
|
||||
} finally {
|
||||
// 🧹 清理 bodyStore
|
||||
if (bodyStoreIdNonStream !== null) {
|
||||
this.bodyStore.delete(bodyStoreIdNonStream)
|
||||
}
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
@@ -684,8 +954,8 @@ class ClaudeRelayService {
|
||||
return body
|
||||
}
|
||||
|
||||
// 深拷贝请求体
|
||||
const processedBody = JSON.parse(JSON.stringify(body))
|
||||
// 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能
|
||||
const processedBody = safeClone(body)
|
||||
|
||||
// 验证并限制max_tokens参数
|
||||
this._validateAndLimitMaxTokens(processedBody)
|
||||
@@ -815,15 +1085,15 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取模型定价配置文件
|
||||
// 使用缓存的定价数据
|
||||
const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json')
|
||||
const pricingData = getPricingData(pricingFilePath)
|
||||
|
||||
if (!fs.existsSync(pricingFilePath)) {
|
||||
if (!pricingData) {
|
||||
logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation')
|
||||
return
|
||||
}
|
||||
|
||||
const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
|
||||
const model = body.model || 'claude-sonnet-4-20250514'
|
||||
|
||||
// 查找对应模型的配置
|
||||
@@ -989,20 +1259,20 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🌐 获取代理Agent(使用统一的代理工具)
|
||||
async _getProxyAgent(accountId) {
|
||||
async _getProxyAgent(accountId, account = null) {
|
||||
try {
|
||||
const accountData = await claudeAccountService.getAllAccounts()
|
||||
const account = accountData.find((acc) => acc.id === accountId)
|
||||
// 优先使用传入的 account 对象,避免重复查询
|
||||
const accountData = account || (await claudeAccountService.getAccount(accountId))
|
||||
|
||||
if (!account || !account.proxy) {
|
||||
if (!accountData || !accountData.proxy) {
|
||||
logger.debug('🌐 No proxy configured for Claude account')
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(accountData.proxy)
|
||||
if (proxyAgent) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}`
|
||||
)
|
||||
}
|
||||
return proxyAgent
|
||||
@@ -1035,23 +1305,19 @@ class ClaudeRelayService {
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
// 判断是否是真实的 Claude Code 请求
|
||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||
const isRealClaudeCode =
|
||||
requestOptions.isRealClaudeCodeRequest === undefined
|
||||
? this.isRealClaudeCodeRequest(body)
|
||||
: requestOptions.isRealClaudeCodeRequest === true
|
||||
|
||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||
let finalHeaders = { ...filteredHeaders }
|
||||
let requestPayload = body
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
|
||||
// 只添加客户端没有提供的 headers
|
||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
}
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1073,6 +1339,13 @@ class ClaudeRelayService {
|
||||
requestPayload = extensionResult.body
|
||||
finalHeaders = extensionResult.headers
|
||||
|
||||
let toolNameMap = null
|
||||
if (!isRealClaudeCode) {
|
||||
toolNameMap = this._transformToolNamesInRequestBody(requestPayload, {
|
||||
useRandomizedToolNames: requestOptions.useRandomizedToolNames === true
|
||||
})
|
||||
}
|
||||
|
||||
// 序列化请求体,计算 content-length
|
||||
const bodyString = JSON.stringify(requestPayload)
|
||||
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||
@@ -1096,19 +1369,18 @@ class ClaudeRelayService {
|
||||
headers['User-Agent'] = userAgent
|
||||
headers['Accept'] = acceptHeader
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||
const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta')
|
||||
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||
return {
|
||||
requestPayload,
|
||||
bodyString,
|
||||
headers,
|
||||
isRealClaudeCode
|
||||
isRealClaudeCode,
|
||||
toolNameMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,7 +1446,8 @@ class ClaudeRelayService {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
const { bodyString, headers } = prepared
|
||||
let { bodyString } = prepared
|
||||
const { headers, isRealClaudeCode, toolNameMap } = prepared
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 支持自定义路径(如 count_tokens)
|
||||
@@ -1191,19 +1464,22 @@ class ClaudeRelayService {
|
||||
path: requestPath + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForNonStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = Buffer.alloc(0)
|
||||
// 使用数组收集 chunks,避免 O(n²) 的 Buffer.concat
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData = Buffer.concat([responseData, chunk])
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// 一次性合并所有 chunks
|
||||
const responseData = Buffer.concat(chunks)
|
||||
let responseBody = ''
|
||||
|
||||
// 根据Content-Encoding处理响应数据
|
||||
@@ -1226,6 +1502,10 @@ class ClaudeRelayService {
|
||||
responseBody = responseData.toString('utf8')
|
||||
}
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap)
|
||||
}
|
||||
|
||||
const response = {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
@@ -1284,6 +1564,8 @@ class ClaudeRelayService {
|
||||
|
||||
// 写入请求体
|
||||
req.write(bodyString)
|
||||
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||
bodyString = null
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -1465,7 +1747,12 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, account)
|
||||
// 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获
|
||||
const originalBodyString = JSON.stringify(processedBody)
|
||||
const bodyStoreId = ++this._bodyStoreIdCounter
|
||||
this.bodyStore.set(bodyStoreId, originalBodyString)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -1487,7 +1774,11 @@ class ClaudeRelayService {
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
bodyStoreId,
|
||||
isRealClaudeCodeRequest
|
||||
},
|
||||
isDedicatedOfficialAccount,
|
||||
// 📬 新增回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
@@ -1576,7 +1867,12 @@ class ClaudeRelayService {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
const { bodyString, headers } = prepared
|
||||
let { bodyString } = prepared
|
||||
const { headers, toolNameMap } = prepared
|
||||
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
|
||||
streamTransformer,
|
||||
toolNameMap
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl)
|
||||
@@ -1586,7 +1882,7 @@ class ClaudeRelayService {
|
||||
path: url.pathname + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
@@ -1684,8 +1980,22 @@ class ClaudeRelayService {
|
||||
|
||||
try {
|
||||
// 递归调用自身进行重试
|
||||
// 🧹 从 bodyStore 获取字符串用于重试
|
||||
if (
|
||||
!requestOptions.bodyStoreId ||
|
||||
!this.bodyStore.has(requestOptions.bodyStoreId)
|
||||
) {
|
||||
throw new Error('529 retry requires valid bodyStoreId')
|
||||
}
|
||||
let retryBody
|
||||
try {
|
||||
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
|
||||
throw new Error(`529 retry body parse failed: ${parseError.message}`)
|
||||
}
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
body,
|
||||
retryBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
@@ -1780,11 +2090,48 @@ class ClaudeRelayService {
|
||||
errorData += chunk.toString()
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
logger.error(
|
||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||
errorData
|
||||
)
|
||||
if (
|
||||
this._isClaudeCodeCredentialError(errorData) &&
|
||||
requestOptions.useRandomizedToolNames !== true &&
|
||||
requestOptions.bodyStoreId &&
|
||||
this.bodyStore.has(requestOptions.bodyStoreId)
|
||||
) {
|
||||
let retryBody
|
||||
try {
|
||||
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||
} catch (parseError) {
|
||||
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
|
||||
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
retryBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
usageCallback,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
{ ...requestOptions, useRandomizedToolNames: true },
|
||||
isDedicatedOfficialAccount,
|
||||
onResponseStart,
|
||||
retryCount
|
||||
)
|
||||
resolve(retryResult)
|
||||
} catch (retryError) {
|
||||
reject(retryError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
|
||||
;(async () => {
|
||||
try {
|
||||
@@ -1819,7 +2166,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||
if (streamTransformer) {
|
||||
if (toolNameStreamTransformer) {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
|
||||
)
|
||||
@@ -1858,6 +2205,11 @@ class ClaudeRelayService {
|
||||
let rateLimitDetected = false // 限流检测标志
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
|
||||
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
|
||||
const requestedModel = body?.model || 'unknown'
|
||||
const { isRealClaudeCodeRequest } = requestOptions
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
@@ -1873,8 +2225,8 @@ class ClaudeRelayService {
|
||||
if (isStreamWritable(responseStream)) {
|
||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
||||
// 如果有流转换器,应用转换
|
||||
if (streamTransformer) {
|
||||
const transformed = streamTransformer(linesToForward)
|
||||
if (toolNameStreamTransformer) {
|
||||
const transformed = toolNameStreamTransformer(linesToForward)
|
||||
if (transformed) {
|
||||
responseStream.write(transformed)
|
||||
}
|
||||
@@ -2007,8 +2359,8 @@ class ClaudeRelayService {
|
||||
try {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim() && isStreamWritable(responseStream)) {
|
||||
if (streamTransformer) {
|
||||
const transformed = streamTransformer(buffer)
|
||||
if (toolNameStreamTransformer) {
|
||||
const transformed = toolNameStreamTransformer(buffer)
|
||||
if (transformed) {
|
||||
responseStream.write(transformed)
|
||||
}
|
||||
@@ -2063,7 +2415,7 @@ class ClaudeRelayService {
|
||||
|
||||
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
||||
logger.info(
|
||||
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||
`📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||
)
|
||||
|
||||
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
||||
@@ -2073,7 +2425,7 @@ class ClaudeRelayService {
|
||||
output_tokens: totalUsage.output_tokens,
|
||||
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
||||
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
|
||||
model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型
|
||||
}
|
||||
|
||||
// 如果有详细的cache_creation数据,合并它们
|
||||
@@ -2182,15 +2534,15 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||
if (
|
||||
clientHeaders &&
|
||||
Object.keys(clientHeaders).length > 0 &&
|
||||
this.isRealClaudeCodeRequest(body)
|
||||
) {
|
||||
if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) {
|
||||
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
logger.debug('🌊 Claude stream response with usage capture completed')
|
||||
resolve()
|
||||
})
|
||||
@@ -2247,6 +2599,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
|
||||
@@ -2276,6 +2632,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
// 🧹 清理 bodyStore
|
||||
if (requestOptions.bodyStoreId) {
|
||||
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||
}
|
||||
reject(new Error('Request timeout'))
|
||||
})
|
||||
|
||||
@@ -2289,6 +2649,8 @@ class ClaudeRelayService {
|
||||
|
||||
// 写入请求体
|
||||
req.write(bodyString)
|
||||
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||
bodyString = null
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
const redis = require('../models/redis')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// HMGET 需要的字段
|
||||
const USAGE_FIELDS = [
|
||||
'totalInputTokens',
|
||||
'inputTokens',
|
||||
'totalOutputTokens',
|
||||
'outputTokens',
|
||||
'totalCacheCreateTokens',
|
||||
'cacheCreateTokens',
|
||||
'totalCacheReadTokens',
|
||||
'cacheReadTokens'
|
||||
]
|
||||
|
||||
class CostInitService {
|
||||
/**
|
||||
* 带并发限制的并行执行
|
||||
*/
|
||||
async parallelLimit(items, fn, concurrency = 20) {
|
||||
let index = 0
|
||||
const results = []
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
try {
|
||||
results[currentIndex] = await fn(items[currentIndex], currentIndex)
|
||||
} catch (error) {
|
||||
results[currentIndex] = { error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker))
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 SCAN 获取匹配的 keys(带去重)
|
||||
*/
|
||||
async scanKeysWithDedup(client, pattern, count = 500) {
|
||||
const seen = new Set()
|
||||
const allKeys = []
|
||||
let cursor = '0'
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
|
||||
cursor = newCursor
|
||||
|
||||
for (const key of keys) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
allKeys.push(key)
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return allKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有API Key的费用数据
|
||||
* 扫描历史使用记录并计算费用
|
||||
@@ -12,25 +68,57 @@ class CostInitService {
|
||||
try {
|
||||
logger.info('💰 Starting cost initialization for all API Keys...')
|
||||
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
// 用 scanApiKeyIds 获取 ID,然后过滤已删除的
|
||||
const allKeyIds = await redis.scanApiKeyIds()
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 批量检查 isDeleted 状态,过滤已删除的 key
|
||||
const FILTER_BATCH = 100
|
||||
const apiKeyIds = []
|
||||
|
||||
for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) {
|
||||
const batch = allKeyIds.slice(i, i + FILTER_BATCH)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const keyId of batch) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'isDeleted')
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, isDeleted] = results[j]
|
||||
if (!err && isDeleted !== 'true') {
|
||||
apiKeyIds.push(batch[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`
|
||||
)
|
||||
|
||||
let processedCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKey.id, client)
|
||||
processedCount++
|
||||
// 优化6: 并行处理 + 并发限制
|
||||
await this.parallelLimit(
|
||||
apiKeyIds,
|
||||
async (apiKeyId) => {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKeyId, client)
|
||||
processedCount++
|
||||
|
||||
if (processedCount % 10 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount} API Keys...`)
|
||||
if (processedCount % 100 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
20 // 并发数
|
||||
)
|
||||
|
||||
logger.success(
|
||||
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
|
||||
@@ -46,16 +134,55 @@ class CostInitService {
|
||||
* 初始化单个API Key的费用数据
|
||||
*/
|
||||
async initializeApiKeyCosts(apiKeyId, client) {
|
||||
// 获取所有时间的模型使用统计
|
||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`)
|
||||
// 优化4: 使用 SCAN 获取 keys(带去重)
|
||||
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`)
|
||||
|
||||
if (modelKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 优化5: 使用 Pipeline + HMGET 批量获取数据
|
||||
const BATCH_SIZE = 100
|
||||
const allData = []
|
||||
|
||||
for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) {
|
||||
const batch = modelKeys.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const key of batch) {
|
||||
pipeline.hmget(key, ...USAGE_FIELDS)
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, values] = results[j]
|
||||
if (err) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 将数组转换为对象
|
||||
const data = {}
|
||||
let hasData = false
|
||||
for (let k = 0; k < USAGE_FIELDS.length; k++) {
|
||||
if (values[k] !== null) {
|
||||
data[USAGE_FIELDS[k]] = values[k]
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasData) {
|
||||
allData.push({ key: batch[j], data })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期分组统计
|
||||
const dailyCosts = new Map() // date -> cost
|
||||
const monthlyCosts = new Map() // month -> cost
|
||||
const hourlyCosts = new Map() // hour -> cost
|
||||
const dailyCosts = new Map()
|
||||
const monthlyCosts = new Map()
|
||||
const hourlyCosts = new Map()
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
||||
for (const { key, data } of allData) {
|
||||
const match = key.match(
|
||||
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
|
||||
)
|
||||
@@ -65,13 +192,6 @@ class CostInitService {
|
||||
|
||||
const [, , period, model, dateStr] = match
|
||||
|
||||
// 获取使用数据
|
||||
const data = await client.hgetall(key)
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||
@@ -84,47 +204,34 @@ class CostInitService {
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
const cost = costResult.costs.total
|
||||
|
||||
// 根据period分组累加费用
|
||||
if (period === 'daily') {
|
||||
const currentCost = dailyCosts.get(dateStr) || 0
|
||||
dailyCosts.set(dateStr, currentCost + cost)
|
||||
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'monthly') {
|
||||
const currentCost = monthlyCosts.get(dateStr) || 0
|
||||
monthlyCosts.set(dateStr, currentCost + cost)
|
||||
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'hourly') {
|
||||
const currentCost = hourlyCosts.get(dateStr) || 0
|
||||
hourlyCosts.set(dateStr, currentCost + cost)
|
||||
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
|
||||
}
|
||||
}
|
||||
|
||||
// 将计算出的费用写入Redis
|
||||
const promises = []
|
||||
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 写入每日费用
|
||||
// 写入每日费用(只补缺失)
|
||||
for (const [date, cost] of dailyCosts) {
|
||||
const key = `usage:cost:daily:${apiKeyId}:${date}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 30) // 30天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
|
||||
}
|
||||
|
||||
// 写入每月费用
|
||||
// 写入每月费用(只补缺失)
|
||||
for (const [month, cost] of monthlyCosts) {
|
||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 90) // 90天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
|
||||
}
|
||||
|
||||
// 写入每小时费用
|
||||
// 写入每小时费用(只补缺失)
|
||||
for (const [hour, cost] of hourlyCosts) {
|
||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 7) // 7天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
|
||||
}
|
||||
|
||||
// 计算总费用
|
||||
@@ -133,37 +240,25 @@ class CostInitService {
|
||||
totalCost += cost
|
||||
}
|
||||
|
||||
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
|
||||
// 写入总费用(只补缺失)
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`
|
||||
// 先检查总费用是否已存在
|
||||
const existingTotal = await client.get(totalKey)
|
||||
|
||||
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
||||
// 仅在总费用不存在或为0时才初始化
|
||||
promises.push(client.set(totalKey, totalCost.toString()))
|
||||
pipeline.set(totalKey, totalCost.toString())
|
||||
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
||||
} else {
|
||||
// 如果总费用已存在,保持不变,避免覆盖累计值
|
||||
// 注意:这个逻辑防止因每日费用键过期(30天)导致的错误覆盖
|
||||
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
|
||||
const existing = parseFloat(existingTotal)
|
||||
const calculated = totalCost
|
||||
|
||||
if (calculated > existing * 1.1) {
|
||||
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
|
||||
if (totalCost > existing * 1.1) {
|
||||
logger.warn(
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
await pipeline.exec()
|
||||
|
||||
logger.debug(
|
||||
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
|
||||
@@ -172,41 +267,70 @@ class CostInitService {
|
||||
|
||||
/**
|
||||
* 检查是否需要初始化费用数据
|
||||
* 使用 SCAN 代替 KEYS,正确处理 cursor
|
||||
*/
|
||||
async needsInitialization() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 检查是否有任何费用数据
|
||||
const costKeys = await client.keys('usage:cost:*')
|
||||
// 正确循环 SCAN 检查是否有任何费用数据
|
||||
let cursor = '0'
|
||||
let hasCostData = false
|
||||
|
||||
// 如果没有费用数据,需要初始化
|
||||
if (costKeys.length === 0) {
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
hasCostData = true
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
if (!hasCostData) {
|
||||
logger.info('💰 No cost data found, initialization needed')
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否有使用数据但没有对应的费用数据
|
||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*')
|
||||
if (sampleKeys.length > 10) {
|
||||
// 抽样检查
|
||||
const sampleSize = Math.min(10, sampleKeys.length)
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
|
||||
// 抽样检查使用数据是否有对应的费用数据
|
||||
cursor = '0'
|
||||
let samplesChecked = 0
|
||||
const maxSamples = 10
|
||||
|
||||
do {
|
||||
const [newCursor, usageKeys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:*:model:daily:*:*',
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
for (const usageKey of usageKeys) {
|
||||
if (samplesChecked >= maxSamples) {
|
||||
break
|
||||
}
|
||||
|
||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, , date] = match
|
||||
const costKey = `usage:cost:daily:${keyId}:${date}`
|
||||
const hasCost = await client.exists(costKey)
|
||||
|
||||
if (!hasCost) {
|
||||
logger.info(
|
||||
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
|
||||
)
|
||||
return true
|
||||
}
|
||||
samplesChecked++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (samplesChecked >= maxSamples) {
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
logger.info('💰 Cost data appears to be up to date')
|
||||
return false
|
||||
|
||||
@@ -103,7 +103,7 @@ class CostRankService {
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
logger.success('✅ CostRankService initialized')
|
||||
logger.success('CostRankService initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize CostRankService:', error)
|
||||
throw error
|
||||
@@ -391,17 +391,32 @@ class CostRankService {
|
||||
return {}
|
||||
}
|
||||
|
||||
const status = {}
|
||||
|
||||
// 使用 Pipeline 批量获取
|
||||
const pipeline = client.pipeline()
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
status: meta.status || 'unknown',
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
pipeline.hgetall(RedisKeys.metaKey(timeRange))
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const status = {}
|
||||
VALID_TIME_RANGES.forEach((timeRange, i) => {
|
||||
const [err, meta] = results[i]
|
||||
if (err || !meta) {
|
||||
status[timeRange] = {
|
||||
lastUpdate: null,
|
||||
keyCount: 0,
|
||||
status: 'unknown',
|
||||
updateDuration: 0
|
||||
}
|
||||
} else {
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
status: meta.status || 'unknown',
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Droid 账户管理服务
|
||||
@@ -26,21 +25,14 @@ class DroidAccountService {
|
||||
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
||||
this.tokenValidHours = 8 // Token 有效期8小时
|
||||
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'droid-account-salt'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('droid-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -69,92 +61,19 @@ class DroidAccountService {
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
// 使用 commonHelper 的 isTruthy
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false
|
||||
}
|
||||
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
|
||||
}
|
||||
return Boolean(value)
|
||||
return isTruthy(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成加密密钥(缓存优化)
|
||||
*/
|
||||
_generateEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
logger.info('🔑 Droid encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感数据
|
||||
*/
|
||||
// 加密敏感数据
|
||||
_encryptSensitiveData(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
return this._encryptor.encrypt(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密敏感数据(带缓存)
|
||||
*/
|
||||
// 解密敏感数据(带缓存)
|
||||
_decryptSensitiveData(encryptedText) {
|
||||
if (!encryptedText) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const parts = encryptedText.split(':')
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to decrypt Droid data:', error)
|
||||
return ''
|
||||
}
|
||||
return this._encryptor.decrypt(encryptedText)
|
||||
}
|
||||
|
||||
_parseApiKeyEntries(rawEntries) {
|
||||
@@ -683,7 +602,7 @@ class DroidAccountService {
|
||||
|
||||
lastRefreshAt = new Date().toISOString()
|
||||
status = 'active'
|
||||
logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
||||
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
||||
@@ -1368,7 +1287,7 @@ class DroidAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Droid account token refreshed successfully: ${accountId}`)
|
||||
logger.success(`Droid account token refreshed successfully: ${accountId}`)
|
||||
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
|
||||
@@ -90,7 +90,7 @@ class DroidRelayService {
|
||||
return normalizedBody
|
||||
}
|
||||
|
||||
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') {
|
||||
async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) {
|
||||
if (!rateLimitInfo) {
|
||||
return
|
||||
}
|
||||
@@ -99,7 +99,9 @@ class DroidRelayService {
|
||||
const { totalTokens, totalCost } = await updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model
|
||||
model,
|
||||
keyId,
|
||||
'droid'
|
||||
)
|
||||
|
||||
if (totalTokens > 0) {
|
||||
@@ -403,6 +405,7 @@ class DroidRelayService {
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(apiUrl)
|
||||
const keyId = apiKeyData?.id
|
||||
const bodyString = JSON.stringify(processedBody)
|
||||
const contentLength = Buffer.byteLength(bodyString)
|
||||
const requestHeaders = {
|
||||
@@ -606,10 +609,11 @@ class DroidRelayService {
|
||||
clientRequest?.rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
' [stream]'
|
||||
' [stream]',
|
||||
keyId
|
||||
)
|
||||
|
||||
logger.success(`✅ Droid stream completed - Account: ${account.name}`)
|
||||
logger.success(`Droid stream completed - Account: ${account.name}`)
|
||||
} else {
|
||||
logger.success(
|
||||
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
|
||||
@@ -1195,6 +1199,7 @@ class DroidRelayService {
|
||||
skipUsageRecord = false
|
||||
) {
|
||||
const { data } = response
|
||||
const keyId = apiKeyData?.id
|
||||
|
||||
// 从响应中提取 usage 数据
|
||||
const usage = data.usage || {}
|
||||
@@ -1225,7 +1230,8 @@ class DroidRelayService {
|
||||
clientRequest?.rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
endpointLabel
|
||||
endpointLabel,
|
||||
keyId
|
||||
)
|
||||
|
||||
logger.success(
|
||||
|
||||
@@ -2,103 +2,40 @@ const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const {
|
||||
isTruthy,
|
||||
isAccountHealthy,
|
||||
sortAccountsByPriority,
|
||||
normalizeEndpointType
|
||||
} = require('../utils/commonHelper')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
this.STICKY_PREFIX = 'droid'
|
||||
}
|
||||
|
||||
_normalizeEndpointType(endpointType) {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
if (normalized === 'openai') {
|
||||
return 'openai'
|
||||
}
|
||||
if (normalized === 'comm') {
|
||||
return 'comm'
|
||||
}
|
||||
if (normalized === 'anthropic') {
|
||||
return 'anthropic'
|
||||
}
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
_isAccountActive(account) {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
const isActive = this._isTruthy(account.isActive)
|
||||
if (!isActive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||
return !unhealthyStatuses.has(status)
|
||||
}
|
||||
|
||||
_isAccountSchedulable(account) {
|
||||
return this._isTruthy(account?.schedulable ?? true)
|
||||
return isTruthy(account?.schedulable ?? true)
|
||||
}
|
||||
|
||||
_matchesEndpoint(account, endpointType) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
// comm 端点可以使用任何类型的账户
|
||||
if (normalizedEndpoint === 'comm') {
|
||||
return true
|
||||
}
|
||||
|
||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||
}
|
||||
|
||||
_sortCandidates(candidates) {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
}
|
||||
|
||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const apiKeyPart = apiKeyId || 'default'
|
||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||
}
|
||||
@@ -121,7 +58,7 @@ class DroidScheduler {
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,7 +82,7 @@ class DroidScheduler {
|
||||
}
|
||||
|
||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||
|
||||
let candidates = []
|
||||
@@ -175,7 +112,7 @@ class DroidScheduler {
|
||||
const filtered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
this._isAccountActive(account) &&
|
||||
isAccountHealthy(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
this._matchesEndpoint(account, normalizedEndpoint)
|
||||
)
|
||||
@@ -203,7 +140,7 @@ class DroidScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = this._sortCandidates(filtered)
|
||||
const sorted = sortAccountsByPriority(filtered)
|
||||
const selected = sorted[0]
|
||||
|
||||
if (!selected) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { OAuth2Client } = require('google-auth-library')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
@@ -15,9 +13,14 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||
|
||||
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
@@ -85,6 +88,10 @@ const keepAliveAgent = new https.Agent({
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('gemini-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
async function fetchAvailableModelsAntigravity(
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
@@ -196,91 +203,11 @@ async function countTokensAntigravity(client, contents, model, proxyConfig = nul
|
||||
return response
|
||||
}
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -605,6 +532,7 @@ async function createAccount(accountData) {
|
||||
// 保存到 Redis
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
|
||||
await redisClient.addToIndex('gemini_account:index', id)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -806,19 +734,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('gemini_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`gemini_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted Gemini account: ${accountId}`)
|
||||
@@ -827,12 +756,18 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const _client = redisClient.getClientSafe()
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'gemini_account:index',
|
||||
`${GEMINI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||
@@ -935,6 +870,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -994,6 +931,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
// 创建粘性会话映射
|
||||
if (sessionHash) {
|
||||
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
|
||||
await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1950,8 +1889,7 @@ module.exports = {
|
||||
setupUser,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
encryptor, // 暴露加密器以便测试和监控
|
||||
countTokens,
|
||||
countTokensAntigravity,
|
||||
generateContent,
|
||||
|
||||
@@ -85,7 +85,7 @@ class GeminiApiAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
|
||||
logger.success(`Created Gemini-API account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
const allAccountIds = await redis.getAllIdsByIndex(
|
||||
'gemini_api_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_api_account:(.+)$/
|
||||
)
|
||||
const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
for (let i = 0; i < allAccountIds.length; i++) {
|
||||
const accountId = allAccountIds[i]
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const accountData = dataList[i]
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null
|
||||
0, // cacheCreateTokens (Gemini 没有这个概念)
|
||||
0, // cacheReadTokens (Gemini 没有这个概念)
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Gemini usage:', error)
|
||||
@@ -317,7 +318,8 @@ async function sendGeminiRequest({
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
accountId
|
||||
accountId,
|
||||
'gemini'
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record Gemini usage:', error)
|
||||
|
||||
@@ -18,7 +18,7 @@ class ModelService {
|
||||
(sum, config) => sum + config.models.length,
|
||||
0
|
||||
)
|
||||
logger.success(`✅ Model service initialized with ${totalModels} models`)
|
||||
logger.success(`Model service initialized with ${totalModels} models`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const config = require('../../config/config')
|
||||
@@ -13,104 +12,23 @@ const {
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('openai-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
// OpenAI 账户键前缀
|
||||
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
||||
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
|
||||
if (text.length < 33 || text.charAt(32) !== ':') {
|
||||
logger.warn('Invalid encrypted text format, returning empty string', {
|
||||
textLength: text ? text.length : 0,
|
||||
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
|
||||
first50: text ? text.substring(0, 50) : 'N/A'
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`openai_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted OpenAI account: ${accountId}`)
|
||||
@@ -746,12 +666,18 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const _client = redisClient.getClientSafe()
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'openai:account:index',
|
||||
`${OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai:account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||
|
||||
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
// 反向索引:accountId -> sessionHash(用于删除账户时快速清理)
|
||||
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
selectedAccount.id
|
||||
)
|
||||
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1278,6 +1209,5 @@ module.exports = {
|
||||
updateCodexUsageSnapshot,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache // 暴露缓存对象以便测试和监控
|
||||
encryptor // 暴露加密器以便测试和监控
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -191,97 +194,68 @@ class OpenAIResponsesAccountService {
|
||||
// 获取所有账户
|
||||
async getAllAccounts(includeInactive = false) {
|
||||
const client = redis.getClientSafe()
|
||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
||||
|
||||
// 使用索引获取所有账户ID
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'openai_responses_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai_responses_account:(.+)$/
|
||||
)
|
||||
if (accountIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
// Pipeline 批量查询所有账户数据
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const accounts = []
|
||||
results.forEach(([err, accountData]) => {
|
||||
if (err || !accountData || !accountData.id) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (account) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || account.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
// 过滤非活跃账户
|
||||
if (!includeInactive && accountData.isActive !== 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: account.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
account.schedulable = account.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
account.expiresAt = account.subscriptionExpiresAt || null
|
||||
account.platform = account.platform || 'openai-responses'
|
||||
|
||||
accounts.push(account)
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换字段类型
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
})
|
||||
|
||||
return accounts
|
||||
}
|
||||
@@ -644,6 +618,9 @@ class OpenAIResponsesAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const config = require('../../config/config')
|
||||
const crypto = require('crypto')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
|
||||
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
|
||||
const LAST_USED_AT_THROTTLE_MS = 60000
|
||||
|
||||
// 抽取缓存写入 token,兼容多种字段命名
|
||||
function extractCacheCreationTokens(usageData) {
|
||||
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
|
||||
this.defaultTimeout = config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 节流更新 lastUsedAt
|
||||
async _throttledUpdateLastUsedAt(accountId) {
|
||||
const now = Date.now()
|
||||
const lastUpdate = lastUsedAtThrottle.get(accountId)
|
||||
|
||||
if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) {
|
||||
return // 跳过更新
|
||||
}
|
||||
|
||||
lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS)
|
||||
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 处理请求转发
|
||||
async handleRequest(req, res, account, apiKeyData) {
|
||||
let abortController = null
|
||||
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
|
||||
return res.status(response.status).json(errorData)
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
// 更新最后使用时间(节流)
|
||||
await this._throttledUpdateLastUsedAt(account.id)
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
||||
@@ -539,7 +557,8 @@ class OpenAIResponsesRelayService {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
account.id
|
||||
account.id,
|
||||
'openai-responses'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -667,7 +686,8 @@ class OpenAIResponsesRelayService {
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
account.id
|
||||
account.id,
|
||||
'openai-responses'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -105,7 +105,7 @@ class PricingService {
|
||||
// 设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
|
||||
logger.success('💰 Pricing service initialized successfully')
|
||||
logger.success('Pricing service initialized successfully')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize pricing service:', error)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ class PricingService {
|
||||
this.pricingData = jsonData
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
|
||||
// 设置或重新设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
@@ -762,7 +762,7 @@ class PricingService {
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`)
|
||||
logger.success(`Reloaded pricing data for ${modelCount} models from file`)
|
||||
|
||||
// 显示一些统计信息
|
||||
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
||||
|
||||
698
src/services/quotaCardService.js
Normal file
698
src/services/quotaCardService.js
Normal file
@@ -0,0 +1,698 @@
|
||||
/**
|
||||
* 额度卡/时间卡服务
|
||||
* 管理员生成卡,用户核销,管理员可撤销
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class QuotaCardService {
|
||||
constructor() {
|
||||
this.CARD_PREFIX = 'quota_card:'
|
||||
this.REDEMPTION_PREFIX = 'redemption:'
|
||||
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
|
||||
this.LIMITS_CONFIG_KEY = 'system:quota_card_limits'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取额度卡上限配置
|
||||
*/
|
||||
async getLimitsConfig() {
|
||||
try {
|
||||
const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY)
|
||||
if (configStr) {
|
||||
return JSON.parse(configStr)
|
||||
}
|
||||
// 没有 Redis 配置时,使用 config.js 默认值
|
||||
const config = require('../../config/config')
|
||||
return (
|
||||
config.quotaCardLimits || {
|
||||
enabled: true,
|
||||
maxExpiryDays: 90,
|
||||
maxTotalCostLimit: 1000
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get limits config:', error)
|
||||
return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存额度卡上限配置
|
||||
*/
|
||||
async saveLimitsConfig(config) {
|
||||
try {
|
||||
const parsedDays = parseInt(config.maxExpiryDays)
|
||||
const parsedCost = parseFloat(config.maxTotalCostLimit)
|
||||
const newConfig = {
|
||||
enabled: config.enabled !== false,
|
||||
maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays,
|
||||
maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig))
|
||||
logger.info('✅ Quota card limits config saved')
|
||||
return newConfig
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to save limits config:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX)
|
||||
*/
|
||||
_generateCardCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
|
||||
let code = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
code += chars.charAt(crypto.randomInt(chars.length))
|
||||
}
|
||||
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建额度卡/时间卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
|
||||
* @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填)
|
||||
* @param {number} options.timeAmount - 时间数量(time/combo 类型必填)
|
||||
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
|
||||
* @param {string} options.expiresAt - 卡本身的有效期(可选)
|
||||
* @param {string} options.note - 备注
|
||||
* @param {string} options.createdBy - 创建者 ID
|
||||
* @returns {Object} 创建的卡信息
|
||||
*/
|
||||
async createCard(options = {}) {
|
||||
try {
|
||||
const {
|
||||
type = 'quota',
|
||||
quotaAmount = 0,
|
||||
timeAmount = 0,
|
||||
timeUnit = 'days',
|
||||
expiresAt = null,
|
||||
note = '',
|
||||
createdBy = 'admin'
|
||||
} = options
|
||||
|
||||
// 验证
|
||||
if (!['quota', 'time', 'combo'].includes(type)) {
|
||||
throw new Error('Invalid card type')
|
||||
}
|
||||
|
||||
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
|
||||
throw new Error('quotaAmount is required for quota/combo cards')
|
||||
}
|
||||
|
||||
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
|
||||
throw new Error('timeAmount is required for time/combo cards')
|
||||
}
|
||||
|
||||
const cardId = uuidv4()
|
||||
const cardCode = this._generateCardCode()
|
||||
|
||||
const cardData = {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: String(quotaAmount || 0),
|
||||
timeAmount: String(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
status: 'unused', // unused | redeemed | revoked | expired
|
||||
createdBy,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: expiresAt || '',
|
||||
note: note || '',
|
||||
// 核销信息
|
||||
redeemedBy: '',
|
||||
redeemedByUsername: '',
|
||||
redeemedApiKeyId: '',
|
||||
redeemedApiKeyName: '',
|
||||
redeemedAt: '',
|
||||
// 撤销信息
|
||||
revokedAt: '',
|
||||
revokedBy: '',
|
||||
revokeReason: ''
|
||||
}
|
||||
|
||||
// 保存卡数据
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
|
||||
|
||||
// 建立卡号到 ID 的映射(用于快速查找)
|
||||
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
|
||||
|
||||
// 添加到卡列表索引
|
||||
await redis.client.sadd('quota_cards:all', cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
|
||||
|
||||
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
|
||||
|
||||
return {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit,
|
||||
status: 'unused',
|
||||
createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {number} count - 创建数量
|
||||
* @returns {Array} 创建的卡列表
|
||||
*/
|
||||
async createCardsBatch(options = {}, count = 1) {
|
||||
const cards = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = await this.createCard(options)
|
||||
cards.push(card)
|
||||
}
|
||||
logger.success(`🎫 Batch created ${count} cards`)
|
||||
return cards
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过卡号获取卡信息
|
||||
*/
|
||||
async getCardByCode(code) {
|
||||
try {
|
||||
const cardId = await redis.client.get(`quota_card_code:${code}`)
|
||||
if (!cardId) {
|
||||
return null
|
||||
}
|
||||
return await this.getCardById(cardId)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card by code:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取卡信息
|
||||
*/
|
||||
async getCardById(cardId) {
|
||||
try {
|
||||
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
|
||||
if (!cardData || Object.keys(cardData).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: cardData.id,
|
||||
code: cardData.code,
|
||||
type: cardData.type,
|
||||
quotaAmount: parseFloat(cardData.quotaAmount || 0),
|
||||
timeAmount: parseInt(cardData.timeAmount || 0),
|
||||
timeUnit: cardData.timeUnit,
|
||||
status: cardData.status,
|
||||
createdBy: cardData.createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note: cardData.note,
|
||||
redeemedBy: cardData.redeemedBy,
|
||||
redeemedByUsername: cardData.redeemedByUsername,
|
||||
redeemedApiKeyId: cardData.redeemedApiKeyId,
|
||||
redeemedApiKeyName: cardData.redeemedApiKeyName,
|
||||
redeemedAt: cardData.redeemedAt,
|
||||
revokedAt: cardData.revokedAt,
|
||||
revokedBy: cardData.revokedBy,
|
||||
revokeReason: cardData.revokeReason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有卡列表
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.status - 按状态筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getAllCards(options = {}) {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = options
|
||||
|
||||
let cardIds
|
||||
if (status) {
|
||||
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
|
||||
} else {
|
||||
cardIds = await redis.client.smembers('quota_cards:all')
|
||||
}
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
const cards = []
|
||||
for (const cardId of cardIds) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (card) {
|
||||
cards.push(card)
|
||||
}
|
||||
}
|
||||
|
||||
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
// 分页
|
||||
const total = cards.length
|
||||
const paginatedCards = cards.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
cards: paginatedCards,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get all cards:', error)
|
||||
return { cards: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销卡
|
||||
* @param {string} code - 卡号
|
||||
* @param {string} apiKeyId - 目标 API Key ID
|
||||
* @param {string} userId - 核销用户 ID
|
||||
* @param {string} username - 核销用户名
|
||||
* @returns {Object} 核销结果
|
||||
*/
|
||||
async redeemCard(code, apiKeyId, userId, username = '') {
|
||||
try {
|
||||
// 获取卡信息
|
||||
const card = await this.getCardByCode(code)
|
||||
if (!card) {
|
||||
throw new Error('卡号不存在')
|
||||
}
|
||||
|
||||
// 检查卡状态
|
||||
if (card.status !== 'unused') {
|
||||
const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' }
|
||||
throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`)
|
||||
}
|
||||
|
||||
// 检查卡是否过期
|
||||
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
|
||||
// 更新卡状态为过期
|
||||
await this._updateCardStatus(card.id, 'expired')
|
||||
throw new Error('卡片已过期')
|
||||
}
|
||||
|
||||
// 获取 API Key 信息
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API Key 不存在')
|
||||
}
|
||||
|
||||
// 获取上限配置
|
||||
const limits = await this.getLimitsConfig()
|
||||
|
||||
// 执行核销
|
||||
const redemptionId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 记录核销前状态
|
||||
const beforeLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||
const beforeExpiry = keyData.expiresAt || ''
|
||||
|
||||
// 应用卡效果
|
||||
let afterLimit = beforeLimit
|
||||
let afterExpiry = beforeExpiry
|
||||
let quotaAdded = 0
|
||||
let timeAdded = 0
|
||||
let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days)
|
||||
const warnings = [] // 截断警告信息
|
||||
|
||||
if (card.type === 'quota' || card.type === 'combo') {
|
||||
let amountToAdd = card.quotaAmount
|
||||
|
||||
// 上限保护:检查是否超过最大额度限制
|
||||
if (limits.enabled && limits.maxTotalCostLimit > 0) {
|
||||
const maxAllowed = limits.maxTotalCostLimit - beforeLimit
|
||||
if (amountToAdd > maxAllowed) {
|
||||
amountToAdd = Math.max(0, maxAllowed)
|
||||
warnings.push(
|
||||
`额度已达上限,本次仅增加 ${amountToAdd} CC(原卡面 ${card.quotaAmount} CC)`
|
||||
)
|
||||
logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (amountToAdd > 0) {
|
||||
const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd)
|
||||
afterLimit = result.newTotalCostLimit
|
||||
quotaAdded = amountToAdd
|
||||
}
|
||||
}
|
||||
|
||||
if (card.type === 'time' || card.type === 'combo') {
|
||||
// 计算新的过期时间
|
||||
let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date()
|
||||
if (baseDate < new Date()) {
|
||||
baseDate = new Date()
|
||||
}
|
||||
|
||||
let newExpiry = new Date(baseDate)
|
||||
switch (card.timeUnit) {
|
||||
case 'hours':
|
||||
newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000)
|
||||
break
|
||||
case 'days':
|
||||
newExpiry.setDate(newExpiry.getDate() + card.timeAmount)
|
||||
break
|
||||
case 'months':
|
||||
newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount)
|
||||
break
|
||||
}
|
||||
|
||||
// 上限保护:检查是否超过最大有效期
|
||||
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||
const maxExpiry = new Date()
|
||||
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||
if (newExpiry > maxExpiry) {
|
||||
newExpiry = maxExpiry
|
||||
warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`)
|
||||
logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
|
||||
// 如果有上限保护,使用截断后的时间
|
||||
if (limits.enabled && limits.maxExpiryDays > 0) {
|
||||
const maxExpiry = new Date()
|
||||
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
|
||||
if (new Date(result.newExpiresAt) > maxExpiry) {
|
||||
await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString())
|
||||
afterExpiry = maxExpiry.toISOString()
|
||||
// 计算实际增加的天数,截断时统一用天
|
||||
const actualDays = Math.max(
|
||||
0,
|
||||
Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
timeAdded = actualDays
|
||||
actualTimeUnit = 'days'
|
||||
} else {
|
||||
afterExpiry = result.newExpiresAt
|
||||
timeAdded = card.timeAmount
|
||||
}
|
||||
} else {
|
||||
afterExpiry = result.newExpiresAt
|
||||
timeAdded = card.timeAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
|
||||
status: 'redeemed',
|
||||
redeemedBy: userId,
|
||||
redeemedByUsername: username,
|
||||
redeemedApiKeyId: apiKeyId,
|
||||
redeemedApiKeyName: keyData.name || '',
|
||||
redeemedAt: now
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:unused`, card.id)
|
||||
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
|
||||
|
||||
// 创建核销记录
|
||||
const redemptionData = {
|
||||
id: redemptionId,
|
||||
cardId: card.id,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
userId,
|
||||
username,
|
||||
apiKeyId,
|
||||
apiKeyName: keyData.name || '',
|
||||
quotaAdded: String(quotaAdded),
|
||||
timeAdded: String(timeAdded),
|
||||
timeUnit: actualTimeUnit,
|
||||
beforeLimit: String(beforeLimit),
|
||||
afterLimit: String(afterLimit),
|
||||
beforeExpiry,
|
||||
afterExpiry,
|
||||
timestamp: now,
|
||||
status: 'active' // active | revoked
|
||||
}
|
||||
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
|
||||
|
||||
// 添加到核销记录索引
|
||||
await redis.client.sadd('redemptions:all', redemptionId)
|
||||
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
|
||||
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
|
||||
|
||||
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
warnings,
|
||||
redemptionId,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
quotaAdded,
|
||||
timeAdded,
|
||||
timeUnit: actualTimeUnit,
|
||||
beforeLimit,
|
||||
afterLimit,
|
||||
beforeExpiry,
|
||||
afterExpiry
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to redeem card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销核销
|
||||
* @param {string} redemptionId - 核销记录 ID
|
||||
* @param {string} revokedBy - 撤销者 ID
|
||||
* @param {string} reason - 撤销原因
|
||||
* @returns {Object} 撤销结果
|
||||
*/
|
||||
async revokeRedemption(redemptionId, revokedBy, reason = '') {
|
||||
try {
|
||||
// 获取核销记录
|
||||
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
|
||||
if (!redemptionData || Object.keys(redemptionData).length === 0) {
|
||||
throw new Error('Redemption record not found')
|
||||
}
|
||||
|
||||
if (redemptionData.status !== 'active') {
|
||||
throw new Error('Redemption is already revoked')
|
||||
}
|
||||
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 撤销效果
|
||||
let actualDeducted = 0
|
||||
if (parseFloat(redemptionData.quotaAdded) > 0) {
|
||||
const result = await apiKeyService.deductTotalCostLimit(
|
||||
redemptionData.apiKeyId,
|
||||
parseFloat(redemptionData.quotaAdded)
|
||||
)
|
||||
;({ actualDeducted } = result)
|
||||
}
|
||||
|
||||
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
|
||||
// 如果需要回退时间,可以在这里添加逻辑
|
||||
|
||||
// 更新核销记录状态
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason,
|
||||
actualDeducted: String(actualDeducted)
|
||||
})
|
||||
|
||||
// 更新卡状态
|
||||
const { cardId } = redemptionData
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
|
||||
|
||||
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redemptionId,
|
||||
cardCode: redemptionData.cardCode,
|
||||
actualDeducted,
|
||||
reason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取核销记录
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.userId - 按用户筛选
|
||||
* @param {string} options.apiKeyId - 按 API Key 筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getRedemptions(options = {}) {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = options
|
||||
|
||||
let redemptionIds
|
||||
if (userId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
|
||||
} else if (apiKeyId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
|
||||
} else {
|
||||
redemptionIds = await redis.client.smembers('redemptions:all')
|
||||
}
|
||||
|
||||
const redemptions = []
|
||||
for (const id of redemptionIds) {
|
||||
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
redemptions.push({
|
||||
id: data.id,
|
||||
cardId: data.cardId,
|
||||
cardCode: data.cardCode,
|
||||
cardType: data.cardType,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
apiKeyId: data.apiKeyId,
|
||||
apiKeyName: data.apiKeyName,
|
||||
quotaAdded: parseFloat(data.quotaAdded || 0),
|
||||
timeAdded: parseInt(data.timeAdded || 0),
|
||||
timeUnit: data.timeUnit,
|
||||
beforeLimit: parseFloat(data.beforeLimit || 0),
|
||||
afterLimit: parseFloat(data.afterLimit || 0),
|
||||
beforeExpiry: data.beforeExpiry,
|
||||
afterExpiry: data.afterExpiry,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status,
|
||||
revokedAt: data.revokedAt,
|
||||
revokedBy: data.revokedBy,
|
||||
revokeReason: data.revokeReason,
|
||||
actualDeducted: parseFloat(data.actualDeducted || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 排序(按时间倒序)
|
||||
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
|
||||
// 分页
|
||||
const total = redemptions.length
|
||||
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
redemptions: paginatedRedemptions,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
return { redemptions: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未使用的卡
|
||||
*/
|
||||
async deleteCard(cardId) {
|
||||
try {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
throw new Error('Card not found')
|
||||
}
|
||||
|
||||
if (card.status !== 'unused') {
|
||||
throw new Error('Only unused cards can be deleted')
|
||||
}
|
||||
|
||||
// 删除卡数据
|
||||
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
|
||||
await redis.client.del(`quota_card_code:${card.code}`)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.client.srem('quota_cards:all', cardId)
|
||||
await redis.client.srem(`quota_cards:status:unused`, cardId)
|
||||
|
||||
logger.success(`🗑️ Deleted card ${card.code}`)
|
||||
|
||||
return { success: true, cardCode: card.code }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新卡状态(内部方法)
|
||||
*/
|
||||
async _updateCardStatus(cardId, newStatus) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldStatus = card.status
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡统计信息
|
||||
*/
|
||||
async getCardStats() {
|
||||
try {
|
||||
const [unused, redeemed, revoked, expired] = await Promise.all([
|
||||
redis.client.scard('quota_cards:status:unused'),
|
||||
redis.client.scard('quota_cards:status:redeemed'),
|
||||
redis.client.scard('quota_cards:status:revoked'),
|
||||
redis.client.scard('quota_cards:status:expired')
|
||||
])
|
||||
|
||||
return {
|
||||
total: unused + redeemed + revoked + expired,
|
||||
unused,
|
||||
redeemed,
|
||||
revoked,
|
||||
expired
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card stats:', error)
|
||||
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new QuotaCardService()
|
||||
@@ -360,7 +360,10 @@ class RateLimitCleanupService {
|
||||
|
||||
/**
|
||||
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||
* 仅对等待重置(schedulable=false)且 Token 即将过期的账户执行刷新
|
||||
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||
* 不处理错误状态账户(error/temp_error)
|
||||
*/
|
||||
async proactiveRefreshClaudeTokens(result) {
|
||||
try {
|
||||
@@ -381,9 +384,13 @@ class RateLimitCleanupService {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 【优化】仅处理等待重置的账户(schedulable=false)
|
||||
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||
if (account.schedulable !== 'false') {
|
||||
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||
const isWaitingForReset =
|
||||
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||
if (!isWaitingForReset) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
259
src/services/serviceRatesService.js
Normal file
259
src/services/serviceRatesService.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 服务倍率配置服务
|
||||
* 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0)
|
||||
* 用于聚合 Key 的虚拟额度计算
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ServiceRatesService {
|
||||
constructor() {
|
||||
this.CONFIG_KEY = 'system:service_rates'
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
this.CACHE_TTL = 60 * 1000 // 1分钟缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认倍率配置
|
||||
*/
|
||||
getDefaultRates() {
|
||||
return {
|
||||
baseService: 'claude',
|
||||
rates: {
|
||||
claude: 1.0, // 基准:1 USD = 1 CC额度
|
||||
codex: 1.0,
|
||||
gemini: 1.0,
|
||||
droid: 1.0,
|
||||
bedrock: 1.0,
|
||||
azure: 1.0,
|
||||
ccr: 1.0
|
||||
},
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取倍率配置(带缓存)
|
||||
*/
|
||||
async getRates() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (this.cachedRates && Date.now() < this.cacheExpiry) {
|
||||
return this.cachedRates
|
||||
}
|
||||
|
||||
const configStr = await redis.client.get(this.CONFIG_KEY)
|
||||
if (!configStr) {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
this.cachedRates = defaultRates
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return defaultRates
|
||||
}
|
||||
|
||||
const storedConfig = JSON.parse(configStr)
|
||||
// 合并默认值,确保新增服务有默认倍率
|
||||
const defaultRates = this.getDefaultRates()
|
||||
storedConfig.rates = {
|
||||
...defaultRates.rates,
|
||||
...storedConfig.rates
|
||||
}
|
||||
|
||||
this.cachedRates = storedConfig
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return storedConfig
|
||||
} catch (error) {
|
||||
logger.error('获取服务倍率配置失败:', error)
|
||||
return this.getDefaultRates()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存倍率配置
|
||||
*/
|
||||
async saveRates(config, updatedBy = 'admin') {
|
||||
try {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
|
||||
// 验证配置
|
||||
this.validateRates(config)
|
||||
|
||||
const newConfig = {
|
||||
baseService: config.baseService || defaultRates.baseService,
|
||||
rates: {
|
||||
...defaultRates.rates,
|
||||
...config.rates
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig))
|
||||
|
||||
// 清除缓存
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
|
||||
logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`)
|
||||
return newConfig
|
||||
} catch (error) {
|
||||
logger.error('保存服务倍率配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证倍率配置
|
||||
*/
|
||||
validateRates(config) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('无效的配置格式')
|
||||
}
|
||||
|
||||
if (config.rates) {
|
||||
for (const [service, rate] of Object.entries(config.rates)) {
|
||||
if (typeof rate !== 'number' || rate <= 0) {
|
||||
throw new Error(`服务 ${service} 的倍率必须是正数`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务的倍率
|
||||
*/
|
||||
async getServiceRate(service) {
|
||||
const config = await this.getRates()
|
||||
return config.rates[service] || 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算消费的 CC 额度
|
||||
* @param {number} costUSD - 真实成本(USD)
|
||||
* @param {string} service - 服务类型
|
||||
* @returns {number} CC 额度消耗
|
||||
*/
|
||||
async calculateQuotaConsumption(costUSD, service) {
|
||||
const rate = await this.getServiceRate(service)
|
||||
return costUSD * rate
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型名称获取服务类型
|
||||
*/
|
||||
getServiceFromModel(model) {
|
||||
if (!model) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
const modelLower = model.toLowerCase()
|
||||
|
||||
// Claude 系列
|
||||
if (
|
||||
modelLower.includes('claude') ||
|
||||
modelLower.includes('anthropic') ||
|
||||
modelLower.includes('opus') ||
|
||||
modelLower.includes('sonnet') ||
|
||||
modelLower.includes('haiku')
|
||||
) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// OpenAI / Codex 系列
|
||||
if (
|
||||
modelLower.includes('gpt') ||
|
||||
modelLower.includes('o1') ||
|
||||
modelLower.includes('o3') ||
|
||||
modelLower.includes('o4') ||
|
||||
modelLower.includes('codex') ||
|
||||
modelLower.includes('davinci') ||
|
||||
modelLower.includes('curie') ||
|
||||
modelLower.includes('babbage') ||
|
||||
modelLower.includes('ada')
|
||||
) {
|
||||
return 'codex'
|
||||
}
|
||||
|
||||
// Gemini 系列
|
||||
if (
|
||||
modelLower.includes('gemini') ||
|
||||
modelLower.includes('palm') ||
|
||||
modelLower.includes('bard')
|
||||
) {
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
// Droid 系列
|
||||
if (modelLower.includes('droid') || modelLower.includes('factory')) {
|
||||
return 'droid'
|
||||
}
|
||||
|
||||
// Bedrock 系列(通常带有 aws 或特定前缀)
|
||||
if (
|
||||
modelLower.includes('bedrock') ||
|
||||
modelLower.includes('amazon') ||
|
||||
modelLower.includes('titan')
|
||||
) {
|
||||
return 'bedrock'
|
||||
}
|
||||
|
||||
// Azure 系列
|
||||
if (modelLower.includes('azure')) {
|
||||
return 'azure'
|
||||
}
|
||||
|
||||
// 默认返回 claude
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户类型获取服务类型(优先级高于模型推断)
|
||||
*/
|
||||
getServiceFromAccountType(accountType) {
|
||||
if (!accountType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapping = {
|
||||
claude: 'claude',
|
||||
'claude-official': 'claude',
|
||||
'claude-console': 'claude',
|
||||
ccr: 'ccr',
|
||||
bedrock: 'bedrock',
|
||||
gemini: 'gemini',
|
||||
'openai-responses': 'codex',
|
||||
openai: 'codex',
|
||||
azure: 'azure',
|
||||
'azure-openai': 'azure',
|
||||
droid: 'droid'
|
||||
}
|
||||
|
||||
return mapping[accountType] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务类型(优先 accountType,后备 model)
|
||||
*/
|
||||
getService(accountType, model) {
|
||||
return this.getServiceFromAccountType(accountType) || this.getServiceFromModel(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的服务列表
|
||||
*/
|
||||
async getAvailableServices() {
|
||||
const config = await this.getRates()
|
||||
return Object.keys(config.rates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ServiceRatesService()
|
||||
@@ -6,6 +6,7 @@ const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -38,16 +39,6 @@ class UnifiedClaudeScheduler {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否支持请求的模型
|
||||
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
|
||||
if (!requestedModel) {
|
||||
@@ -286,7 +277,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
@@ -319,7 +310,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -354,7 +345,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -436,7 +427,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -496,7 +487,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
|
||||
)
|
||||
@@ -530,7 +521,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 主动触发一次额度检查
|
||||
try {
|
||||
@@ -579,7 +570,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
||||
@@ -609,7 +600,7 @@ class UnifiedClaudeScheduler {
|
||||
account.status !== 'blocked' &&
|
||||
account.status !== 'temp_error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -691,7 +682,7 @@ class UnifiedClaudeScheduler {
|
||||
currentAccount.isActive === true &&
|
||||
currentAccount.status === 'active' &&
|
||||
currentAccount.accountType === 'shared' &&
|
||||
this._isSchedulable(currentAccount.schedulable)
|
||||
isSchedulable(currentAccount.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -826,7 +817,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -870,7 +861,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
@@ -949,21 +940,6 @@ class UnifiedClaudeScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType, requestedModel = null) {
|
||||
try {
|
||||
@@ -978,7 +954,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1029,7 +1005,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1093,7 +1069,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(accountResult.data.schedulable)) {
|
||||
if (!isSchedulable(accountResult.data.schedulable)) {
|
||||
logger.info(`🚫 Bedrock account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1113,7 +1089,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1544,7 +1520,7 @@ class UnifiedClaudeScheduler {
|
||||
? account.status === 'active'
|
||||
: account.status === 'active'
|
||||
|
||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||
if (isActive && status && isSchedulable(account.schedulable)) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
|
||||
continue
|
||||
@@ -1594,7 +1570,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -1664,7 +1640,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 3. 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableCcrAccounts)
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 4. 建立会话映射
|
||||
@@ -1710,7 +1686,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const geminiApiAccountService = require('./geminiApiAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
@@ -44,9 +45,9 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
|
||||
_isActive(isActive) {
|
||||
_isActive(activeValue) {
|
||||
// 兼容布尔值 true 和字符串 'true'
|
||||
return isActive === true || isActive === 'true'
|
||||
return activeValue === true || activeValue === 'true'
|
||||
}
|
||||
|
||||
// 🎯 统一调度Gemini账号
|
||||
@@ -66,11 +67,7 @@ class UnifiedGeminiScheduler {
|
||||
if (apiKeyData.geminiAccountId.startsWith('api:')) {
|
||||
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
|
||||
logger.info(
|
||||
`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -183,7 +180,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -243,11 +240,7 @@ class UnifiedGeminiScheduler {
|
||||
if (apiKeyData.geminiAccountId.startsWith('api:')) {
|
||||
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
|
||||
const isRateLimited = await this.isAccountRateLimited(accountId)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持
|
||||
@@ -349,10 +342,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiAccounts = await geminiAccountService.getAllAccounts()
|
||||
for (const account of geminiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
@@ -405,10 +398,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiApiAccounts = await geminiApiAccountService.getAllAccounts()
|
||||
for (const account of geminiApiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
@@ -445,42 +438,27 @@ class UnifiedGeminiScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'gemini') {
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await this.isAccountRateLimited(accountId))
|
||||
} else if (accountType === 'gemini-api') {
|
||||
const account = await geminiApiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -738,9 +716,9 @@ class UnifiedGeminiScheduler {
|
||||
|
||||
// 检查账户是否可用
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 对于 Gemini OAuth 账户,检查 token 是否过期
|
||||
if (accountType === 'gemini') {
|
||||
@@ -787,7 +765,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -3,42 +3,13 @@ const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
class UnifiedOpenAIScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
const aPriority = Number.parseInt(a.priority, 10)
|
||||
const bPriority = Number.parseInt(b.priority, 10)
|
||||
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
|
||||
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
|
||||
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (normalizedAPriority !== normalizedBPriority) {
|
||||
return normalizedAPriority - normalizedBPriority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式)
|
||||
_isRateLimited(rateLimitStatus) {
|
||||
if (!rateLimitStatus) {
|
||||
@@ -85,9 +56,9 @@ class UnifiedOpenAIScheduler {
|
||||
let rateLimitChecked = false
|
||||
let stillLimited = false
|
||||
|
||||
let isSchedulable = this._isSchedulable(account.schedulable)
|
||||
const accountSchedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!isSchedulable) {
|
||||
if (!accountSchedulable) {
|
||||
if (!hasRateLimitFlag) {
|
||||
return { canUse: false, reason: 'not_schedulable' }
|
||||
}
|
||||
@@ -104,7 +75,6 @@ class UnifiedOpenAIScheduler {
|
||||
} else {
|
||||
account.schedulable = 'true'
|
||||
}
|
||||
isSchedulable = true
|
||||
logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`)
|
||||
}
|
||||
|
||||
@@ -224,7 +194,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
@@ -336,7 +306,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -451,11 +421,12 @@ class UnifiedOpenAIScheduler {
|
||||
if (
|
||||
(account.isActive === true || account.isActive === 'true') &&
|
||||
account.status !== 'error' &&
|
||||
account.status !== 'rateLimited' &&
|
||||
(account.accountType === 'shared' || !account.accountType)
|
||||
) {
|
||||
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus)
|
||||
const schedulable = this._isSchedulable(account.schedulable)
|
||||
// 检查 rateLimitStatus 或 status === 'rateLimited'
|
||||
const hasRateLimitFlag =
|
||||
this._hasRateLimitFlag(account.rateLimitStatus) || account.status === 'rateLimited'
|
||||
const schedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!schedulable && !hasRateLimitFlag) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`)
|
||||
@@ -464,9 +435,23 @@ class UnifiedOpenAIScheduler {
|
||||
|
||||
let isRateLimitCleared = false
|
||||
if (hasRateLimitFlag) {
|
||||
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
// 区分正常限流和历史遗留数据
|
||||
if (this._hasRateLimitFlag(account.rateLimitStatus)) {
|
||||
// 有 rateLimitStatus,走正常清理逻辑
|
||||
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
} else {
|
||||
// 只有 status=rateLimited 但没有 rateLimitStatus,是历史遗留数据,直接清除
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
status: 'active',
|
||||
schedulable: 'true'
|
||||
})
|
||||
isRateLimitCleared = true
|
||||
logger.info(
|
||||
`✅ OpenAI-Responses账号 ${account.name} 清除历史遗留限流状态(status=rateLimited 但无 rateLimitStatus)`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRateLimitCleared) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
|
||||
@@ -544,7 +529,7 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -905,7 +890,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -10,6 +10,7 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { getCachedConfig, setCachedConfig } = require('../utils/performanceOptimizer')
|
||||
|
||||
// 清理任务间隔
|
||||
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||
@@ -19,6 +20,9 @@ const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||
|
||||
// 配置缓存 key
|
||||
const CONFIG_CACHE_KEY = 'user_message_queue_config'
|
||||
|
||||
class UserMessageQueueService {
|
||||
constructor() {
|
||||
this.cleanupTimer = null
|
||||
@@ -64,18 +68,23 @@ class UserMessageQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置(支持 Web 界面配置优先)
|
||||
* 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
// 检查缓存
|
||||
const cached = getCachedConfig(CONFIG_CACHE_KEY)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||
const queueConfig = config.userMessageQueue || {}
|
||||
const defaults = {
|
||||
enabled: queueConfig.enabled ?? false,
|
||||
delayMs: queueConfig.delayMs ?? 200,
|
||||
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||
timeoutMs: queueConfig.timeoutMs ?? 60000,
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 120000
|
||||
}
|
||||
|
||||
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||
@@ -83,7 +92,7 @@ class UserMessageQueueService {
|
||||
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||
const webConfig = await claudeRelayConfigService.getConfig()
|
||||
|
||||
return {
|
||||
const result = {
|
||||
enabled:
|
||||
webConfig.userMessageQueueEnabled !== undefined
|
||||
? webConfig.userMessageQueueEnabled
|
||||
@@ -101,8 +110,13 @@ class UserMessageQueueService {
|
||||
? webConfig.userMessageQueueLockTtlMs
|
||||
: defaults.lockTtlMs
|
||||
}
|
||||
|
||||
// 缓存配置 30 秒
|
||||
setCachedConfig(CONFIG_CACHE_KEY, result, 30000)
|
||||
return result
|
||||
} catch {
|
||||
// 回退到环境变量配置
|
||||
// 回退到环境变量配置,也缓存
|
||||
setCachedConfig(CONFIG_CACHE_KEY, defaults, 30000)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserService {
|
||||
// 保存用户信息
|
||||
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
||||
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
||||
await redis.addToIndex('user:index', user.id)
|
||||
|
||||
// 如果是新用户,尝试转移匹配的API Keys
|
||||
if (isNewUser) {
|
||||
@@ -167,8 +168,8 @@ class UserService {
|
||||
`📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys`
|
||||
)
|
||||
|
||||
// Count only non-deleted API keys for the user's active count
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
|
||||
// Count only non-deleted API keys for the user's active count(布尔值比较)
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => !key.isDeleted).length
|
||||
|
||||
return {
|
||||
totalUsage,
|
||||
@@ -191,14 +192,18 @@ class UserService {
|
||||
// 📋 获取所有用户列表(管理员功能)
|
||||
async getAllUsers(options = {}) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const { page = 1, limit = 20, role, isActive } = options
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const users = []
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
|
||||
@@ -398,14 +403,15 @@ class UserService {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userSessionPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const keys = await redis.scanKeys(pattern)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const sessionData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const sessionData = dataList[i]
|
||||
if (sessionData) {
|
||||
const session = JSON.parse(sessionData)
|
||||
if (session.userId === userId) {
|
||||
await client.del(key)
|
||||
await client.del(keys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,9 +460,13 @@ class UserService {
|
||||
// 📊 获取用户统计信息
|
||||
async getUserStats() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const stats = {
|
||||
totalUsers: 0,
|
||||
@@ -472,8 +482,8 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
stats.totalUsers++
|
||||
@@ -522,7 +532,7 @@ class UserService {
|
||||
const { displayName, username, email } = user
|
||||
|
||||
// 获取所有API Keys
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找到没有用户ID的API Keys(即由Admin创建的)
|
||||
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
|
||||
|
||||
283
src/services/weeklyClaudeCostInitService.js
Normal file
283
src/services/weeklyClaudeCostInitService.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const pricingService = require('./pricingService')
|
||||
const serviceRatesService = require('./serviceRatesService')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// 生成配置时区下的 YYYY-MM-DD 字符串。
|
||||
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
|
||||
function formatTzDateYmd(tzDate) {
|
||||
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
|
||||
}
|
||||
|
||||
class WeeklyClaudeCostInitService {
|
||||
_getCurrentWeekDatesInTimezone() {
|
||||
const tzNow = redis.getDateInTimezone(new Date())
|
||||
const tzToday = new Date(tzNow)
|
||||
tzToday.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// ISO 周:周一=1 ... 周日=7
|
||||
const isoDay = tzToday.getUTCDay() || 7
|
||||
const tzMonday = new Date(tzToday)
|
||||
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
|
||||
|
||||
const dates = []
|
||||
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
dates.push(formatTzDateYmd(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
_buildWeeklyOpusKey(keyId, weekString) {
|
||||
return `usage:opus:weekly:${keyId}:${weekString}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动回填:把"本周(周一到今天)Claude 全模型"周费用从按日/按模型统计里反算出来,
|
||||
* 写入 `usage:opus:weekly:*`,保证周限额在重启后不归零。
|
||||
*
|
||||
* 说明:
|
||||
* - 只回填本周,不做历史回填(符合"只要本周数据"诉求)
|
||||
* - 会加分布式锁,避免多实例重复跑
|
||||
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key)
|
||||
*/
|
||||
async backfillCurrentWeekClaudeCosts() {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用')
|
||||
return { success: false, reason: 'redis_unavailable' }
|
||||
}
|
||||
|
||||
if (!pricingService || !pricingService.pricingData) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化')
|
||||
return { success: false, reason: 'pricing_uninitialized' }
|
||||
}
|
||||
|
||||
const weekString = redis.getWeekStringInTimezone()
|
||||
const doneKey = `init:weekly_opus_cost:${weekString}:done`
|
||||
|
||||
try {
|
||||
const alreadyDone = await client.get(doneKey)
|
||||
if (alreadyDone) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
} catch (e) {
|
||||
// 尽力而为:读取失败不阻断启动回填流程。
|
||||
}
|
||||
|
||||
const lockKey = `lock:init:weekly_opus_cost:${weekString}`
|
||||
const lockValue = `${process.pid}:${Date.now()}`
|
||||
const lockTtlMs = 15 * 60 * 1000
|
||||
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
|
||||
if (!lockAcquired) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
|
||||
return { success: true, skipped: true, reason: 'locked' }
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
|
||||
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
const dates = this._getCurrentWeekDatesInTimezone()
|
||||
|
||||
// 预加载所有 API Key 数据和全局倍率(避免循环内重复查询)
|
||||
const keyDataCache = new Map()
|
||||
const globalRateCache = new Map()
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
pipeline.hgetall(`apikey:${keyId}`)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const [, data] = results[j] || []
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
keyDataCache.set(batch[j], data)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`💰 预加载 ${keyDataCache.size} 个 API Key 数据`)
|
||||
|
||||
// 推断账户类型的辅助函数(与运行时 recordOpusCost 一致,只统计 claude-official/claude-console/ccr)
|
||||
const OPUS_ACCOUNT_TYPES = ['claude-official', 'claude-console', 'ccr']
|
||||
const inferAccountType = (keyData) => {
|
||||
if (keyData?.ccrAccountId) {
|
||||
return 'ccr'
|
||||
}
|
||||
if (keyData?.claudeConsoleAccountId) {
|
||||
return 'claude-console'
|
||||
}
|
||||
if (keyData?.claudeAccountId) {
|
||||
return 'claude-official'
|
||||
}
|
||||
// bedrock/azure/gemini 等不计入周费用
|
||||
return null
|
||||
}
|
||||
|
||||
const costByKeyId = new Map()
|
||||
let scannedKeys = 0
|
||||
let matchedClaudeKeys = 0
|
||||
|
||||
const toInt = (v) => {
|
||||
const n = parseInt(v || '0', 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。
|
||||
for (const dateStr of dates) {
|
||||
let cursor = '0'
|
||||
const pattern = `usage:*:model:daily:*:${dateStr}`
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
|
||||
cursor = nextCursor
|
||||
scannedKeys += keys.length
|
||||
|
||||
const entries = []
|
||||
for (const usageKey of keys) {
|
||||
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
|
||||
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const keyId = match[1]
|
||||
const model = match[2]
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
continue
|
||||
}
|
||||
matchedClaudeKeys++
|
||||
entries.push({ usageKey, keyId, model })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const entry of entries) {
|
||||
pipeline.hgetall(entry.usageKey)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
|
||||
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
|
||||
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
|
||||
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
|
||||
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
|
||||
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
|
||||
|
||||
const cacheCreationTotal =
|
||||
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
|
||||
? ephemeral5mTokens + ephemeral1hTokens
|
||||
: cacheCreateTokens
|
||||
|
||||
const usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreationTotal,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usage.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
const costInfo = pricingService.calculateCost(usage, entry.model)
|
||||
const realCost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
|
||||
if (realCost <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 应用倍率:全局倍率 × Key 倍率(使用缓存数据)
|
||||
const keyData = keyDataCache.get(entry.keyId)
|
||||
const accountType = inferAccountType(keyData)
|
||||
|
||||
// 与运行时 recordOpusCost 一致:只统计 claude-official/claude-console/ccr 账户
|
||||
if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const service = serviceRatesService.getService(accountType, entry.model)
|
||||
|
||||
// 获取全局倍率(带缓存)
|
||||
let globalRate = globalRateCache.get(service)
|
||||
if (globalRate === undefined) {
|
||||
globalRate = await serviceRatesService.getServiceRate(service)
|
||||
globalRateCache.set(service, globalRate)
|
||||
}
|
||||
|
||||
// 获取 Key 倍率
|
||||
let keyRates = {}
|
||||
try {
|
||||
keyRates = JSON.parse(keyData?.serviceRates || '{}')
|
||||
} catch (e) {
|
||||
keyRates = {}
|
||||
}
|
||||
const keyRate = keyRates[service] ?? 1.0
|
||||
const ratedCost = realCost * globalRate * keyRate
|
||||
|
||||
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + ratedCost)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
}
|
||||
|
||||
// 为所有 API Key 写入本周 opus:weekly key
|
||||
const ttlSeconds = 14 * 24 * 3600
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
const weeklyKey = this._buildWeeklyOpusKey(keyId, weekString)
|
||||
const cost = costByKeyId.get(keyId) || 0
|
||||
pipeline.set(weeklyKey, String(cost))
|
||||
pipeline.expire(weeklyKey, ttlSeconds)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
|
||||
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
logger.info(
|
||||
`✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
weekString,
|
||||
keyCount: keyIds.length,
|
||||
scannedKeys,
|
||||
matchedClaudeKeys,
|
||||
filledKeys: costByKeyId.size,
|
||||
durationMs
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeeklyClaudeCostInitService()
|
||||
408
src/utils/commonHelper.js
Normal file
408
src/utils/commonHelper.js
Normal file
@@ -0,0 +1,408 @@
|
||||
// 通用工具函数集合
|
||||
// 抽取自各服务的重复代码,统一管理
|
||||
|
||||
const crypto = require('crypto')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('./lruCache')
|
||||
|
||||
// ============================================
|
||||
// 加密相关 - 工厂模式支持不同 salt
|
||||
// ============================================
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 缓存不同 salt 的加密实例
|
||||
const _encryptorCache = new Map()
|
||||
|
||||
// 创建加密器实例(每个 salt 独立缓存)
|
||||
const createEncryptor = (salt) => {
|
||||
if (_encryptorCache.has(salt)) {
|
||||
return _encryptorCache.get(salt)
|
||||
}
|
||||
|
||||
let keyCache = null
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
const getKey = () => {
|
||||
if (!keyCache) {
|
||||
keyCache = crypto.scryptSync(config.security.encryptionKey, salt, 32)
|
||||
}
|
||||
return keyCache
|
||||
}
|
||||
|
||||
const encrypt = (text) => {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = getKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
}
|
||||
|
||||
const decrypt = (text, useCache = true) => {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (!text.includes(':')) {
|
||||
return text
|
||||
}
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
if (useCache) {
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
try {
|
||||
const key = getKey()
|
||||
const [ivHex, encrypted] = text.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
if (useCache) {
|
||||
decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
}
|
||||
return decrypted
|
||||
} catch (e) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
const instance = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
getKey,
|
||||
clearCache: () => decryptCache.clear(),
|
||||
getStats: () => decryptCache.getStats?.() || { size: decryptCache.size }
|
||||
}
|
||||
|
||||
_encryptorCache.set(salt, instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 默认加密器(向后兼容)
|
||||
const defaultEncryptor = createEncryptor('claude-relay-salt')
|
||||
const { encrypt } = defaultEncryptor
|
||||
const { decrypt } = defaultEncryptor
|
||||
const getEncryptionKey = defaultEncryptor.getKey
|
||||
const clearDecryptCache = defaultEncryptor.clearCache
|
||||
const getDecryptCacheStats = defaultEncryptor.getStats
|
||||
|
||||
// ============================================
|
||||
// 布尔值处理
|
||||
// ============================================
|
||||
|
||||
// 转换为布尔值(宽松模式)
|
||||
const toBoolean = (value) =>
|
||||
value === true ||
|
||||
value === 'true' ||
|
||||
(typeof value === 'string' && value.toLowerCase() === 'true')
|
||||
|
||||
// 检查是否为真值(null/undefined 返回 false)
|
||||
const isTruthy = (value) => value !== null && value !== undefined && toBoolean(value)
|
||||
|
||||
// 检查是否可调度(默认 true,只有明确 false 才返回 false)
|
||||
const isSchedulable = (value) => value !== false && value !== 'false'
|
||||
|
||||
// 检查是否激活
|
||||
const isActive = (value) => value === true || value === 'true'
|
||||
|
||||
// 检查账户是否健康(激活且状态正常)
|
||||
const isAccountHealthy = (account) => {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
if (!isTruthy(account.isActive)) {
|
||||
return false
|
||||
}
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
return !['error', 'unauthorized', 'blocked', 'temp_error'].includes(status)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// JSON 处理
|
||||
// ============================================
|
||||
|
||||
// 安全解析 JSON
|
||||
const safeParseJson = (value, fallback = null) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 安全解析 JSON 为对象
|
||||
const safeParseJsonObject = (value, fallback = null) => {
|
||||
const parsed = safeParseJson(value, fallback)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
// 安全解析 JSON 为数组
|
||||
const safeParseJsonArray = (value, fallback = []) => {
|
||||
const parsed = safeParseJson(value, fallback)
|
||||
return Array.isArray(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 模型名称处理
|
||||
// ============================================
|
||||
|
||||
// 规范化模型名称(用于统计聚合)
|
||||
const normalizeModelName = (model) => {
|
||||
if (!model || model === 'unknown') {
|
||||
return model
|
||||
}
|
||||
// Bedrock 模型: us-east-1.anthropic.claude-3-5-sonnet-v1:0
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
return model
|
||||
.replace(/^[a-z0-9-]+\./, '')
|
||||
.replace('anthropic.', '')
|
||||
.replace(/-v\d+:\d+$/, '')
|
||||
}
|
||||
return model.replace(/-v\d+:\d+$|:latest$/, '')
|
||||
}
|
||||
|
||||
// 规范化端点类型
|
||||
const normalizeEndpointType = (endpointType) => {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
return ['openai', 'comm', 'anthropic'].includes(normalized) ? normalized : 'anthropic'
|
||||
}
|
||||
|
||||
// 检查模型是否在映射表中
|
||||
const isModelInMapping = (modelMapping, requestedModel) => {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
const lower = requestedModel.toLowerCase()
|
||||
return Object.keys(modelMapping).some((k) => k.toLowerCase() === lower)
|
||||
}
|
||||
|
||||
// 获取映射后的模型名称
|
||||
const getMappedModelName = (modelMapping, requestedModel) => {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return requestedModel
|
||||
}
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
const lower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 账户调度相关
|
||||
// ============================================
|
||||
|
||||
// 按优先级和最后使用时间排序账户
|
||||
const sortAccountsByPriority = (accounts) =>
|
||||
[...accounts].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
|
||||
// 生成粘性会话 Key
|
||||
const composeStickySessionKey = (prefix, sessionHash, apiKeyId = null) => {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
return `sticky:${prefix}:${apiKeyId || 'default'}:${sessionHash}`
|
||||
}
|
||||
|
||||
// 过滤可用账户(激活 + 健康 + 可调度)
|
||||
const filterAvailableAccounts = (accounts) =>
|
||||
accounts.filter((acc) => acc && isAccountHealthy(acc) && isSchedulable(acc.schedulable))
|
||||
|
||||
// ============================================
|
||||
// 字符串处理
|
||||
// ============================================
|
||||
|
||||
// 截断字符串
|
||||
const truncate = (str, maxLen = 100, suffix = '...') => {
|
||||
if (!str || str.length <= maxLen) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLen - suffix.length) + suffix
|
||||
}
|
||||
|
||||
// 掩码敏感信息(保留前后几位)
|
||||
const maskSensitive = (str, keepStart = 4, keepEnd = 4, maskChar = '*') => {
|
||||
if (!str || str.length <= keepStart + keepEnd) {
|
||||
return str
|
||||
}
|
||||
const maskLen = Math.min(str.length - keepStart - keepEnd, 8)
|
||||
return str.slice(0, keepStart) + maskChar.repeat(maskLen) + str.slice(-keepEnd)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 数值处理
|
||||
// ============================================
|
||||
|
||||
// 安全解析整数
|
||||
const safeParseInt = (value, fallback = 0) => {
|
||||
const parsed = parseInt(value, 10)
|
||||
return isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
|
||||
// 安全解析浮点数
|
||||
const safeParseFloat = (value, fallback = 0) => {
|
||||
const parsed = parseFloat(value)
|
||||
return isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
|
||||
// 限制数值范围
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
// ============================================
|
||||
// 时间处理
|
||||
// ============================================
|
||||
|
||||
// 获取时区偏移后的日期
|
||||
const getDateInTimezone = (date = new Date(), offset = config.system?.timezoneOffset || 8) =>
|
||||
new Date(date.getTime() + offset * 3600000)
|
||||
|
||||
// 获取时区日期字符串 YYYY-MM-DD
|
||||
const getDateStringInTimezone = (date = new Date()) => {
|
||||
const d = getDateInTimezone(date)
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
const isExpired = (expiresAt) => {
|
||||
if (!expiresAt) {
|
||||
return false
|
||||
}
|
||||
return new Date(expiresAt).getTime() < Date.now()
|
||||
}
|
||||
|
||||
// 计算剩余时间(秒)
|
||||
const getTimeRemaining = (expiresAt) => {
|
||||
if (!expiresAt) {
|
||||
return Infinity
|
||||
}
|
||||
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 版本处理
|
||||
// ============================================
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 获取应用版本号
|
||||
const getAppVersion = () => {
|
||||
if (process.env.APP_VERSION) {
|
||||
return process.env.APP_VERSION
|
||||
}
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION
|
||||
}
|
||||
try {
|
||||
const versionFile = path.join(__dirname, '..', '..', 'VERSION')
|
||||
if (fs.existsSync(versionFile)) {
|
||||
return fs.readFileSync(versionFile, 'utf8').trim()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
return require('../../package.json').version
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return '1.0.0'
|
||||
}
|
||||
|
||||
// 版本比较: a > b
|
||||
const versionGt = (a, b) => {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) {
|
||||
return true
|
||||
}
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 版本比较: a >= b
|
||||
const versionGte = (a, b) => a === b || versionGt(a, b)
|
||||
|
||||
module.exports = {
|
||||
// 加密
|
||||
createEncryptor,
|
||||
encrypt,
|
||||
decrypt,
|
||||
getEncryptionKey,
|
||||
clearDecryptCache,
|
||||
getDecryptCacheStats,
|
||||
// 布尔值
|
||||
toBoolean,
|
||||
isTruthy,
|
||||
isSchedulable,
|
||||
isActive,
|
||||
isAccountHealthy,
|
||||
// JSON
|
||||
safeParseJson,
|
||||
safeParseJsonObject,
|
||||
safeParseJsonArray,
|
||||
// 模型
|
||||
normalizeModelName,
|
||||
normalizeEndpointType,
|
||||
isModelInMapping,
|
||||
getMappedModelName,
|
||||
// 调度
|
||||
sortAccountsByPriority,
|
||||
composeStickySessionKey,
|
||||
filterAvailableAccounts,
|
||||
// 字符串
|
||||
truncate,
|
||||
maskSensitive,
|
||||
// 数值
|
||||
safeParseInt,
|
||||
safeParseFloat,
|
||||
clamp,
|
||||
// 时间
|
||||
getDateInTimezone,
|
||||
getDateStringInTimezone,
|
||||
isExpired,
|
||||
getTimeRemaining,
|
||||
// 版本
|
||||
getAppVersion,
|
||||
versionGt,
|
||||
versionGte
|
||||
}
|
||||
@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
|
||||
title: 'Claude Code Compact System Prompt Agent SDK2',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
||||
},
|
||||
claudeOtherSystemPrompt5: {
|
||||
category: 'system',
|
||||
title: 'Claude CLI Billing Header',
|
||||
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
|
||||
},
|
||||
claudeOtherSystemPromptCompact: {
|
||||
category: 'system',
|
||||
title: 'Claude Code Compact System Prompt',
|
||||
|
||||
@@ -1,217 +1,260 @@
|
||||
/**
|
||||
* 错误消息清理工具
|
||||
* 用于移除上游错误中的供应商特定信息(如 URL、引用等)
|
||||
* 错误消息清理工具 - 白名单错误码制
|
||||
* 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端
|
||||
*/
|
||||
|
||||
const logger = require('./logger')
|
||||
|
||||
// 标准错误码定义
|
||||
const ERROR_CODES = {
|
||||
E001: { message: 'Service temporarily unavailable', status: 503 },
|
||||
E002: { message: 'Network connection failed', status: 502 },
|
||||
E003: { message: 'Authentication failed', status: 401 },
|
||||
E004: { message: 'Rate limit exceeded', status: 429 },
|
||||
E005: { message: 'Invalid request', status: 400 },
|
||||
E006: { message: 'Model not available', status: 503 },
|
||||
E007: { message: 'Upstream service error', status: 502 },
|
||||
E008: { message: 'Request timeout', status: 504 },
|
||||
E009: { message: 'Permission denied', status: 403 },
|
||||
E010: { message: 'Resource not found', status: 404 },
|
||||
E011: { message: 'Account temporarily unavailable', status: 503 },
|
||||
E012: { message: 'Server overloaded', status: 529 },
|
||||
E013: { message: 'Invalid API key', status: 401 },
|
||||
E014: { message: 'Quota exceeded', status: 429 },
|
||||
E015: { message: 'Internal server error', status: 500 }
|
||||
}
|
||||
|
||||
// 错误特征匹配规则(按优先级排序)
|
||||
const ERROR_MATCHERS = [
|
||||
// 网络层错误
|
||||
{ pattern: /ENOTFOUND|DNS|getaddrinfo/i, code: 'E002' },
|
||||
{ pattern: /ECONNREFUSED|ECONNRESET|connection refused/i, code: 'E002' },
|
||||
{ pattern: /ETIMEDOUT|timeout/i, code: 'E008' },
|
||||
{ pattern: /ECONNABORTED|aborted/i, code: 'E002' },
|
||||
|
||||
// 认证错误
|
||||
{ pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' },
|
||||
{ pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' },
|
||||
{ pattern: /authentication|auth.*fail/i, code: 'E003' },
|
||||
|
||||
// 权限错误
|
||||
{ pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' },
|
||||
{ pattern: /does not have.*permission/i, code: 'E009' },
|
||||
|
||||
// 限流错误
|
||||
{ pattern: /rate.*limit|too many requests|429/i, code: 'E004' },
|
||||
{ pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' },
|
||||
|
||||
// 过载错误
|
||||
{ pattern: /overloaded|529|capacity/i, code: 'E012' },
|
||||
|
||||
// 账户错误
|
||||
{ pattern: /account.*disabled|organization.*disabled/i, code: 'E011' },
|
||||
{ pattern: /too many active sessions/i, code: 'E011' },
|
||||
|
||||
// 模型错误
|
||||
{ pattern: /model.*not.*found|model.*unavailable|unsupported.*model/i, code: 'E006' },
|
||||
|
||||
// 请求错误
|
||||
{ pattern: /bad.*request|invalid.*request|malformed/i, code: 'E005' },
|
||||
{ pattern: /not.*found|404/i, code: 'E010' },
|
||||
|
||||
// 上游错误
|
||||
{ pattern: /upstream|502|bad.*gateway/i, code: 'E007' },
|
||||
{ pattern: /503|service.*unavailable/i, code: 'E001' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 清理错误消息中的 URL 和供应商引用
|
||||
* @param {string} message - 原始错误消息
|
||||
* @returns {string} - 清理后的消息
|
||||
* 根据原始错误匹配标准错误码
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @param {string} options.context - 错误上下文(用于日志)
|
||||
* @param {boolean} options.logOriginal - 是否记录原始错误(默认true)
|
||||
* @returns {{ code: string, message: string, status: number }}
|
||||
*/
|
||||
function sanitizeErrorMessage(message) {
|
||||
if (typeof message !== 'string') {
|
||||
return message
|
||||
function mapToErrorCode(error, options = {}) {
|
||||
const { context = 'unknown', logOriginal = true } = options
|
||||
|
||||
// 提取原始错误信息
|
||||
const originalMessage = extractOriginalMessage(error)
|
||||
const errorCode = error?.code || error?.response?.status
|
||||
const statusCode = error?.response?.status || error?.status || error?.statusCode
|
||||
|
||||
// 记录原始错误到日志(供调试)
|
||||
if (logOriginal && originalMessage) {
|
||||
logger.debug(`[ErrorSanitizer] Original error (${context}):`, {
|
||||
message: originalMessage,
|
||||
code: errorCode,
|
||||
status: statusCode
|
||||
})
|
||||
}
|
||||
|
||||
// 移除 URL(http:// 或 https://)
|
||||
let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '')
|
||||
// 匹配错误码
|
||||
let matchedCode = 'E015' // 默认:内部服务器错误
|
||||
|
||||
// 移除常见的供应商引用模式
|
||||
cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '')
|
||||
cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx"
|
||||
cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx"
|
||||
cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息
|
||||
|
||||
// 移除供应商特定关键词(包括整个单词)
|
||||
cleaned = cleaned.replace(/88code\S*/gi, '')
|
||||
cleaned = cleaned.replace(/duck\S*/gi, '')
|
||||
cleaned = cleaned.replace(/packy\S*/gi, '')
|
||||
cleaned = cleaned.replace(/ikun\S*/gi, '')
|
||||
cleaned = cleaned.replace(/privnode\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yescode\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yes.vg\S*/gi, '')
|
||||
cleaned = cleaned.replace(/share\S*/gi, '')
|
||||
cleaned = cleaned.replace(/yhlxj\S*/gi, '')
|
||||
cleaned = cleaned.replace(/gac\S*/gi, '')
|
||||
cleaned = cleaned.replace(/driod\S*/gi, '')
|
||||
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// 如果消息被清理得太短或为空,返回通用消息
|
||||
if (cleaned.length < 5) {
|
||||
return 'The requested model is currently unavailable'
|
||||
// 先按 HTTP 状态码快速匹配
|
||||
if (statusCode) {
|
||||
if (statusCode === 401) {
|
||||
matchedCode = 'E003'
|
||||
} else if (statusCode === 403) {
|
||||
matchedCode = 'E009'
|
||||
} else if (statusCode === 404) {
|
||||
matchedCode = 'E010'
|
||||
} else if (statusCode === 429) {
|
||||
matchedCode = 'E004'
|
||||
} else if (statusCode === 502) {
|
||||
matchedCode = 'E007'
|
||||
} else if (statusCode === 503) {
|
||||
matchedCode = 'E001'
|
||||
} else if (statusCode === 504) {
|
||||
matchedCode = 'E008'
|
||||
} else if (statusCode === 529) {
|
||||
matchedCode = 'E012'
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
// 再按消息内容精确匹配(可能覆盖状态码匹配)
|
||||
if (originalMessage) {
|
||||
for (const matcher of ERROR_MATCHERS) {
|
||||
if (matcher.pattern.test(originalMessage)) {
|
||||
matchedCode = matcher.code
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按错误 code 匹配(网络错误)
|
||||
if (errorCode) {
|
||||
const codeStr = String(errorCode).toUpperCase()
|
||||
if (codeStr === 'ENOTFOUND' || codeStr === 'EAI_AGAIN') {
|
||||
matchedCode = 'E002'
|
||||
} else if (codeStr === 'ECONNREFUSED' || codeStr === 'ECONNRESET') {
|
||||
matchedCode = 'E002'
|
||||
} else if (codeStr === 'ETIMEDOUT' || codeStr === 'ESOCKETTIMEDOUT') {
|
||||
matchedCode = 'E008'
|
||||
} else if (codeStr === 'ECONNABORTED') {
|
||||
matchedCode = 'E002'
|
||||
}
|
||||
}
|
||||
|
||||
const result = ERROR_CODES[matchedCode]
|
||||
return {
|
||||
code: matchedCode,
|
||||
message: result.message,
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归清理对象中的所有错误消息字段
|
||||
* @param {Object} errorData - 原始错误数据对象
|
||||
* @returns {Object} - 清理后的错误数据
|
||||
* 提取原始错误消息
|
||||
*/
|
||||
function sanitizeUpstreamError(errorData) {
|
||||
if (!errorData || typeof errorData !== 'object') {
|
||||
return errorData
|
||||
}
|
||||
|
||||
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||
const looksLikeAxiosError =
|
||||
errorData.isAxiosError ||
|
||||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
|
||||
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
|
||||
|
||||
if (looksLikeAxiosError || looksLikeError) {
|
||||
const statusCode = errorData.response?.status
|
||||
const upstreamBody = errorData.response?.data
|
||||
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
|
||||
|
||||
return {
|
||||
name: errorData.name || 'Error',
|
||||
code: errorData.code,
|
||||
statusCode,
|
||||
message: sanitizeErrorMessage(errorData.message || ''),
|
||||
upstreamMessage: upstreamMessage || undefined,
|
||||
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 递归清理嵌套的错误对象
|
||||
const visited = new WeakSet()
|
||||
|
||||
const shouldRedactKey = (key) => {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const lowerKey = String(key).toLowerCase()
|
||||
return (
|
||||
lowerKey === 'authorization' ||
|
||||
lowerKey === 'cookie' ||
|
||||
lowerKey.includes('api_key') ||
|
||||
lowerKey.includes('apikey') ||
|
||||
lowerKey.includes('access_token') ||
|
||||
lowerKey.includes('refresh_token') ||
|
||||
lowerKey.endsWith('token') ||
|
||||
lowerKey.includes('secret') ||
|
||||
lowerKey.includes('password')
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizeObject = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (visited.has(obj)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(obj)
|
||||
|
||||
// 主动剔除常见“超大且敏感”的字段
|
||||
if (obj.config || obj.request || obj.response) {
|
||||
return '[Redacted]'
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (shouldRedactKey(key)) {
|
||||
obj[key] = '[REDACTED]'
|
||||
continue
|
||||
}
|
||||
|
||||
// 清理所有字符串字段,不仅仅是 message
|
||||
if (typeof obj[key] === 'string') {
|
||||
obj[key] = sanitizeErrorMessage(obj[key])
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
sanitizeObject(obj[key])
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// 尽量不修改原对象:浅拷贝后递归清理
|
||||
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||
return sanitizeObject(clone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取错误消息(支持多种错误格式)
|
||||
* @param {*} body - 错误响应体(字符串或对象)
|
||||
* @returns {string} - 提取的错误消息
|
||||
*/
|
||||
function extractErrorMessage(body) {
|
||||
if (!body) {
|
||||
function extractOriginalMessage(error) {
|
||||
if (!error) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 处理字符串类型
|
||||
if (typeof body === 'string') {
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return extractErrorMessage(parsed)
|
||||
} catch (error) {
|
||||
return trimmed
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
// 处理对象类型
|
||||
if (typeof body === 'object') {
|
||||
// 常见错误格式: { error: "message" }
|
||||
if (typeof body.error === 'string') {
|
||||
return body.error
|
||||
}
|
||||
// 嵌套错误格式: { error: { message: "..." } }
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
if (typeof body.error.message === 'string') {
|
||||
return body.error.message
|
||||
}
|
||||
if (typeof body.error.error === 'string') {
|
||||
return body.error.error
|
||||
}
|
||||
}
|
||||
// 直接消息格式: { message: "..." }
|
||||
if (typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
if (error.response?.data?.error?.message) {
|
||||
return error.response.data.error.message
|
||||
}
|
||||
if (error.response?.data?.error) {
|
||||
return String(error.response.data.error)
|
||||
}
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为账户被禁用或不可用的 400 错误
|
||||
* @param {number} statusCode - HTTP 状态码
|
||||
* @param {*} body - 响应体
|
||||
* @returns {boolean} - 是否为账户禁用错误
|
||||
* 创建安全的错误响应对象
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {{ error: { code: string, message: string }, status: number }}
|
||||
*/
|
||||
function createSafeErrorResponse(error, options = {}) {
|
||||
const mapped = mapToErrorCode(error, options)
|
||||
return {
|
||||
error: {
|
||||
code: mapped.code,
|
||||
message: mapped.message
|
||||
},
|
||||
status: mapped.status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全的 SSE 错误事件
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {string} - SSE 格式的错误事件
|
||||
*/
|
||||
function createSafeSSEError(error, options = {}) {
|
||||
const mapped = mapToErrorCode(error, options)
|
||||
return `event: error\ndata: ${JSON.stringify({
|
||||
error: mapped.message,
|
||||
code: mapped.code,
|
||||
timestamp: new Date().toISOString()
|
||||
})}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全的错误消息(用于替换 error.message)
|
||||
* @param {Error|string|object} error - 原始错误
|
||||
* @param {object} options - 选项
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSafeMessage(error, options = {}) {
|
||||
return mapToErrorCode(error, options).message
|
||||
}
|
||||
|
||||
// 兼容旧接口
|
||||
function sanitizeErrorMessage(message) {
|
||||
if (!message) {
|
||||
return 'Service temporarily unavailable'
|
||||
}
|
||||
return mapToErrorCode({ message }, { logOriginal: false }).message
|
||||
}
|
||||
|
||||
function sanitizeUpstreamError(errorData) {
|
||||
return createSafeErrorResponse(errorData, { logOriginal: false })
|
||||
}
|
||||
|
||||
function extractErrorMessage(body) {
|
||||
return extractOriginalMessage(body)
|
||||
}
|
||||
|
||||
function isAccountDisabledError(statusCode, body) {
|
||||
if (statusCode !== 400) {
|
||||
return false
|
||||
}
|
||||
|
||||
const message = extractErrorMessage(body)
|
||||
const message = extractOriginalMessage(body)
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
// 将消息全部转换为小写,进行模糊匹配(避免大小写问题)
|
||||
const lowerMessage = message.toLowerCase()
|
||||
// 检测常见的账户禁用/不可用模式
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lowerMessage.includes('organization has been disabled') ||
|
||||
lowerMessage.includes('account has been disabled') ||
|
||||
lowerMessage.includes('account is disabled') ||
|
||||
lowerMessage.includes('no account supporting') ||
|
||||
lowerMessage.includes('account not found') ||
|
||||
lowerMessage.includes('invalid account') ||
|
||||
lowerMessage.includes('too many active sessions')
|
||||
lower.includes('organization has been disabled') ||
|
||||
lower.includes('account has been disabled') ||
|
||||
lower.includes('account is disabled') ||
|
||||
lower.includes('no account supporting') ||
|
||||
lower.includes('account not found') ||
|
||||
lower.includes('invalid account') ||
|
||||
lower.includes('too many active sessions')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ERROR_CODES,
|
||||
mapToErrorCode,
|
||||
createSafeErrorResponse,
|
||||
createSafeSSEError,
|
||||
getSafeMessage,
|
||||
// 兼容旧接口
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamError,
|
||||
extractErrorMessage,
|
||||
|
||||
@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
|
||||
*
|
||||
* 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名:
|
||||
* - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
|
||||
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
|
||||
* - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列
|
||||
*
|
||||
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
|
||||
*/
|
||||
function isClaudeFamilyModel(modelName) {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { baseModel } = parseVendorPrefixedModel(modelName)
|
||||
const m = (baseModel || '').trim().toLowerCase()
|
||||
if (!m) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bedrock 模型格式
|
||||
if (
|
||||
m.includes('.anthropic.claude-') ||
|
||||
m.startsWith('anthropic.claude-') ||
|
||||
m.includes('.claude-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 标准 Anthropic 模型 ID
|
||||
if (m.startsWith('claude-') || m.includes('claude-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
|
||||
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
isOpus45OrNewer,
|
||||
isClaudeFamilyModel
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ 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',
|
||||
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
|
||||
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
|
||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function generateState() {
|
||||
|
||||
/**
|
||||
* 生成随机的 code verifier(PKCE)
|
||||
* 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符
|
||||
* @returns {string} base64url 编码的随机字符串
|
||||
*/
|
||||
function generateCodeVerifier() {
|
||||
@@ -210,7 +211,7 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ OAuth token exchange successful', {
|
||||
logger.success('OAuth token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
hasRefreshToken: !!response.data?.refresh_token,
|
||||
@@ -430,7 +431,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
|
||||
dataKeys: response.data ? Object.keys(response.data) : []
|
||||
})
|
||||
|
||||
logger.success('✅ Setup Token exchange successful', {
|
||||
logger.success('Setup Token exchange successful', {
|
||||
status: response.status,
|
||||
hasAccessToken: !!response.data?.access_token,
|
||||
scopes: response.data?.scope,
|
||||
@@ -660,7 +661,7 @@ async function getOrganizationInfo(sessionKey, proxyConfig = null) {
|
||||
throw new Error('未找到具有chat能力的组织')
|
||||
}
|
||||
|
||||
logger.success('✅ Found organization', {
|
||||
logger.success('Found organization', {
|
||||
uuid: bestOrg.uuid,
|
||||
capabilities: maxCapabilities
|
||||
})
|
||||
@@ -777,7 +778,7 @@ async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyCon
|
||||
// 构建完整的授权码(包含state,如果有的话)
|
||||
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
|
||||
|
||||
logger.success('✅ Got authorization code via Cookie', {
|
||||
logger.success('Got authorization code via Cookie', {
|
||||
codeLength: authorizationCode.length,
|
||||
codePrefix: `${authorizationCode.substring(0, 10)}...`
|
||||
})
|
||||
@@ -853,7 +854,7 @@ async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = fa
|
||||
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
|
||||
|
||||
logger.success('✅ Cookie-based OAuth flow completed', {
|
||||
logger.success('Cookie-based OAuth flow completed', {
|
||||
isSetupToken,
|
||||
organizationUuid,
|
||||
hasAccessToken: !!tokenData.accessToken,
|
||||
|
||||
168
src/utils/performanceOptimizer.js
Normal file
168
src/utils/performanceOptimizer.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 性能优化工具模块
|
||||
* 提供 HTTP keep-alive 连接池、定价数据缓存等优化功能
|
||||
*/
|
||||
|
||||
const https = require('https')
|
||||
const http = require('http')
|
||||
const fs = require('fs')
|
||||
const LRUCache = require('./lruCache')
|
||||
|
||||
// 连接池配置(从环境变量读取)
|
||||
const STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_STREAM) || 65535
|
||||
const NON_STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_NON_STREAM) || 16384
|
||||
const MAX_FREE_SOCKETS = parseInt(process.env.HTTPS_MAX_FREE_SOCKETS) || 2048
|
||||
const FREE_SOCKET_TIMEOUT = parseInt(process.env.HTTPS_FREE_SOCKET_TIMEOUT) || 30000
|
||||
|
||||
// 流式请求 agent:高 maxSockets,timeout=0(不限制)
|
||||
const httpsAgentStream = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0,
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// 非流式请求 agent:较小 maxSockets
|
||||
const httpsAgentNonStream = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// HTTP agent(非流式)
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS,
|
||||
maxFreeSockets: MAX_FREE_SOCKETS,
|
||||
timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制
|
||||
freeSocketTimeout: FREE_SOCKET_TIMEOUT
|
||||
})
|
||||
|
||||
// 定价数据缓存(按文件路径区分)
|
||||
const pricingDataCache = new Map()
|
||||
const PRICING_CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||
|
||||
// Redis 配置缓存(短 TTL)
|
||||
const configCache = new LRUCache(100)
|
||||
const CONFIG_CACHE_TTL = 30 * 1000 // 30秒
|
||||
|
||||
/**
|
||||
* 获取流式请求的 HTTPS agent
|
||||
*/
|
||||
function getHttpsAgentForStream() {
|
||||
return httpsAgentStream
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取非流式请求的 HTTPS agent
|
||||
*/
|
||||
function getHttpsAgentForNonStream() {
|
||||
return httpsAgentNonStream
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取定价数据(带缓存,按路径区分)
|
||||
* @param {string} pricingFilePath - 定价文件路径
|
||||
* @returns {Object|null} 定价数据
|
||||
*/
|
||||
function getPricingData(pricingFilePath) {
|
||||
const now = Date.now()
|
||||
const cached = pricingDataCache.get(pricingFilePath)
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (cached && now - cached.loadTime < PRICING_CACHE_TTL) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
// 重新加载
|
||||
try {
|
||||
if (!fs.existsSync(pricingFilePath)) {
|
||||
return null
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
|
||||
pricingDataCache.set(pricingFilePath, { data, loadTime: now })
|
||||
return data
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除定价数据缓存(用于热更新)
|
||||
* @param {string} pricingFilePath - 可选,指定路径则只清除该路径缓存
|
||||
*/
|
||||
function clearPricingCache(pricingFilePath = null) {
|
||||
if (pricingFilePath) {
|
||||
pricingDataCache.delete(pricingFilePath)
|
||||
} else {
|
||||
pricingDataCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的配置
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {*} 缓存值
|
||||
*/
|
||||
function getCachedConfig(key) {
|
||||
return configCache.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置缓存
|
||||
* @param {string} key - 缓存键
|
||||
* @param {*} value - 值
|
||||
* @param {number} ttl - TTL(毫秒)
|
||||
*/
|
||||
function setCachedConfig(key, value, ttl = CONFIG_CACHE_TTL) {
|
||||
configCache.set(key, value, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置缓存
|
||||
* @param {string} key - 缓存键
|
||||
*/
|
||||
function deleteCachedConfig(key) {
|
||||
configCache.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接池统计信息
|
||||
*/
|
||||
function getAgentStats() {
|
||||
return {
|
||||
httpsStream: {
|
||||
sockets: Object.keys(httpsAgentStream.sockets).length,
|
||||
freeSockets: Object.keys(httpsAgentStream.freeSockets).length,
|
||||
requests: Object.keys(httpsAgentStream.requests).length,
|
||||
maxSockets: STREAM_MAX_SOCKETS
|
||||
},
|
||||
httpsNonStream: {
|
||||
sockets: Object.keys(httpsAgentNonStream.sockets).length,
|
||||
freeSockets: Object.keys(httpsAgentNonStream.freeSockets).length,
|
||||
requests: Object.keys(httpsAgentNonStream.requests).length,
|
||||
maxSockets: NON_STREAM_MAX_SOCKETS
|
||||
},
|
||||
http: {
|
||||
sockets: Object.keys(httpAgent.sockets).length,
|
||||
freeSockets: Object.keys(httpAgent.freeSockets).length,
|
||||
requests: Object.keys(httpAgent.requests).length
|
||||
},
|
||||
configCache: configCache.getStats()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getHttpAgent: () => httpAgent,
|
||||
getPricingData,
|
||||
clearPricingCache,
|
||||
getCachedConfig,
|
||||
setCachedConfig,
|
||||
deleteCachedConfig,
|
||||
getAgentStats
|
||||
}
|
||||
@@ -7,9 +7,16 @@ function toNumber(value) {
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) {
|
||||
// keyId 和 accountType 用于计算倍率成本
|
||||
async function updateRateLimitCounters(
|
||||
rateLimitInfo,
|
||||
usageSummary,
|
||||
model,
|
||||
keyId = null,
|
||||
accountType = null
|
||||
) {
|
||||
if (!rateLimitInfo) {
|
||||
return { totalTokens: 0, totalCost: 0 }
|
||||
return { totalTokens: 0, totalCost: 0, ratedCost: 0 }
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
@@ -59,11 +66,25 @@ async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCost > 0 && rateLimitInfo.costCountKey) {
|
||||
await client.incrbyfloat(rateLimitInfo.costCountKey, totalCost)
|
||||
// 计算倍率成本(用于限流计数)
|
||||
let ratedCost = totalCost
|
||||
if (totalCost > 0 && keyId) {
|
||||
try {
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const service = serviceRatesService.getService(accountType, model)
|
||||
ratedCost = await apiKeyService.calculateRatedCost(keyId, service, totalCost)
|
||||
} catch (error) {
|
||||
// 倍率计算失败时使用真实成本
|
||||
ratedCost = totalCost
|
||||
}
|
||||
}
|
||||
|
||||
return { totalTokens, totalCost }
|
||||
if (ratedCost > 0 && rateLimitInfo.costCountKey) {
|
||||
await client.incrbyfloat(rateLimitInfo.costCountKey, ratedCost)
|
||||
}
|
||||
|
||||
return { totalTokens, totalCost, ratedCost }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -47,6 +47,72 @@ function parseSSELine(line) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSSELine
|
||||
/**
|
||||
* 增量 SSE 解析器类
|
||||
* 用于处理流式数据,避免每次都 split 整个 buffer
|
||||
*/
|
||||
class IncrementalSSEParser {
|
||||
constructor() {
|
||||
this.buffer = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加数据块并返回完整的事件
|
||||
* @param {string} chunk - 数据块
|
||||
* @returns {Array<Object>} 解析出的完整事件数组
|
||||
*/
|
||||
feed(chunk) {
|
||||
this.buffer += chunk
|
||||
const events = []
|
||||
|
||||
// 查找完整的事件(以 \n\n 分隔)
|
||||
let idx
|
||||
while ((idx = this.buffer.indexOf('\n\n')) !== -1) {
|
||||
const event = this.buffer.slice(0, idx)
|
||||
this.buffer = this.buffer.slice(idx + 2)
|
||||
|
||||
if (event.trim()) {
|
||||
// 解析事件中的每一行
|
||||
const lines = event.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6)
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
events.push({ type: 'data', data: JSON.parse(jsonStr) })
|
||||
} catch (e) {
|
||||
events.push({ type: 'invalid', raw: jsonStr, error: e })
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
events.push({ type: 'done' })
|
||||
}
|
||||
} else if (line.startsWith('event: ')) {
|
||||
events.push({ type: 'event', name: line.slice(7).trim() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余的 buffer 内容
|
||||
* @returns {string}
|
||||
*/
|
||||
getRemaining() {
|
||||
return this.buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置解析器
|
||||
*/
|
||||
reset() {
|
||||
this.buffer = ''
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSSELine,
|
||||
IncrementalSSEParser
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ function generateSessionString() {
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {boolean} options.stream - 是否流式(默认false)
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 1000)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||
const { stream, prompt = 'hi', maxTokens = 1000 } = options
|
||||
const payload = {
|
||||
model,
|
||||
messages: [
|
||||
@@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
text: prompt,
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
@@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
metadata: {
|
||||
user_id: generateSessionString()
|
||||
},
|
||||
max_tokens: 21333,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
|
||||
if (options.stream) {
|
||||
if (stream) {
|
||||
payload.stream = true
|
||||
}
|
||||
|
||||
@@ -234,9 +237,58 @@ async function sendStreamTestRequest(options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Gemini 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createGeminiTestPayload(_model = 'gemini-2.5-pro', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAI Responses 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_output_tokens: maxTokens,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
createGeminiTestPayload,
|
||||
createOpenAITestPayload,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ async function startDeviceAuthorization(proxyConfig = null) {
|
||||
throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)')
|
||||
}
|
||||
|
||||
logger.success('✅ 成功获取 WorkOS 设备码授权信息', {
|
||||
logger.success('成功获取 WorkOS 设备码授权信息', {
|
||||
verificationUri: data.verification_uri,
|
||||
userCode: data.user_code
|
||||
})
|
||||
|
||||
@@ -5,20 +5,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Relay Service - 管理后台</title>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 预连接到CDN域名,加速资源加载 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,12 +12,10 @@ import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import ToastNotification from '@/components/common/ToastNotification.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const toastRef = ref()
|
||||
const confirmRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化主题
|
||||
|
||||
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user