Compare commits

..

24 Commits

Author SHA1 Message Date
github-actions[bot]
b9c088ce58 chore: sync VERSION file with release v1.1.253 [skip ci] 2026-01-07 14:12:03 +00:00
shaw
2ff74c21d2 Merge branch 'antigravity' 2026-01-07 21:55:15 +08:00
shaw
8a4dadbbc0 fix(security): 修复余额脚本功能的RCE和SSRF漏洞
- 将 BALANCE_SCRIPT_ENABLED 默认值改为 false,需显式启用
- 添加 isUrlSafe() SSRF防护,禁止访问:
  - localhost/127.x
  - 私有IP (10.x, 172.16-31.x, 192.168.x)
  - AWS metadata (169.254.x)
  - 非HTTP(S)协议
2026-01-07 21:55:08 +08:00
shaw
adf2890f65 fix: 去除context_management会导致压缩失败还原逻辑 [skip ci] 2026-01-07 21:24:39 +08:00
Wesley Liddick
7d892a69f1 Merge pull request #873 from DaydreamCoding/patch-5 [skip ci]
fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 08:09:15 -05:00
QTom
a749ddfede fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 20:57:49 +08:00
Wesley Liddick
dbd4fb19cf Merge branch 'main' into antigravity 2026-01-07 03:49:14 -05:00
github-actions[bot]
39ba345a43 chore: sync VERSION file with release v1.1.252 [skip ci] 2026-01-07 08:22:01 +00:00
shaw
2693fd77b7 fix: 移除context_management字段,避免报错 2026-01-07 16:21:41 +08:00
52227
3cc3219a90 docs: 更新中英文 README,完善模型配额查询与 Claude Code 适配说明
- 新增 Antigravity 账户额度与模型列表查询指南
- 完善 Claude Code 兼容性特性说明 (Thinking Signature, Zombie Stream Watchdog)
- 移除无关广告信息,优化文档结构
- 明确二开维护者信息
2026-01-05 23:07:04 +08:00
52227
1b834ffcdb feat: 增强稳定性与Antigravity适配 (僵尸流看门狗/自动重试/签名缓存)
主要变更:
1. **僵尸流看门狗 (Zombie Stream Watchdog)**:
   - 新增 resetActivityTimeout 机制,45秒无数据强制断开连接,防止服务假死。

2. **智能重试机制**:
   - 针对 Antigravity 429 (Resource Exhausted) 错误,自动清理会话并切换账号重试。
   - 涵盖流式 (Stream) 和非流式 (Non-stream) 请求。

3. **Thought Signature 增强**:
   - 新增签名缓存与恢复机制 (signatureCache)。
   - 增加 skip_thought_signature_validator 兜底签名策略。
   - 强制补充 thought: true 标记以满足上游校验。

4. **系统稳定性与调试**:
   - 使用 util.inspect 替代 JSON.stringify 打印错误日志,彻底修复循环引用导致的服务崩溃。
   - 新增针对 Antigravity 参数错误 (400) 的详细请求结构分析日志。
   - 优化日志写入为轮转模式 (safeRotatingAppend)。

5. **其他优化**:
   - antigravityClient 数据处理安全增强 (safeDataToString)。
2026-01-05 09:37:39 +08:00
52227
41999f56b4 feat: 适配 Antigravity 账户余额查询与流式响应优化
1. Antigravity 账户适配:
   - 新增 GeminiBalanceProvider,支持 Antigravity 账户的额度查询(API 模式)
   - AccountBalanceService 增加 queryMode 逻辑与安全限制
   - 前端 BalanceDisplay 适配 Antigravity 配额显示

2. 流式响应增强:
   - 优化 thoughtSignature 捕获与回填,支持思维链透传
   - 修复工具调用签名校验

3. 其他:
   - 请求体大小限制提升至 100MB
   - .gitignore 更新
2026-01-03 10:15:13 +08:00
52227
b81c2b946f feat: 增强 Gemini 桥接处理并添加 Antigravity 响应转储工具 2026-01-01 15:24:12 +08:00
github-actions[bot]
0a59a0f9d4 chore: sync VERSION file with release v1.1.251 [skip ci] 2026-01-01 05:57:53 +00:00
Chapoly1305
c4448db6ab fix: 防止客户端断开连接时服务崩溃
当客户端在流式响应过程中断开连接时,catch 块尝试发送 JSON 错误响应
会触发 ERR_HTTP_HEADERS_SENT 错误,导致 unhandledRejection 使服务崩溃。

修复文件:
- src/routes/openaiClaudeRoutes.js
- src/routes/openaiGeminiRoutes.js

修复内容:
- 添加 res.headersSent 检查,避免在响应已发送后再次尝试发送
- 客户端断开连接使用 INFO 级别日志(不是 ERROR)
- 客户端断开使用 499 状态码 (Client Closed Request)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 01:18:06 -05:00
52227
c67d2bce9d feat: 完善 Antigravity OAuth 功能与权限校验
新增功能:
- 实现 Antigravity OAuth 账户支持与路径分流
- 支持 /antigravity/api 路径自动分流到 Antigravity OAuth 账户
- 支持 gemini-antigravity 平台类型的账户创建和管理

修复问题:
- 修复 OAuthFlow 组件中 gemini-antigravity 平台授权页面空白的问题
- 修复 EditApiKeyModal 中 Redis 返回字符串格式 permissions 导致的 400 错误
- 统一使用 hasPermission 函数进行权限校验,支持数组格式

优化改进:
- 添加 Antigravity 调试环境变量说明
2025-12-29 14:23:43 +08:00
github-actions[bot]
a345812cd7 chore: sync VERSION file with release v1.1.250 [skip ci] 2025-12-29 05:46:39 +00:00
shaw
a0cbafd759 Merge branch 'fix-authenticateUserOrAdmin-bypass' 2025-12-29 13:45:44 +08:00
Wesley Liddick
3c64038fa7 Create SECURITY.md for security policy [skip ci]
Add a security policy document outlining supported versions and vulnerability reporting.
2025-12-29 13:37:15 +08:00
Junming Chen
45b81bd478 fix: 修复 authenticateUserOrAdmin 认证绕过漏洞
- 添加 username 和 loginTime 字段验证(与 authenticateAdmin 保持一致)
- 无效/伪造会话自动删除并记录安全日志
- 删除未使用的 id 字段(死代码清理)

漏洞详情:
- 位置:src/middleware/auth.js:1569-1581
- 原因:只检查 Object.keys(session).length > 0,未验证必须字段
- 影响:攻击者可通过注入最小会话 {foo:'bar'} 绕过认证

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 23:56:05 -05:00
github-actions[bot]
fc57133230 chore: sync VERSION file with release v1.1.249 [skip ci] 2025-12-26 11:26:14 +00:00
shaw
1f06af4a56 chore: trigger release [force release] 2025-12-26 19:25:53 +08:00
shaw
6165fad090 docs: 添加安全漏洞警告 2025-12-26 19:22:08 +08:00
shaw
d53a399d41 revert: 回退到安全漏洞修复版本 2025-12-26 19:15:50 +08:00
39 changed files with 2679 additions and 3286 deletions

View File

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

1064
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,630 +1,124 @@
# Claude Relay Service
# Claude Relay Service (Antigravity Edition)
> [!CAUTION]
> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
>
> **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
**🔐 Self-hosted Claude API relay service with multi-account management**
[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
</div>
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
---
## ⭐ If You Find It Useful, Please Give It a Star!
## Highlights
> 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
- **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)
---
## ⚠️ Important Notice
## Quick Start
**Please read carefully before using this project:**
### Requirements
- Node.js 18+ (or Docker)
- Redis 6+/7+
🚨 **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
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install Redis
sudo apt update
sudo apt install redis-server
sudo systemctl start redis-server
```
**CentOS/RHEL users:**
```bash
# Install Node.js
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
sudo yum install -y nodejs
# Install Redis
sudo yum install redis
sudo systemctl start redis
```
### Step 2: Download and Configure
### Docker Compose (recommended)
```bash
# Download project
git clone https://github.com/Wei-Shaw/claude-relay-service.git
cd claude-relay-service
# Install dependencies
npm install
# Copy configuration files (Important!)
cp config/config.example.js config/config.js
cp .env.example .env
cp config/config.example.js config/config.js
# Edit .env at least:
# JWT_SECRET=... (random string)
# ENCRYPTION_KEY=... (32-char random string)
docker-compose up -d
```
### Step 3: Configuration File Setup
**Edit `.env` file:**
```bash
# Generate these two keys randomly, but remember them
JWT_SECRET=your-super-secret-key
ENCRYPTION_KEY=32-character-encryption-key-write-randomly
# Redis configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
```
**Edit `config/config.js` file:**
```javascript
module.exports = {
server: {
port: 3000, // Service port, can be changed
host: '0.0.0.0' // Don't change
},
redis: {
host: '127.0.0.1', // Redis address
port: 6379 // Redis port
},
// Keep other configurations as default
}
```
### Step 4: Start Service
### Node (no Docker)
```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
npm install
cp .env.example .env
cp config/config.example.js config/config.js
npm run setup
npm run service:start:daemon
```
### Admin UI
- 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).
---
## 🎮 Getting Started
## Using with Claude Code (CLI)
### 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 (Claude/Console/Bedrock/CCR):
### Antigravity pool (recommended)
```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"
```
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_BASE_URL="http://<host>:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth (Gemini models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude Plugin Configuration:**
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
claude
```
**Use Gemini CLI:**
### Gemini pool
```bash
gemini
export ANTHROPIC_BASE_URL="http://<host>:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
export ANTHROPIC_MODEL="gemini-2.5-pro"
claude
```
---
## 🔧 Daily Maintenance
### Service Management
### Standard Claude pool
```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
export ANTHROPIC_BASE_URL="http://<host>:3000/api/"
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
claude
```
### 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
---
## 🛠️ Advanced Usage
## Antigravity Quota & Models
### Reverse Proxy Deployment Guide
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)**.
- 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`)
---
## Caddy Solution
## Debug Dumps (optional)
Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
See `.env.example` for the full list. Common toggles:
**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
- `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`
---
## Nginx Proxy Manager (NPM) Solution
## License
Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
This project is licensed under the [MIT License](LICENSE).
**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>

21
SECURITY.md Normal file
View File

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

View File

@@ -1 +1 @@
1.1.243
1.1.253

View File

@@ -179,7 +179,7 @@ class Application {
// 🔧 基础中间件
this.app.use(
express.json({
limit: '10mb',
limit: '100mb',
verify: (req, res, buf, encoding) => {
// 验证JSON格式
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
@@ -188,7 +188,7 @@ class Application {
}
})
)
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
this.app.use(securityMiddleware)
// 🎯 信任代理

View File

@@ -1434,7 +1434,6 @@ const authenticateAdmin = async (req, res, next) => {
// 设置管理员信息(只包含必要信息)
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: token,
loginTime: adminSession.loginTime
@@ -1567,17 +1566,25 @@ const authenticateUserOrAdmin = async (req, res, next) => {
try {
const adminSession = await redis.getSession(adminToken)
if (adminSession && Object.keys(adminSession).length > 0) {
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
// 不返回 401继续尝试用户认证
} else {
req.admin = {
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
}
}
} catch (error) {
logger.debug('Admin authentication failed, trying user authentication:', error.message)
@@ -2043,7 +2050,7 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0')
@@ -2052,7 +2059,7 @@ const requestSizeLimit = (req, res, next) => {
return res.status(413).json({
error: 'Payload Too Large',
message: 'Request body size exceeds limit',
limit: '10MB'
limit: `${MAX_SIZE_MB}MB`
})
}

View File

@@ -4,10 +4,6 @@ const path = require('path')
const axios = require('axios')
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const droidAccountService = require('../../services/droidAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
@@ -258,43 +254,30 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => {
// ==================== OEM 设置管理 ====================
// 默认OEM设置
const defaultOemSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮
publicStatsEnabled: false, // 是否在首页显示公开统计概览
// 公开统计显示选项
publicStatsShowModelDistribution: true, // 显示模型使用分布
publicStatsModelDistributionPeriod: 'today', // 模型使用分布时间范围: today, 24h, 7d, 30d, all
publicStatsShowTokenTrends: false, // 显示Token使用趋势
publicStatsShowApiKeysTrends: false, // 显示API Keys使用趋势
publicStatsShowAccountTrends: false, // 显示账号使用趋势
updatedAt: new Date().toISOString()
}
// 获取OEM设置的辅助函数
async function getOemSettings() {
const client = redis.getClient()
const oemSettings = await client.get('oem:settings')
let settings = { ...defaultOemSettings }
if (oemSettings) {
try {
settings = { ...defaultOemSettings, ...JSON.parse(oemSettings) }
} catch (err) {
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
}
}
return settings
}
// 获取OEM设置公开接口用于显示
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
router.get('/oem-settings', async (req, res) => {
try {
const settings = await getOemSettings()
const client = redis.getClient()
const oemSettings = await client.get('oem:settings')
// 默认设置
const defaultSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '', // Base64编码的图标数据
showAdminButton: true, // 是否显示管理后台按钮
updatedAt: new Date().toISOString()
}
let settings = defaultSettings
if (oemSettings) {
try {
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
} catch (err) {
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
}
}
// 添加 LDAP 启用状态到响应中
return res.json({
@@ -313,18 +296,7 @@ router.get('/oem-settings', async (req, res) => {
// 更新OEM设置
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
try {
const {
siteName,
siteIcon,
siteIconData,
showAdminButton,
publicStatsEnabled,
publicStatsShowModelDistribution,
publicStatsModelDistributionPeriod,
publicStatsShowTokenTrends,
publicStatsShowApiKeysTrends,
publicStatsShowAccountTrends
} = req.body
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
// 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -351,24 +323,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
}
}
// 验证时间范围值
const validPeriods = ['today', '24h', '7d', '30d', 'all']
const periodValue = validPeriods.includes(publicStatsModelDistributionPeriod)
? publicStatsModelDistributionPeriod
: 'today'
const settings = {
siteName: siteName.trim(),
siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true
publicStatsEnabled: publicStatsEnabled === true, // 默认为false
// 公开统计显示选项
publicStatsShowModelDistribution: publicStatsShowModelDistribution !== false, // 默认为true
publicStatsModelDistributionPeriod: periodValue, // 时间范围
publicStatsShowTokenTrends: publicStatsShowTokenTrends === true, // 默认为false
publicStatsShowApiKeysTrends: publicStatsShowApiKeysTrends === true, // 默认为false
publicStatsShowAccountTrends: publicStatsShowAccountTrends === true, // 默认为false
updatedAt: new Date().toISOString()
}
@@ -439,420 +398,4 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
}
})
// ==================== 公开统计概览 ====================
// 获取公开统计数据(无需认证,用于首页展示)
// 只在 publicStatsEnabled 开启时返回数据
router.get('/public-stats', async (req, res) => {
try {
// 检查是否启用了公开统计
const settings = await getOemSettings()
if (!settings.publicStatsEnabled) {
return res.json({
success: true,
enabled: false,
data: null
})
}
// 辅助函数:规范化布尔值
const normalizeBoolean = (value) => value === true || value === 'true'
const isRateLimitedFlag = (status) => {
if (!status) {
return false
}
if (typeof status === 'string') {
return status === 'limited'
}
if (typeof status === 'object') {
return status.isRateLimited === true
}
return false
}
// 并行获取统计数据
const [
claudeAccounts,
claudeConsoleAccounts,
geminiAccounts,
bedrockAccountsResult,
droidAccounts,
todayStats,
modelStats
] = await Promise.all([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
droidAccountService.getAllAccounts(),
redis.getTodayStats(),
getPublicModelStats(settings.publicStatsModelDistributionPeriod || 'today')
])
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
// 计算各平台正常账户数
const normalClaudeAccounts = claudeAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
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 normalBedrockAccounts = bedrockAccounts.filter(
(acc) =>
acc.isActive &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
acc.schedulable !== false &&
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
).length
const normalDroidAccounts = droidAccounts.filter(
(acc) =>
normalizeBoolean(acc.isActive) &&
acc.status !== 'blocked' &&
acc.status !== 'unauthorized' &&
normalizeBoolean(acc.schedulable) &&
!isRateLimitedFlag(acc.rateLimitStatus)
).length
// 计算总正常账户数
const totalNormalAccounts =
normalClaudeAccounts +
normalClaudeConsoleAccounts +
normalGeminiAccounts +
normalBedrockAccounts +
normalDroidAccounts
// 判断服务状态
const isHealthy = redis.isConnected && totalNormalAccounts > 0
// 构建公开统计数据(脱敏后的数据)
const publicStats = {
// 服务状态
serviceStatus: isHealthy ? 'healthy' : 'degraded',
uptime: process.uptime(),
// 平台可用性(只显示是否有可用账户,不显示具体数量)
platforms: {
claude: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
gemini: normalGeminiAccounts > 0,
bedrock: normalBedrockAccounts > 0,
droid: normalDroidAccounts > 0
},
// 今日统计
todayStats: {
requests: todayStats.requestsToday || 0,
tokens: todayStats.tokensToday || 0,
inputTokens: todayStats.inputTokensToday || 0,
outputTokens: todayStats.outputTokensToday || 0
},
// 系统时区
systemTimezone: config.system.timezoneOffset || 8,
// 显示选项
showOptions: {
modelDistribution: settings.publicStatsShowModelDistribution !== false,
tokenTrends: settings.publicStatsShowTokenTrends === true,
apiKeysTrends: settings.publicStatsShowApiKeysTrends === true,
accountTrends: settings.publicStatsShowAccountTrends === true
}
}
// 根据设置添加可选数据
if (settings.publicStatsShowModelDistribution !== false) {
// modelStats 现在返回 { stats: [], period }
publicStats.modelDistribution = modelStats.stats
publicStats.modelDistributionPeriod = modelStats.period
}
// 获取趋势数据最近7天
if (
settings.publicStatsShowTokenTrends ||
settings.publicStatsShowApiKeysTrends ||
settings.publicStatsShowAccountTrends
) {
const trendData = await getPublicTrendData(settings)
if (settings.publicStatsShowTokenTrends && trendData.tokenTrends) {
publicStats.tokenTrends = trendData.tokenTrends
}
if (settings.publicStatsShowApiKeysTrends && trendData.apiKeysTrends) {
publicStats.apiKeysTrends = trendData.apiKeysTrends
}
if (settings.publicStatsShowAccountTrends && trendData.accountTrends) {
publicStats.accountTrends = trendData.accountTrends
}
}
return res.json({
success: true,
enabled: true,
data: publicStats
})
} catch (error) {
logger.error('❌ Failed to get public stats:', error)
return res.status(500).json({
success: false,
error: 'Failed to get public stats',
message: error.message
})
}
})
// 获取公开模型统计的辅助函数
// period: 'today' | '24h' | '7d' | '30d' | 'all'
async function getPublicModelStats(period = 'today') {
try {
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
// 根据period生成日期范围
const getDatePatterns = () => {
const patterns = []
if (period === 'today') {
patterns.push(`usage:model:daily:*:${today}`)
} else if (period === '24h') {
// 过去24小时 = 今天 + 昨天
patterns.push(`usage:model:daily:*:${today}`)
const yesterday = new Date(tzDate)
yesterday.setDate(yesterday.getDate() - 1)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(yesterday)}`)
} else if (period === '7d') {
// 过去7天
for (let i = 0; i < 7; i++) {
const date = new Date(tzDate)
date.setDate(date.getDate() - i)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
}
} else if (period === '30d') {
// 过去30天
for (let i = 0; i < 30; i++) {
const date = new Date(tzDate)
date.setDate(date.getDate() - i)
patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
}
} else if (period === 'all') {
// 所有数据
patterns.push('usage:model:daily:*')
} else {
// 默认今天
patterns.push(`usage:model:daily:*:${today}`)
}
return patterns
}
const patterns = getDatePatterns()
let allKeys = []
for (const pattern of patterns) {
const keys = await client.keys(pattern)
allKeys.push(...keys)
}
// 去重
allKeys = [...new Set(allKeys)]
if (allKeys.length === 0) {
return { stats: [], period }
}
// 模型名标准化
const normalizeModelName = (model) => {
if (!model || model === 'unknown') {
return model
}
if (model.includes('.anthropic.') || model.includes('.claude')) {
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()
let totalRequests = 0
for (const key of allKeys) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!match) {
continue
}
const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const requests = parseInt(data.requests) || 0
totalRequests += requests
const stats = modelStatsMap.get(normalizedModel) || { requests: 0 }
stats.requests += requests
modelStatsMap.set(normalizedModel, stats)
}
}
// 转换为数组并计算占比
const modelStats = []
for (const [model, stats] of modelStatsMap) {
modelStats.push({
model,
percentage: totalRequests > 0 ? Math.round((stats.requests / totalRequests) * 100) : 0
})
}
// 按占比排序取前5个
modelStats.sort((a, b) => b.percentage - a.percentage)
return { stats: modelStats.slice(0, 5), period }
} catch (error) {
logger.warn('⚠️ Failed to get public model stats:', error.message)
return { stats: [], period }
}
}
// 获取公开趋势数据的辅助函数最近7天
async function getPublicTrendData(settings) {
const result = {
tokenTrends: null,
apiKeysTrends: null,
accountTrends: null
}
try {
const client = redis.getClientSafe()
const days = 7
// 生成最近7天的日期列表
const dates = []
for (let i = days - 1; i >= 0; i--) {
const date = new Date()
date.setDate(date.getDate() - i)
dates.push(redis.getDateStringInTimezone(date))
}
// Token使用趋势
if (settings.publicStatsShowTokenTrends) {
const tokenTrends = []
for (const dateStr of dates) {
const pattern = `usage:model:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayTokens = 0
let dayRequests = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
dayRequests += parseInt(data.requests) || 0
}
}
tokenTrends.push({
date: dateStr,
tokens: dayTokens,
requests: dayRequests
})
}
result.tokenTrends = tokenTrends
}
// API Keys使用趋势脱敏只显示总数不显示具体Key
if (settings.publicStatsShowApiKeysTrends) {
const apiKeysTrends = []
for (const dateStr of dates) {
const pattern = `usage:apikey:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayRequests = 0
let dayTokens = 0
let activeKeys = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
const requests = parseInt(data.requests) || 0
if (requests > 0) {
activeKeys++
dayRequests += requests
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
}
}
}
apiKeysTrends.push({
date: dateStr,
activeKeys,
requests: dayRequests,
tokens: dayTokens
})
}
result.apiKeysTrends = apiKeysTrends
}
// 账号使用趋势(脱敏:只显示总数,不显示具体账号)
if (settings.publicStatsShowAccountTrends) {
const accountTrends = []
for (const dateStr of dates) {
const pattern = `usage:account:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayRequests = 0
let dayTokens = 0
let activeAccounts = 0
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
const requests = parseInt(data.requests) || 0
if (requests > 0) {
activeAccounts++
dayRequests += requests
dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
}
}
}
accountTrends.push({
date: dateStr,
activeAccounts,
requests: dayRequests,
tokens: dayTokens
})
}
result.accountTrends = accountTrends
}
} catch (error) {
logger.warn('⚠️ Failed to get public trend data:', error.message)
}
return result
}
module.exports = router

View File

@@ -122,12 +122,18 @@ async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
const forcedVendor = req._anthropicVendor || null
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
message:
requiredService === 'gemini'
? '此 API Key 无权访问 Gemini 服务'
: '此 API Key 无权访问 Claude 服务'
}
})
}
@@ -176,7 +182,6 @@ async function handleMessagesRequest(req, res) {
}
}
const forcedVendor = req._anthropicVendor || null
logger.api('📥 /v1/messages request received', {
model: req.body.model || null,
forcedVendor,
@@ -192,34 +197,10 @@ async function handleMessagesRequest(req, res) {
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -1250,8 +1231,7 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -1444,34 +1424,25 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Gemini'
}
})
}
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
message:
requiredService === 'gemini'
? 'This API key does not have permission to access Gemini'
: 'This API key does not have permission to access Claude'
}
})
}
if (requiredService === 'gemini') {
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 🔗 会话绑定验证(与 messages 端点保持一致)
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
const sessionValidation = await claudeRelayConfigService.validateNewSession(

View File

@@ -402,16 +402,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
const duration = Date.now() - startTime
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
} catch (error) {
logger.error('❌ OpenAI-Claude request error:', error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
} else {
logger.error('❌ OpenAI-Claude request error:', error)
}
const status = error.status || 500
res.status(status).json({
error: {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
if (!res.headersSent) {
// 客户端断开使用 499 状态码 (Client Closed Request)
if (error.message === 'Client disconnected') {
res.status(499).end()
} else {
const status = error.status || 500
res.status(status).json({
error: {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
})
}
})
}
} finally {
// 清理资源
if (abortController) {

View File

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

View File

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

View File

@@ -270,7 +270,7 @@ class AccountBalanceService {
}
async _getAccountBalanceForAccount(account, platform, options = {}) {
const queryApi = this._parseBoolean(options.queryApi) || false
const queryMode = this._parseQueryMode(options.queryApi)
const useCache = options.useCache !== false
const accountId = account?.id
@@ -297,8 +297,14 @@ class AccountBalanceService {
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
// 非强制查询:优先读缓存
if (!queryApi) {
// 安全限制queryApi=auto 仅用于 Antigravitygemini + oauthProvider=antigravity账户
const effectiveQueryMode =
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
? 'local'
: queryMode
// local: 仅本地统计/缓存auto: 优先缓存,无缓存则尝试远程 Provider并缓存结果
if (effectiveQueryMode !== 'api') {
if (useCache) {
const cached = await this.redis.getAccountBalance(platform, accountId)
if (cached && cached.status === 'success') {
@@ -321,22 +327,24 @@ class AccountBalanceService {
}
}
return this._buildResponse(
{
status: 'success',
errorMessage: null,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: localBalance.lastCalculated
},
accountId,
platform,
'local',
null,
scriptMeta
)
if (effectiveQueryMode === 'local') {
return this._buildResponse(
{
status: 'success',
errorMessage: null,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: localBalance.lastCalculated
},
accountId,
platform,
'local',
null,
scriptMeta
)
}
}
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider失败自动降级到本地统计
@@ -723,6 +731,14 @@ class AccountBalanceService {
return null
}
_parseQueryMode(value) {
if (value === 'auto') {
return 'auto'
}
const parsed = this._parseBoolean(value)
return parsed ? 'api' : 'local'
}
async _mapWithConcurrency(items, limit, mapper) {
const concurrency = Math.max(1, Number(limit) || 1)
const list = Array.isArray(items) ? items : []

File diff suppressed because it is too large Load Diff

View File

@@ -304,6 +304,11 @@ async function request({
}
const isRetryable = (error) => {
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true
}
const status = error?.response?.status
if (status === 429) {
return true
@@ -429,7 +434,37 @@ async function request({
const status = error?.response?.status
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
const data = error?.response?.data
const msg = typeof data === 'string' ? data : JSON.stringify(data || '')
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
const safeDataToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// stream 对象存在循环引用,不能 JSON.stringify
if (typeof value === 'object' && typeof value.pipe === 'function') {
return ''
}
if (Buffer.isBuffer(value)) {
try {
return value.toString('utf8')
} catch (_) {
return ''
}
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (_) {
return ''
}
}
return String(value)
}
const msg = safeDataToString(data)
if (
msg.toLowerCase().includes('resource_exhausted') ||
msg.toLowerCase().includes('no capacity')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
/**
* ============================================================================
* 安全 JSONL 追加工具(带文件大小限制与自动轮转)
* ============================================================================
*
* 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。
*
* 策略:
* - 每次写入前检查目标文件大小
* - 超过阈值时,将现有文件重命名为 .bak覆盖旧 .bak
* - 然后写入新文件
*/
const fs = require('fs/promises')
const logger = require('./logger')
// 默认文件大小上限10MB
const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES'
/**
* 获取文件大小上限(可通过环境变量覆盖)
*/
function getMaxFileSize() {
const raw = process.env[MAX_FILE_SIZE_ENV]
if (raw) {
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) {
return parsed
}
}
return DEFAULT_MAX_FILE_SIZE_BYTES
}
/**
* 获取文件大小,文件不存在时返回 0
*/
async function getFileSize(filepath) {
try {
const stat = await fs.stat(filepath)
return stat.size
} catch (e) {
// 文件不存在或无法读取
return 0
}
}
/**
* 安全追加写入 JSONL 文件,支持自动轮转
*
* @param {string} filepath - 目标文件绝对路径
* @param {string} line - 要写入的单行(应以 \n 结尾)
* @param {Object} options - 可选配置
* @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB
*/
async function safeRotatingAppend(filepath, line, options = {}) {
const maxFileSize = options.maxFileSize || getMaxFileSize()
const currentSize = await getFileSize(filepath)
// 如果当前文件已达到或超过阈值,轮转
if (currentSize >= maxFileSize) {
const backupPath = `${filepath}.bak`
try {
// 先删除旧备份(如果存在)
await fs.unlink(backupPath).catch(() => {})
// 重命名当前文件为备份
await fs.rename(filepath, backupPath)
} catch (renameErr) {
// 轮转失败时记录警告日志,继续写入原文件
logger.warn('⚠️ Log rotation failed, continuing to write to original file', {
filepath,
backupPath,
error: renameErr?.message || String(renameErr)
})
}
}
// 追加写入
await fs.appendFile(filepath, line, { encoding: 'utf8' })
}
module.exports = {
safeRotatingAppend,
getMaxFileSize,
MAX_FILE_SIZE_ENV,
DEFAULT_MAX_FILE_SIZE_BYTES
}

183
src/utils/signatureCache.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* Signature Cache - 签名缓存模块
*
* 用于缓存 Antigravity thinking block 的 thoughtSignature。
* Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。
* 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。
*
* 参考实现:
* - CLIProxyAPI: internal/cache/signature_cache.go
* - antigravity-claude-proxy: src/format/signature-cache.js
*/
const crypto = require('crypto')
const logger = require('./logger')
// 配置常量
const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI
const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目
const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度
const TEXT_HASH_LENGTH = 16 // 文本哈希长度SHA256 前 16 位)
// 主缓存sessionId -> Map<textHash, { signature, timestamp }>
const signatureCache = new Map()
/**
* 生成文本内容的稳定哈希值
* @param {string} text - 待哈希的文本
* @returns {string} 16 字符的十六进制哈希
*/
function hashText(text) {
if (!text || typeof text !== 'string') {
return ''
}
const hash = crypto.createHash('sha256').update(text).digest('hex')
return hash.slice(0, TEXT_HASH_LENGTH)
}
/**
* 获取或创建会话缓存
* @param {string} sessionId - 会话 ID
* @returns {Map} 会话的签名缓存 Map
*/
function getOrCreateSessionCache(sessionId) {
if (!signatureCache.has(sessionId)) {
signatureCache.set(sessionId, new Map())
}
return signatureCache.get(sessionId)
}
/**
* 检查签名是否有效
* @param {string} signature - 待检查的签名
* @returns {boolean} 签名是否有效
*/
function isValidSignature(signature) {
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH
}
/**
* 缓存 thinking 签名
* @param {string} sessionId - 会话 ID
* @param {string} thinkingText - thinking 内容文本
* @param {string} signature - thoughtSignature
*/
function cacheSignature(sessionId, thinkingText, signature) {
if (!sessionId || !thinkingText || !signature) {
return
}
if (!isValidSignature(signature)) {
return
}
const sessionCache = getOrCreateSessionCache(sessionId)
const textHash = hashText(thinkingText)
if (!textHash) {
return
}
// 淘汰策略:超过限制时删除最老的 1/4 条目
if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) {
const entries = Array.from(sessionCache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toRemove = Math.max(1, Math.floor(entries.length / 4))
for (let i = 0; i < toRemove; i++) {
sessionCache.delete(entries[i][0])
}
logger.debug(
`[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...`
)
}
sessionCache.set(textHash, {
signature,
timestamp: Date.now()
})
logger.debug(
`[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
)
}
/**
* 获取缓存的签名
* @param {string} sessionId - 会话 ID
* @param {string} thinkingText - thinking 内容文本
* @returns {string|null} 缓存的签名,未找到或过期则返回 null
*/
function getCachedSignature(sessionId, thinkingText) {
if (!sessionId || !thinkingText) {
return null
}
const sessionCache = signatureCache.get(sessionId)
if (!sessionCache) {
return null
}
const textHash = hashText(thinkingText)
if (!textHash) {
return null
}
const entry = sessionCache.get(textHash)
if (!entry) {
return null
}
// 检查是否过期
if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
sessionCache.delete(textHash)
logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`)
return null
}
logger.debug(
`[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
)
return entry.signature
}
/**
* 清除会话缓存
* @param {string} sessionId - 要清除的会话 ID为空则清除全部
*/
function clearSignatureCache(sessionId = null) {
if (sessionId) {
signatureCache.delete(sessionId)
logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`)
} else {
signatureCache.clear()
logger.debug('[SignatureCache] Cleared all caches')
}
}
/**
* 获取缓存统计信息(调试用)
* @returns {Object} { sessionCount, totalEntries }
*/
function getCacheStats() {
let totalEntries = 0
for (const sessionCache of signatureCache.values()) {
totalEntries += sessionCache.size
}
return {
sessionCount: signatureCache.size,
totalEntries
}
}
module.exports = {
cacheSignature,
getCachedSignature,
clearSignatureCache,
getCacheStats,
isValidSignature,
// 内部函数导出(用于测试或扩展)
hashText,
MIN_SIGNATURE_LENGTH,
MAX_ENTRIES_PER_SESSION,
SIGNATURE_CACHE_TTL_MS
}

View File

@@ -15,7 +15,6 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
@@ -5132,16 +5131,6 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",

View File

@@ -18,7 +18,6 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"

View File

@@ -52,10 +52,51 @@
</div>
<!-- 配额如适用 -->
<div v-if="quotaInfo" class="space-y-1">
<div v-if="quotaInfo && isAntigravityQuota" class="space-y-2">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
<span>剩余</span>
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
</div>
<div class="space-y-1">
<div
v-for="row in antigravityRows"
:key="row.category"
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
<span
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
:title="row.category"
>
{{ row.category }}
</span>
<div class="flex w-[94px] flex-col gap-0.5">
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-1.5 rounded-full transition-all"
:class="row.barClass"
:style="{ width: `${row.remainingPercent ?? 0}%` }"
></div>
</div>
<div
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
>
<span>{{ row.remainingText }}</span>
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
formatResetTime(row.resetAt)
}}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatQuotaNumber(quotaInfo.remaining) }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
@@ -100,7 +141,8 @@ const props = defineProps({
platform: { type: String, required: true },
initialBalance: { type: Object, default: null },
hideRefresh: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true }
autoLoad: { type: Boolean, default: true },
queryMode: { type: String, default: 'local' } // local | auto | api
})
const emit = defineEmits(['refreshed', 'error'])
@@ -136,6 +178,43 @@ const quotaInfo = computed(() => {
}
})
const isAntigravityQuota = computed(() => {
return balanceData.value?.quota?.type === 'antigravity'
})
const antigravityRows = computed(() => {
if (!isAntigravityQuota.value) return []
const buckets = balanceData.value?.quota?.buckets
const list = Array.isArray(buckets) ? buckets : []
const map = new Map(list.map((b) => [b?.category, b]))
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const styles = {
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
}
return order.map((category) => {
const raw = map.get(category) || null
const remaining = raw?.remaining
const remainingPercent = Number.isFinite(Number(remaining))
? Math.max(0, Math.min(100, Number(remaining)))
: null
return {
category,
remainingPercent,
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
resetAt: raw?.resetAt || null,
dotClass: styles[category]?.dotClass || 'bg-gray-400',
barClass: styles[category]?.barClass || 'bg-gray-400'
}
})
})
const quotaBarClass = computed(() => {
const percentage = quotaInfo.value?.percentage || 0
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
@@ -144,7 +223,12 @@ const quotaBarClass = computed(() => {
})
const canRefresh = computed(() => {
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本
if (props.queryMode === 'api' || props.queryMode === 'auto') {
return true
}
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
const data = balanceData.value
if (!data) return false
if (data.scriptEnabled === false) return false
@@ -159,6 +243,9 @@ const refreshTitle = computed(() => {
}
return '请先配置余额脚本'
}
if (isAntigravityQuota.value) {
return '刷新配额(调用 Antigravity API'
}
return '刷新余额(调用脚本配置的余额 API'
})
@@ -179,7 +266,10 @@ const load = async () => {
try {
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
params: { platform: props.platform, queryApi: false }
params: {
platform: props.platform,
queryApi: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : false
}
})
if (response?.success) {
balanceData.value = response.data
@@ -231,6 +321,16 @@ const formatNumber = (num) => {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const formatQuotaNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
if (isAntigravityQuota.value) {
return `${Math.round(value)}%`
}
return formatNumber(value)
}
const formatCurrency = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'

View File

@@ -287,7 +287,7 @@
</div>
<!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'">
<div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
<div
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
>

View File

@@ -1233,13 +1233,28 @@ onMounted(async () => {
form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions
// 有效的权限值
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
let perms = props.apiKey.permissions
// 如果是字符串,尝试 JSON.parseRedis 可能返回 "[]" 或 "[\"gemini\"]"
if (typeof perms === 'string') {
if (perms === 'all' || perms === '') {
perms = []
} else if (perms.startsWith('[')) {
try {
perms = JSON.parse(perms)
} catch {
perms = VALID_PERMS.includes(perms) ? [perms] : []
}
} else if (VALID_PERMS.includes(perms)) {
perms = [perms]
} else {
perms = []
}
}
if (Array.isArray(perms)) {
form.permissions = perms
} else if (perms === 'all' || !perms) {
form.permissions = []
} else if (typeof perms === 'string') {
form.permissions = [perms]
// 过滤掉无效值(如 "[]"
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
} else {
form.permissions = []
}

View File

@@ -1,750 +0,0 @@
<template>
<div v-if="authStore.publicStats" class="public-stats-overview">
<!-- 顶部状态栏服务状态 + 平台可用性 -->
<div class="header-section">
<div class="flex items-center gap-2">
<div
class="status-badge"
:class="{
'status-healthy': authStore.publicStats.serviceStatus === 'healthy',
'status-degraded': authStore.publicStats.serviceStatus === 'degraded'
}"
>
<span class="status-dot"></span>
<span class="status-text">{{
authStore.publicStats.serviceStatus === 'healthy' ? '服务正常' : '服务降级'
}}</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
运行 {{ formatUptime(authStore.publicStats.uptime) }}
</span>
</div>
<div class="flex flex-wrap justify-center gap-2 md:justify-end">
<div
v-for="(available, platform) in authStore.publicStats.platforms"
:key="platform"
class="platform-badge"
:class="{ available: available, unavailable: !available }"
>
<i class="mr-1" :class="getPlatformIcon(platform)"></i>
<span>{{ getPlatformName(platform) }}</span>
</div>
</div>
</div>
<!-- 主内容区今日统计 + 模型分布 -->
<div class="main-content">
<!-- 左侧今日统计 -->
<div class="stats-section">
<div class="section-title-left">今日统计</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">
{{ formatNumber(authStore.publicStats.todayStats.requests) }}
</div>
<div class="stat-label">请求数</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.tokens) }}
</div>
<div class="stat-label">Tokens</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.inputTokens) }}
</div>
<div class="stat-label">输入</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ formatTokens(authStore.publicStats.todayStats.outputTokens) }}
</div>
<div class="stat-label">输出</div>
</div>
</div>
</div>
<!-- 右侧模型使用分布 -->
<div
v-if="
authStore.publicStats.showOptions?.modelDistribution &&
authStore.publicStats.modelDistribution?.length > 0
"
class="model-section"
>
<div class="section-title-left">
模型使用分布
<span class="period-label">{{
formatPeriodLabel(authStore.publicStats.modelDistributionPeriod)
}}</span>
</div>
<div class="model-chart-container">
<Doughnut :data="modelChartData" :options="modelChartOptions" />
</div>
</div>
</div>
<!-- 趋势图表三合一双Y轴折线图 -->
<div v-if="hasAnyTrendData" class="chart-section">
<div class="section-title-left">使用趋势近7天</div>
<div class="chart-container">
<Line :data="chartData" :options="chartOptions" />
</div>
<!-- 图例 -->
<div class="chart-legend">
<div v-if="authStore.publicStats.showOptions?.tokenTrends" class="legend-item">
<span class="legend-dot legend-tokens"></span>
<span class="legend-text">Tokens</span>
</div>
<div v-if="authStore.publicStats.showOptions?.apiKeysTrends" class="legend-item">
<span class="legend-dot legend-keys"></span>
<span class="legend-text">活跃 Keys</span>
</div>
<div v-if="authStore.publicStats.showOptions?.accountTrends" class="legend-item">
<span class="legend-dot legend-accounts"></span>
<span class="legend-text">活跃账号</span>
</div>
</div>
</div>
<!-- 暂无趋势数据 -->
<div v-else-if="hasTrendOptionsEnabled" class="empty-state">
<i class="fas fa-chart-line empty-icon"></i>
<p class="empty-text">暂无趋势数据</p>
<p class="empty-hint">数据将在有请求后自动更新</p>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="authStore.publicStatsLoading" class="public-stats-loading">
<div class="loading-spinner"></div>
</div>
<!-- 无数据状态 -->
<div v-else class="public-stats-empty">
<i class="fas fa-chart-pie empty-icon"></i>
<p class="empty-text">暂无统计数据</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Line, Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
)
const authStore = useAuthStore()
// 检查是否有任何趋势选项启用
const hasTrendOptionsEnabled = computed(() => {
const opts = authStore.publicStats?.showOptions
return opts?.tokenTrends || opts?.apiKeysTrends || opts?.accountTrends
})
// 检查是否有实际趋势数据
const hasAnyTrendData = computed(() => {
const stats = authStore.publicStats
if (!stats) return false
const opts = stats.showOptions || {}
const hasTokens = opts.tokenTrends && stats.tokenTrends?.length > 0
const hasKeys = opts.apiKeysTrends && stats.apiKeysTrends?.length > 0
const hasAccounts = opts.accountTrends && stats.accountTrends?.length > 0
return hasTokens || hasKeys || hasAccounts
})
// 模型分布颜色
const modelColors = [
'rgb(99, 102, 241)', // indigo
'rgb(59, 130, 246)', // blue
'rgb(16, 185, 129)', // emerald
'rgb(245, 158, 11)', // amber
'rgb(239, 68, 68)', // red
'rgb(139, 92, 246)', // violet
'rgb(236, 72, 153)', // pink
'rgb(20, 184, 166)' // teal
]
// 模型分布环形图数据
const modelChartData = computed(() => {
const stats = authStore.publicStats
if (!stats?.modelDistribution?.length) {
return { labels: [], datasets: [] }
}
const models = stats.modelDistribution
return {
labels: models.map((m) => formatModelName(m.model)),
datasets: [
{
data: models.map((m) => m.percentage),
backgroundColor: models.map((_, i) => modelColors[i % modelColors.length]),
borderColor: 'transparent',
borderWidth: 0,
hoverOffset: 4
}
]
}
})
// 模型分布环形图选项
const modelChartOptions = computed(() => {
const isDark = document.documentElement.classList.contains('dark')
const textColor = isDark ? 'rgb(156, 163, 175)' : 'rgb(107, 114, 128)'
return {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
color: textColor,
padding: 12,
usePointStyle: true,
pointStyle: 'circle',
font: {
size: 11
},
generateLabels: (chart) => {
const data = chart.data
if (data.labels.length && data.datasets.length) {
return data.labels.map((label, i) => ({
text: `${label} ${data.datasets[0].data[i]}%`,
fillStyle: data.datasets[0].backgroundColor[i],
strokeStyle: 'transparent',
lineWidth: 0,
pointStyle: 'circle',
hidden: false,
index: i
}))
}
return []
}
}
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark ? 'rgb(243, 244, 246)' : 'rgb(17, 24, 39)',
bodyColor: isDark ? 'rgb(209, 213, 219)' : 'rgb(75, 85, 99)',
borderColor: isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(209, 213, 219, 0.5)',
borderWidth: 1,
padding: 10,
cornerRadius: 8,
callbacks: {
label: (context) => {
return ` ${context.label}: ${context.parsed}%`
}
}
}
}
}
})
// 趋势图表数据
const chartData = computed(() => {
const stats = authStore.publicStats
if (!stats) return { labels: [], datasets: [] }
const opts = stats.showOptions || {}
// 获取日期标签(优先使用 tokenTrends
const labels =
stats.tokenTrends?.map((t) => formatDateShort(t.date)) ||
stats.apiKeysTrends?.map((t) => formatDateShort(t.date)) ||
stats.accountTrends?.map((t) => formatDateShort(t.date)) ||
[]
const datasets = []
// Token 趋势左Y轴
if (opts.tokenTrends && stats.tokenTrends?.length > 0) {
datasets.push({
label: 'Tokens',
data: stats.tokenTrends.map((t) => t.tokens),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y',
tension: 0.3,
fill: true,
pointRadius: 3,
pointHoverRadius: 5
})
}
// API Keys 趋势右Y轴
if (opts.apiKeysTrends && stats.apiKeysTrends?.length > 0) {
datasets.push({
label: '活跃 Keys',
data: stats.apiKeysTrends.map((t) => t.activeKeys),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
// 账号趋势右Y轴
if (opts.accountTrends && stats.accountTrends?.length > 0) {
datasets.push({
label: '活跃账号',
data: stats.accountTrends.map((t) => t.activeAccounts),
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
return { labels, datasets }
})
// 图表配置
const chartOptions = computed(() => {
const isDark = document.documentElement.classList.contains('dark')
const textColor = isDark ? 'rgba(156, 163, 175, 1)' : 'rgba(107, 114, 128, 1)'
const gridColor = isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)'
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: isDark ? '#e5e7eb' : '#1f2937',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? 'rgba(75, 85, 99, 0.5)' : 'rgba(229, 231, 235, 1)',
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: function (context) {
let label = context.dataset.label || ''
if (label) {
label += ': '
}
if (context.dataset.yAxisID === 'y') {
label += formatTokens(context.parsed.y)
} else {
label += context.parsed.y
}
return label
}
}
}
},
scales: {
x: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
}
}
},
y: {
type: 'linear',
display: true,
position: 'left',
min: 0,
beginAtZero: true,
title: {
display: true,
text: 'Tokens',
color: 'rgb(59, 130, 246)',
font: {
size: 10
}
},
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
},
callback: function (value) {
return formatTokensShort(value)
}
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
min: 0,
beginAtZero: true,
title: {
display: true,
text: '数量',
color: 'rgb(34, 197, 94)',
font: {
size: 10
}
},
grid: {
drawOnChartArea: false
},
ticks: {
color: textColor,
font: {
size: 10
},
stepSize: 1
}
}
}
}
})
// 格式化运行时间
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
// 格式化 tokens
function formatTokens(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(2) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(2) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(1) + 'K'
}
return tokens.toString()
}
// 格式化 tokens简短版用于Y轴
function formatTokensShort(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(0) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(0) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(0) + 'K'
}
return tokens.toString()
}
// 格式化时间范围标签
function formatPeriodLabel(period) {
const labels = {
today: '今天',
'24h': '过去24小时',
'7d': '过去7天',
'30d': '过去30天',
all: '全部'
}
return labels[period] || labels['today']
}
// 获取平台图标
function getPlatformIcon(platform) {
const icons = {
claude: 'fas fa-robot',
gemini: 'fas fa-gem',
bedrock: 'fab fa-aws',
droid: 'fas fa-microchip'
}
return icons[platform] || 'fas fa-server'
}
// 获取平台名称
function getPlatformName(platform) {
const names = {
claude: 'Claude',
gemini: 'Gemini',
bedrock: 'Bedrock',
droid: 'Droid'
}
return names[platform] || platform
}
// 格式化模型名称
function formatModelName(model) {
if (!model) return 'Unknown'
// 简化长模型名称
const parts = model.split('-')
if (parts.length > 2) {
return parts.slice(0, 2).join('-')
}
return model
}
// 格式化日期(短格式)
function formatDateShort(dateStr) {
if (!dateStr) return ''
const parts = dateStr.split('-')
if (parts.length === 3) {
return `${parts[1]}/${parts[2]}`
}
return dateStr
}
</script>
<style scoped>
.public-stats-overview {
@apply rounded-xl border border-gray-200/50 bg-white/80 p-4 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80 md:p-6;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 顶部状态栏 */
.header-section {
@apply mb-4 flex flex-col items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:flex-row md:pb-6;
}
/* 主内容区 */
.main-content {
@apply grid gap-4 md:grid-cols-2 md:gap-6;
}
/* 统计区块 */
.stats-section {
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
}
/* 模型区块 */
.model-section {
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
}
/* 图表区块 */
.chart-section {
@apply mt-4 rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30 md:mt-6;
}
/* 章节标题(居中) */
.section-title {
@apply mb-2 text-center text-xs text-gray-600 dark:text-gray-400;
}
/* 章节标题(左对齐) */
.section-title-left {
@apply mb-3 text-sm font-medium text-gray-700 dark:text-gray-300;
}
/* 时间范围标签 */
.period-label {
@apply ml-1 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-normal text-gray-500 dark:bg-gray-600 dark:text-gray-400;
}
/* 状态徽章 */
.status-badge {
@apply inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium;
}
.status-healthy {
@apply bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400;
}
.status-degraded {
@apply bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.status-dot {
@apply inline-block h-2 w-2 rounded-full;
}
.status-healthy .status-dot {
@apply bg-green-500;
animation: pulse 2s infinite;
}
.status-degraded .status-dot {
@apply bg-yellow-500;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 平台徽章 */
.platform-badge {
@apply inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium transition-all;
}
.platform-badge.available {
@apply bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400;
}
.platform-badge.unavailable {
@apply bg-gray-100 text-gray-400 line-through dark:bg-gray-800 dark:text-gray-600;
}
/* 统计网格 */
.stats-grid {
@apply grid grid-cols-2 gap-3;
}
.stat-item {
@apply rounded-lg bg-white p-3 text-center shadow-sm dark:bg-gray-800/50;
}
.stat-value {
@apply text-lg font-bold text-gray-900 dark:text-gray-100 md:text-xl;
}
.stat-label {
@apply text-xs text-gray-500 dark:text-gray-400;
}
/* 模型分布环形图容器 */
.model-chart-container {
height: 160px;
}
@media (min-width: 768px) {
.model-chart-container {
height: 180px;
}
}
/* 趋势图表容器 */
.chart-container {
@apply rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50;
height: 180px;
}
/* 图例 */
.chart-legend {
@apply mt-2 flex flex-wrap items-center justify-center gap-4;
}
.legend-item {
@apply flex items-center gap-1.5;
}
.legend-dot {
@apply inline-block h-2.5 w-2.5 rounded-full;
}
.legend-tokens {
@apply bg-blue-500;
}
.legend-keys {
@apply bg-green-500;
}
.legend-accounts {
@apply bg-purple-500;
}
.legend-text {
@apply text-xs text-gray-600 dark:text-gray-400;
}
/* 空状态 */
.empty-state {
@apply flex flex-col items-center justify-center rounded-lg bg-gray-50 py-6 dark:bg-gray-700/50;
}
.empty-icon {
@apply mb-2 text-2xl text-gray-400 dark:text-gray-500;
}
.empty-text {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.empty-hint {
@apply mt-1 text-xs text-gray-400 dark:text-gray-500;
}
/* 加载状态 */
.public-stats-loading {
@apply flex items-center justify-center py-8;
}
.public-stats-empty {
@apply flex flex-col items-center justify-center rounded-xl border border-gray-200/50 bg-white/80 py-8 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80;
}
.loading-spinner {
@apply h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent;
}
</style>

View File

@@ -14,15 +14,10 @@ export const useAuthStore = defineStore('auth', () => {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
faviconData: '',
publicStatsEnabled: false
faviconData: ''
})
const oemLoading = ref(true)
// 公开统计数据
const publicStats = ref(null)
const publicStatsLoading = ref(false)
// 计算属性
const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value)
const token = computed(() => authToken.value)
@@ -109,11 +104,6 @@ export const useAuthStore = defineStore('auth', () => {
if (result.data.siteName) {
document.title = `${result.data.siteName} - 管理后台`
}
// 如果公开统计已启用,加载统计数据
if (result.data.publicStatsEnabled) {
loadPublicStats()
}
}
} catch (error) {
console.error('加载OEM设置失败:', error)
@@ -122,23 +112,6 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function loadPublicStats() {
publicStatsLoading.value = true
try {
const result = await apiClient.get('/admin/public-stats')
if (result.success && result.enabled && result.data) {
publicStats.value = result.data
} else {
publicStats.value = null
}
} catch (error) {
console.error('加载公开统计失败:', error)
publicStats.value = null
} finally {
publicStatsLoading.value = false
}
}
return {
// 状态
isLoggedIn,
@@ -148,8 +121,6 @@ export const useAuthStore = defineStore('auth', () => {
loginLoading,
oemSettings,
oemLoading,
publicStats,
publicStatsLoading,
// 计算属性
isAuthenticated,
@@ -160,7 +131,6 @@ export const useAuthStore = defineStore('auth', () => {
login,
logout,
checkAuth,
loadOemSettings,
loadPublicStats
loadOemSettings
}
})

View File

@@ -9,12 +9,6 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '',
siteIconData: '',
showAdminButton: true, // 控制管理后台按钮的显示
publicStatsEnabled: false, // 是否在首页显示公开统计概览
publicStatsShowModelDistribution: true,
publicStatsModelDistributionPeriod: 'today', // 时间范围: today, 24h, 7d, 30d, all
publicStatsShowTokenTrends: false,
publicStatsShowApiKeysTrends: false,
publicStatsShowAccountTrends: false,
updatedAt: null
})
@@ -72,7 +66,6 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '',
siteIconData: '',
showAdminButton: true,
publicStatsEnabled: false,
updatedAt: null
}

View File

@@ -797,11 +797,19 @@
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
@@ -1476,11 +1484,17 @@
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>

View File

@@ -6,13 +6,7 @@
<LogoTitle
:loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="
currentTab === 'stats'
? 'API Key 使用统计'
: currentTab === 'overview'
? '服务状态概览'
: '使用教程'
"
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
:title="oemSettings.siteName"
/>
<div class="flex items-center gap-2 md:gap-4">
@@ -55,13 +49,6 @@
<div
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
>
<button
:class="['tab-pill-button', currentTab === 'overview' ? 'active' : '']"
@click="switchToOverview"
>
<i class="fas fa-tachometer-alt mr-1 md:mr-2" />
<span class="text-sm md:text-base">状态概览</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
@click="currentTab = 'stats'"
@@ -80,11 +67,6 @@
</div>
</div>
<!-- 状态概览内容 -->
<div v-if="currentTab === 'overview'" class="tab-content">
<PublicStatsOverview />
</div>
<!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content">
<!-- API Key 输入区域 -->
@@ -192,7 +174,6 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme'
import { useAuthStore } from '@/stores/auth'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
@@ -203,15 +184,13 @@ import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import TutorialView from './TutorialView.vue'
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
import PublicStatsOverview from '@/components/common/PublicStatsOverview.vue'
const route = useRoute()
const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore()
const authStore = useAuthStore()
// 当前标签页 - 默认显示状态概览
const currentTab = ref('overview')
// 当前标签页
const currentTab = ref('stats')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
@@ -244,12 +223,6 @@ const closeTestModal = () => {
showTestModal.value = false
}
// 切换到状态概览并加载数据
const switchToOverview = () => {
currentTab.value = 'overview'
authStore.loadPublicStats()
}
// 处理键盘快捷键
const handleKeyDown = (event) => {
// Ctrl/Cmd + Enter 查询
@@ -276,9 +249,6 @@ onMounted(() => {
// 加载 OEM 设置
loadOemSettings()
// 默认加载公开统计数据
authStore.loadPublicStats()
// 检查 URL 参数
const urlApiId = route.query.apiId
const urlApiKey = route.query.apiKey

View File

@@ -5,7 +5,6 @@
<ThemeToggle mode="dropdown" />
</div>
<!-- 登录卡片 -->
<div
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
>

View File

@@ -48,18 +48,6 @@
<i class="fas fa-robot mr-2"></i>
Claude 转发
</button>
<button
:class="[
'border-b-2 pb-2 text-sm font-medium transition-colors',
activeSection === 'publicStats'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="activeSection = 'publicStats'"
>
<i class="fas fa-chart-line mr-2"></i>
公开统计
</button>
</nav>
</div>
@@ -1037,158 +1025,6 @@
</div>
</div>
</div>
<!-- 公开统计设置部分 -->
<div v-show="activeSection === 'publicStats'">
<div class="rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-md"
>
<i class="fas fa-chart-line"></i>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
公开统计概览
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
配置未登录用户可见的统计数据
</p>
</div>
</div>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="oemSettings.publicStatsEnabled"
class="peer sr-only"
type="checkbox"
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-green-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
oemSettings.publicStatsEnabled ? '已启用' : '已禁用'
}}</span>
</label>
</div>
<!-- 数据显示选项 -->
<div
v-if="oemSettings.publicStatsEnabled"
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-eye mr-2 text-gray-400"></i>
选择要公开显示的数据
</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="rounded-lg border border-gray-200 bg-white p-3 transition-colors dark:border-gray-600 dark:bg-gray-800"
>
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="oemSettings.publicStatsShowModelDistribution"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>模型使用分布</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示各模型的使用占比</p>
</div>
</label>
<div v-if="oemSettings.publicStatsShowModelDistribution" class="mt-3 pl-7">
<div class="mb-1.5 text-xs text-gray-500 dark:text-gray-400">时间范围</div>
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700/50">
<button
v-for="option in modelDistributionPeriodOptions"
:key="option.value"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
:class="
oemSettings.publicStatsModelDistributionPeriod === option.value
? 'bg-white text-green-600 shadow-sm dark:bg-gray-600 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
"
type="button"
@click="oemSettings.publicStatsModelDistributionPeriod = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowTokenTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Token 使用趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的Token使用量</p>
</div>
</label>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowApiKeysTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>API Keys 活跃趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">
显示近7天的活跃API Key数量
</p>
</div>
</label>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<input
v-model="oemSettings.publicStatsShowAccountTrends"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>账号活跃趋势</span
>
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的活跃账号数量</p>
</div>
</label>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex items-center justify-between">
<div class="flex gap-3">
<button
class="btn btn-primary px-6 py-3"
:class="{ 'cursor-not-allowed opacity-50': saving }"
:disabled="saving"
@click="saveOemSettings"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2" />
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-clock mr-1" />
最后更新{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1786,15 +1622,6 @@ defineOptions({
const settingsStore = useSettingsStore()
const { loading, saving, oemSettings } = storeToRefs(settingsStore)
// 模型使用分布时间范围选项
const modelDistributionPeriodOptions = [
{ value: 'today', label: '今天' },
{ value: '24h', label: '24小时' },
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: 'all', label: '全部' }
]
// 组件refs
const iconFileInput = ref()
@@ -2640,14 +2467,7 @@ const saveOemSettings = async () => {
siteName: oemSettings.value.siteName,
siteIcon: oemSettings.value.siteIcon,
siteIconData: oemSettings.value.siteIconData,
showAdminButton: oemSettings.value.showAdminButton,
publicStatsEnabled: oemSettings.value.publicStatsEnabled,
publicStatsShowModelDistribution: oemSettings.value.publicStatsShowModelDistribution,
publicStatsModelDistributionPeriod:
oemSettings.value.publicStatsModelDistributionPeriod || 'today',
publicStatsShowTokenTrends: oemSettings.value.publicStatsShowTokenTrends,
publicStatsShowApiKeysTrends: oemSettings.value.publicStatsShowApiKeysTrends,
publicStatsShowAccountTrends: oemSettings.value.publicStatsShowAccountTrends
showAdminButton: oemSettings.value.showAdminButton
}
const result = await settingsStore.saveOemSettings(settings)
if (result && result.success) {