feat: 添加批量删除、最近活动、界面优化等功能

This commit is contained in:
lulistart
2026-02-18 02:50:40 +08:00
parent 5e3cbb0eca
commit d5288035d4
31 changed files with 5899 additions and 139 deletions

View File

@@ -6,3 +6,13 @@ TOKEN_FILE=./token.json
# 模型列表文件路径
MODELS_FILE=./models.json
# HTTP 代理(可选,用于 token 刷新)
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# 负载均衡策略(可选)
# round-robin: 轮询(默认)
# random: 随机选择
# least-used: 最少使用(选择请求次数最少的账号)
LOAD_BALANCE_STRATEGY=round-robin

371
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,371 @@
# GPT2API Node - 部署文档
## 🎉 系统功能
### 核心功能
- ✅ OpenAI Codex 反向代理服务
- ✅ 完整的 Web 管理后台
- ✅ 多账号管理和批量操作
- ✅ 自动 Token 刷新机制
- ✅ 负载均衡(轮询/随机/最少使用)
- ✅ API Key 管理和认证
- ✅ 请求统计和数据分析
- ✅ 实时活动记录
### 管理后台功能
#### 1. 仪表盘
- 系统概览和实时统计
- API Keys 数量
- Token 账号数量
- 今日请求数和成功率
- 最近活动记录API请求、账号添加等
#### 2. API Keys 管理
- 创建和管理 API Keys
- 查看使用统计
- 启用/禁用 API Key
- 删除 API Key
#### 3. 账号管理
- **批量导入 Token**(支持 JSON 文件和多文件)
- **批量删除账号**(支持多选)
- 手动添加账号
- 查看账号额度和使用情况
- 刷新账号额度(单个/全部)
- 负载均衡策略配置
- 账号总数实时显示
#### 4. 数据分析
- **请求量趋势图表**(基于真实数据)
- 模型使用分布
- 账号详细统计(带滚动条)
- API 请求日志(带滚动条)
- 支持时间范围筛选24小时/7天/30天
#### 5. 系统设置
- 修改管理员密码
- 负载均衡策略设置
- GitHub 项目链接
## 🚀 快速部署
### 1. 环境要求
- Node.js 16+
- npm 或 yarn
### 2. 安装步骤
```bash
# 克隆项目
git clone https://github.com/lulistart/gpt2api-node.git
cd gpt2api-node
# 安装依赖
npm install
# 初始化数据库
npm run init-db
# 启动服务
npm start
```
### 3. 访问管理后台
打开浏览器访问:`http://localhost:3000/admin`
默认管理员账户:
- 用户名:`admin`
- 密码:`admin123`
**重要**:首次登录后请立即修改密码!
## 📁 项目结构
```
gpt2api-node/
├── src/
│ ├── config/
│ │ └── database.js # 数据库配置和初始化
│ ├── middleware/
│ │ └── auth.js # 认证中间件
│ ├── models/
│ │ └── index.js # 数据模型User、ApiKey、Token、ApiLog
│ ├── routes/
│ │ ├── auth.js # 认证路由(登录、登出、修改密码)
│ │ ├── apiKeys.js # API Keys 管理路由
│ │ ├── tokens.js # Tokens 管理路由(含批量删除)
│ │ ├── stats.js # 统计路由(含最近活动)
│ │ └── settings.js # 设置路由
│ ├── scripts/
│ │ └── initDatabase.js # 数据库初始化脚本
│ ├── index.js # 主入口文件
│ ├── tokenManager.js # Token 管理模块
│ └── proxyHandler.js # 代理处理模块
├── public/
│ └── admin/
│ ├── login.html # 登录页面
│ ├── index.html # 管理后台主页
│ └── js/
│ └── admin.js # 管理后台脚本
├── database/
│ └── app.db # SQLite 数据库
├── models.json # 模型配置
├── package.json
├── README.md
└── DEPLOYMENT.md
```
## 🔧 配置说明
### 环境变量
创建 `.env` 文件:
```env
# 服务端口
PORT=3000
# Session 密钥(生产环境必须修改)
SESSION_SECRET=your-random-secret-key-change-in-production
# 负载均衡策略round-robin轮询、random随机、least-used最少使用
LOAD_BALANCE_STRATEGY=round-robin
# 模型配置文件
MODELS_FILE=./models.json
# 数据库路径
DATABASE_PATH=./database/app.db
```
### 负载均衡策略
支持三种策略:
1. **round-robin轮询**:按顺序依次使用每个账号,默认策略
2. **random随机**:随机选择一个可用账号
3. **least-used最少使用**:选择请求次数最少的账号
可通过环境变量或管理后台配置。
## 📊 数据库结构
### users 表
- 管理员账户信息
- 字段id, username, password_hash, created_at
### api_keys 表
- API 密钥管理
- 字段id, name, key, is_active, usage_count, last_used_at, created_at
### tokens 表
- OpenAI Token 账户
- 字段id, name, email, account_id, access_token, refresh_token, id_token, expired_at, last_refresh_at, is_active, total_requests, success_requests, failed_requests, quota_total, quota_used, quota_remaining, created_at
### api_logs 表
- API 请求日志
- 字段id, api_key_id, token_id, model, endpoint, status_code, error_message, created_at
## 🔐 安全建议
### 生产环境配置
1. **修改默认密码**
- 首次登录后立即修改管理员密码
- 使用强密码至少8位包含大小写字母、数字、特殊字符
2. **设置环境变量**
```bash
SESSION_SECRET=$(openssl rand -base64 32)
```
3. **启用 HTTPS**
- 使用 Nginx 或 Caddy 作为反向代理
- 配置 SSL 证书
- 设置 `cookie.secure = true`
4. **防火墙配置**
- 只开放必要的端口
- 限制管理后台访问 IP
5. **定期备份**
- 备份 `database/app.db` 数据库文件
- 备份环境变量配置
### Nginx 反向代理示例
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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;
}
}
```
## 🎯 使用指南
### 1. 创建 API Key
1. 登录管理后台
2. 进入 **API Keys** 页面
3. 点击 **创建 API Key**
4. 输入名称(可选)
5. 复制生成的 API Key只显示一次
### 2. 导入 Token 账号
#### 方式一:批量导入 JSON
1. 准备 JSON 文件:
```json
[
{
"access_token": "your_access_token",
"refresh_token": "your_refresh_token",
"id_token": "your_id_token",
"account_id": "account_id",
"email": "email@example.com",
"name": "账号名称"
}
]
```
2. 进入 **账号管理** 页面
3. 点击 **导入 JSON**
4. 选择文件或粘贴 JSON 内容
5. 点击 **预览导入**
6. 确认后点击 **确认导入**
#### 方式二:手动添加
1. 进入 **账号管理** 页面
2. 点击 **手动添加**
3. 填写 Access Token 和 Refresh Token
4. 点击 **添加**
### 3. 批量删除账号
1. 进入 **账号管理** 页面
2. 勾选要删除的账号
3. 点击 **删除选中** 按钮
4. 确认删除
### 4. 使用 API
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.3-codex",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
## 🐛 故障排除
### 无法访问管理后台
1. 检查服务是否启动:`npm start`
2. 检查端口是否被占用:`netstat -ano | findstr :3000`
3. 检查防火墙设置
### 数据库初始化失败
```bash
# 删除旧数据库
rm database/app.db
# 重新初始化
npm run init-db
```
### Token 刷新失败
1. 检查网络连接
2. 确认 refresh_token 是否有效
3. 重新导入新的 token
### API 请求失败
1. 检查 API Key 是否正确
2. 确保有可用的 Token 账号
3. 查看管理后台的请求日志
4. 检查账号是否被禁用
### 请求趋势图表显示异常
- 图表数据基于 `api_logs` 表的真实请求记录
- 如果没有请求记录,图表会显示为空
- 发送几次 API 请求后刷新页面查看
## 📝 维护建议
1. **定期备份数据库**
```bash
cp database/app.db database/app.db.backup.$(date +%Y%m%d)
```
2. **监控日志**
- 查看终端输出
- 检查请求日志
3. **更新依赖**
```bash
npm update
```
4. **清理旧日志**
- 定期清理 `api_logs` 表中的旧记录
## 🔄 更新日志
### v2.0.0 (2026-02-17)
- ✅ 添加批量删除账号功能
- ✅ 添加仪表盘最近活动记录
- ✅ 添加 GitHub 项目链接
- ✅ 移除前台页面,根路径重定向到管理后台
- ✅ 修复模型列表(删除不存在的 gpt-5.3-codex-spark
- ✅ 优化终端日志输出
- ✅ 账号管理页面显示账号总数
- ✅ 账号详细统计和请求日志添加滚动条
- ✅ 修复请求趋势图表,使用真实数据
### v1.0.0
- ✅ 基础管理系统
- ✅ API Keys 管理
- ✅ Tokens 管理
- ✅ 数据统计
## 📞 支持
- GitHub: https://github.com/lulistart/gpt2api-node
- Issues: https://github.com/lulistart/gpt2api-node/issues
## 📄 许可证
MIT License

270
README.md
View File

@@ -1,15 +1,55 @@
# GPT2API Node
基于 Node.js + Express 的 OpenAI Codex 反向代理服务,支持 JSON 文件导入 token自动刷新 token提供 OpenAI 兼容的 API 接口。
基于 Node.js + Express 的 OpenAI Codex 反向代理服务,支持多账号管理、自动刷新 token、负载均衡,提供 OpenAI 兼容的 API 接口和完整的管理后台
## 界面预览
<table>
<tr>
<td width="50%">
<img src="screenshots/管理员登录.png" alt="管理员登录" />
<p align="center">管理员登录</p>
</td>
<td width="50%">
<img src="screenshots/仪表盘.png" alt="仪表盘" />
<p align="center">仪表盘</p>
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/API keys.png" alt="API Keys管理" />
<p align="center">API Keys 管理</p>
</td>
<td width="50%">
<img src="screenshots/账号管理.png" alt="账号管理" />
<p align="center">账号管理</p>
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/数据分析.png" alt="数据分析" />
<p align="center">数据分析</p>
</td>
<td width="50%">
<img src="screenshots/系统设置.png" alt="系统设置" />
<p align="center">系统设置</p>
</td>
</tr>
</table>
## 功能特性
- ✅ OpenAI Codex 反向代理
- ✅ 完整的 Web 管理后台
- ✅ 多账号管理和批量导入
- ✅ 自动 Token 刷新机制
- ✅ 负载均衡(轮询/随机/最少使用)
- ✅ API Key 管理和认证
- ✅ 请求统计和数据分析
- ✅ 支持流式和非流式响应
- ✅ OpenAI API 兼容接口
-JSON 文件导入 Token
-简单易用的配置
-批量删除账号功能
-实时活动记录
## 快速开始
@@ -20,37 +60,17 @@ cd gpt2api-node
npm install
```
### 2. 配置 Token
从 CLIProxyAPI 或其他来源获取 token 文件,复制到项目根目录并命名为 `token.json`
```json
{
"id_token": "your_id_token_here",
"access_token": "your_access_token_here",
"refresh_token": "your_refresh_token_here",
"account_id": "your_account_id",
"email": "your_email@example.com",
"type": "codex",
"expired": "2026-12-31T23:59:59.000Z",
"last_refresh": "2026-01-01T00:00:00.000Z"
}
```
### 3. 配置环境变量(可选)
复制 `.env.example``.env` 并修改配置:
### 2. 初始化数据库
```bash
cp .env.example .env
npm run init-db
```
```env
PORT=3000
TOKEN_FILE=./token.json
```
默认管理员账户:
- 用户名:`admin`
- 密码:`admin123`
### 4. 启动服务
### 3. 启动服务
```bash
npm start
@@ -62,16 +82,71 @@ npm start
npm run dev
```
### 4. 访问管理后台
打开浏览器访问:`http://localhost:3000/admin`
使用默认账户登录后,请立即修改密码。
## 管理后台功能
### 仪表盘
- 系统概览和实时统计
- API Keys 数量
- Token 账号数量
- 今日请求数和成功率
- 最近活动记录
### API Keys 管理
- 创建和管理 API Keys
- 查看使用统计
- 启用/禁用 API Key
### 账号管理
- 批量导入 Token支持 JSON 文件)
- 手动添加账号
- 批量删除账号
- 查看账号额度和使用情况
- 刷新账号额度
- 负载均衡策略配置
### 数据分析
- 请求量趋势图表
- 模型使用分布
- 账号详细统计
- API 请求日志
### 系统设置
- 修改管理员密码
- 负载均衡策略设置
## 负载均衡策略
支持三种负载均衡策略:
1. **轮询round-robin**:按顺序依次使用每个账号
2. **随机random**:随机选择一个可用账号
3. **最少使用least-used**:选择请求次数最少的账号
可在管理后台的账号管理页面或通过环境变量配置。
## API 接口
### 聊天完成接口
**端点**: `POST /v1/chat/completions`
**请求头**:
```
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
```
**请求示例**:
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.3-codex",
@@ -86,6 +161,7 @@ curl http://localhost:3000/v1/chat/completions \
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.3-codex",
@@ -115,7 +191,6 @@ curl http://localhost:3000/health
## 支持的模型
- `gpt-5.3-codex` - GPT 5.3 Codex最新
- `gpt-5.3-codex-spark` - GPT 5.3 Codex Spark超快速编码模型
- `gpt-5.2` - GPT 5.2
- `gpt-5.2-codex` - GPT 5.2 Codex
- `gpt-5.1` - GPT 5.1
@@ -130,12 +205,12 @@ curl http://localhost:3000/health
Cherry Studio 是一个支持多种 AI 服务的桌面客户端。配置步骤:
### 1. 启动代理服务
### 1. 创建 API Key
```bash
cd gpt2api-node
npm start
```
1. 访问管理后台:`http://localhost:3000/admin`
2. 进入 **API Keys** 页面
3. 点击 **创建 API Key**
4. 复制生成的 API Key只显示一次
### 2. 在 Cherry Studio 中配置
@@ -145,22 +220,13 @@ npm start
4. 填写配置:
- **名称**: GPT2API Node或自定义名称
- **API 地址**: `http://localhost:3000/v1`
- **API Key**: 随意填写(如 `dummy`),不会被验证
- **API Key**: 粘贴刚才创建的 API Key
- **模型**: 选择或手动输入模型名称(如 `gpt-5.3-codex`
### 3. 开始使用
配置完成后,在 Cherry Studio 中选择刚才添加的提供商和模型,即可开始对话。
### 可用模型列表
在 Cherry Studio 中可以使用以下任意模型:
- `gpt-5.3-codex` - 推荐,最新版本
- `gpt-5.3-codex-spark` - 超快速编码
- `gpt-5.2-codex` - 稳定版本
- `gpt-5.1-codex` - 较旧版本
- 其他 GPT-5 系列模型
## 使用示例
### Python
@@ -170,7 +236,7 @@ import openai
client = openai.OpenAI(
base_url="http://localhost:3000/v1",
api_key="dummy" # 不需要真实的 API key
api_key="YOUR_API_KEY"
)
response = client.chat.completions.create(
@@ -190,7 +256,7 @@ import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'http://localhost:3000/v1',
apiKey: 'dummy'
apiKey: 'YOUR_API_KEY'
});
const response = await client.chat.completions.create({
@@ -203,41 +269,47 @@ const response = await client.chat.completions.create({
console.log(response.choices[0].message.content);
```
### cURL
## Token 管理
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.3-codex",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"}
]
}'
### 批量导入
1. 准备 JSON 文件,格式如下:
```json
[
{
"access_token": "your_access_token",
"refresh_token": "your_refresh_token",
"id_token": "your_id_token",
"account_id": "account_id",
"email": "email@example.com",
"name": "账号名称"
}
]
```
## Token 管理
2. 在管理后台的账号管理页面点击 **导入 JSON**
3. 选择文件或粘贴 JSON 内容
4. 预览后确认导入
### 手动添加
在管理后台的账号管理页面点击 **手动添加**,填写必要信息。
### 自动刷新
服务会自动检测 token 是否过期(提前 5 分钟),并在需要时自动刷新。刷新后的 token 会自动保存到文件中。
服务会自动检测 token 是否过期,并在需要时自动刷新。
### 手动导入
## 环境变量配置
如果你有从 CLIProxyAPI 导出的 token 文件,直接复制为 `token.json` 即可使用。
创建 `.env` 文件:
### Token 文件格式
Token 文件必须包含以下字段:
- `access_token`: 访问令牌
- `refresh_token`: 刷新令牌
- `id_token`: ID 令牌(可选)
- `account_id`: 账户 ID可选
- `email`: 邮箱(可选)
- `expired`: 过期时间ISO 8601 格式)
- `type`: 类型(固定为 "codex"
```env
PORT=3000
SESSION_SECRET=your-secret-key-change-in-production
LOAD_BALANCE_STRATEGY=round-robin
MODELS_FILE=./models.json
```
## 项目结构
@@ -246,34 +318,66 @@ gpt2api-node/
├── src/
│ ├── index.js # 主服务器文件
│ ├── tokenManager.js # Token 管理模块
── proxyHandler.js # 代理处理模块
── proxyHandler.js # 代理处理模块
│ ├── config/
│ │ └── database.js # 数据库配置
│ ├── models/
│ │ └── index.js # 数据模型
│ ├── routes/
│ │ ├── auth.js # 认证路由
│ │ ├── apiKeys.js # API Keys 路由
│ │ ├── tokens.js # Tokens 路由
│ │ ├── stats.js # 统计路由
│ │ └── settings.js # 设置路由
│ ├── middleware/
│ │ └── auth.js # 认证中间件
│ └── scripts/
│ └── initDatabase.js # 数据库初始化脚本
├── public/
│ └── admin/ # 管理后台前端
│ ├── index.html
│ ├── login.html
│ └── js/
│ └── admin.js
├── database/
│ └── app.db # SQLite 数据库
├── models.json # 模型配置
├── package.json
├── .env.example
├── token.example.json
├── .gitignore
└── README.md
```
## 注意事项
1. **Token 安全**: 请妥善保管 `token.json` 文件,不要提交到版本控制系统
1. **安全**:
- 首次登录后请立即修改管理员密码
- 妥善保管 API Keys
- 生产环境请使用 HTTPS
2. **网络要求**: 需要能够访问 `chatgpt.com``auth.openai.com`
3. **Token 有效期**: Token 会自动刷新,但如果 refresh_token 失效,需要重新获取
4. **并发限制**: 根据 OpenAI 账户限制,注意控制并发请求数量
## 故障排除
### Token 加载失败
### 无法访问管理后台
确保 `token.json` 文件存在且格式正确,参考 `token.example.json`
确保服务已启动,访问 `http://localhost:3000/admin`
### 数据库初始化失败
删除 `database/app.db` 文件,重新运行 `npm run init-db`
### Token 刷新失败
可能是 refresh_token 已过期,需要重新从 CLIProxyAPI 获取新的 token
可能是 refresh_token 已过期,需要重新导入新的 token
### 代理请求失败
### API 请求失败
检查网络连接,确保能够访问 OpenAI 服务。
1. 检查 API Key 是否正确
2. 确保有可用的 Token 账号
3. 查看管理后台的请求日志
## 许可证

99
UI.md Normal file
View File

@@ -0,0 +1,99 @@
🎨 UI 设计风格分析
kiro-unified-manager 的 UI 特点:
现代简约风格 - 使用 Tailwind CSS干净的卡片布局
专业的管理后台风格 - 类似企业级 SaaS 产品
优雅的动画效果 - 淡入淡出、滑动过渡
响应式设计 - 适配不同屏幕尺寸
直观的状态指示 - 颜色编码的状态标签
丰富的交互反馈 - 悬停效果、加载状态
📝 UI 提示词模板
基础风格提示词
请设计一个现代化的管理后台界面,具有以下特点:
**整体风格:**
- 使用简约现代的设计语言,类似 Tailwind CSS 风格
- 采用卡片式布局,白色背景配合浅灰色边框
- 整体配色以蓝色系为主色调(#3b82f6),搭配灰色系辅助色
- 界面干净整洁,留白充足,视觉层次清晰
**布局结构:**
- 顶部导航栏:白色背景,包含 Logo、导航标签和用户信息
- 主内容区域:浅灰色背景(#f9fafb),居中布局,最大宽度限制
- 卡片容器:白色背景,圆角边框,轻微阴影效果
**交互元素:**
- 按钮:圆角设计,主要按钮使用蓝色,次要按钮使用灰色
- 表格:斑马纹效果,悬停高亮
- 表单:简洁的输入框,聚焦时蓝色边框
- 状态标签:彩色圆角标签,不同状态使用不同颜色
**动画效果:**
- 页面切换使用淡入淡出效果
- 按钮悬停有颜色过渡
- 模态框弹出有缩放动画
- 通知消息从右上角滑入
**响应式设计:**
- 移动端友好,自适应布局
- 表格在小屏幕上可横向滚动
- 导航在移动端可折叠
具体组件提示词
**导航栏设计:**
创建一个顶部导航栏白色背景高度64px包含
- 左侧:品牌 Logo + 图标,使用蓝色主题
- 中间:水平导航标签,激活状态有蓝色下边框
- 右侧:用户头像 + 下拉菜单
**数据表格设计:**
设计一个现代化数据表格:
- 表头使用浅灰色背景
- 行间隔使用斑马纹效果
- 悬停行高亮显示
- 状态列使用彩色圆角标签
- 操作列包含彩色文字链接
**模态框设计:**
创建优雅的模态框:
- 半透明黑色遮罩背景
- 白色圆角内容区域
- 顶部标题栏带关闭按钮
- 底部操作按钮区域
- 弹出时有缩放动画效果
**通知消息设计:**
设计右上角通知组件:
- 固定定位在右上角
- 彩色背景(成功绿色、错误红色、信息蓝色)
- 白色文字,包含图标
- 自动消失,滑入滑出动画
**按钮系统:**
- 主要按钮:蓝色背景,白色文字,悬停变深蓝
- 次要按钮:灰色边框,灰色文字,悬停背景变浅灰
- 危险按钮:红色背景,用于删除等操作
- 禁用状态50% 透明度,不可点击
颜色系统提示词
**配色方案:**
- 主色调:蓝色系 (#3b82f6, #2563eb, #1d4ed8)
- 成功色:绿色系 (#10b981, #059669)
- 警告色:黄色系 (#f59e0b, #d97706)
- 错误色:红色系 (#ef4444, #dc2626)
- 中性色:灰色系 (#6b7280, #4b5563, #374151)
- 背景色:浅灰色 (#f9fafb, #f3f4f6)
- 文字色:深灰色 (#111827, #374151, #6b7280)
动画效果提示词
**动画系统:**
- 页面过渡300ms 淡入淡出效果
- 按钮悬停200ms 颜色过渡
- 模态框:弹出时 0.3s 缩放动画,从 0.95 到 1.0
- 通知消息从右侧滑入3秒后自动滑出
- 加载状态:旋转动画的 spinner 图标
- 表格行悬停:背景色平滑过渡
🚀 使用建议
当你要创建类似风格的网站时,可以这样使用提示词:
基础搭建:先使用"基础风格提示词"建立整体框架
组件细化:根据需要使用"具体组件提示词"
样式调整:参考"颜色系统"和"动画效果"进行微调
技术栈:建议使用 Tailwind CSS + Vue.js 或 React

BIN
database/app.db Normal file

Binary file not shown.

View File

@@ -5,12 +5,6 @@
"created": 1770307200,
"owned_by": "openai"
},
{
"id": "gpt-5.3-codex-spark",
"object": "model",
"created": 1770912000,
"owned_by": "openai"
},
{
"id": "gpt-5.2",
"object": "model",

1354
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,27 @@
{
"name": "gpt2api-node",
"version": "1.0.0",
"description": "OpenAI Codex reverse proxy with token management",
"description": "OpenAI Codex reverse proxy with admin management system",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
"dev": "node --watch src/index.js",
"init-db": "node src/scripts/initDatabase.js"
},
"keywords": ["openai", "codex", "proxy", "api"],
"keywords": ["openai", "codex", "proxy", "api", "admin"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"dotenv": "^16.3.1"
"dotenv": "^16.3.1",
"better-sqlite3": "^9.2.2",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"express-session": "^1.17.3",
"multer": "^1.4.5-lts.1",
"cookie-parser": "^1.4.6",
"https-proxy-agent": "^7.0.2"
}
}

View File

@@ -0,0 +1,306 @@
# GPT2API Node - API 网关后台管理系统架构设计
## 1. 系统概述
构建一个专业的 API 网关后台管理系统,用于管理 OpenAI Codex 代理服务的用户、API Keys 和 Token 账户。
## 2. 核心功能模块
### 2.1 用户认证模块
- 管理员登录/登出
- 密码修改
- Session 管理
- JWT Token 认证
### 2.2 API Key 管理模块
- 创建 API Key自动生成
- 删除 API Key
- 列表展示(包含创建时间、最后使用时间、使用次数)
- API Key 权限控制(可选:限流、配额)
### 2.3 Token 账户管理模块
- JSON 文件导入(支持 CLIProxyAPI 格式)
- 账户列表展示
- 账户状态监控Token 过期时间、刷新状态)
- 账户删除
- 自动 Token 刷新
### 2.4 统计监控模块
- API 调用统计
- 使用量统计
- 错误日志
- 实时状态监控
## 3. 技术架构
### 3.1 后端技术栈
```
- Node.js + Express
- SQLite轻量级数据库
- bcrypt密码加密
- jsonwebtokenJWT 认证)
- multer文件上传
- express-session会话管理
```
### 3.2 前端技术栈
```
- HTML5 + TailwindCSS + DaisyUI
- Vanilla JavaScript无框架保持轻量
- Fetch APIHTTP 请求)
```
### 3.3 数据库设计
#### 表结构
**users 表**(管理员用户)
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, -- bcrypt 加密
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**api_keys 表**API 密钥)
```sql
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL, -- sk-xxx 格式
name TEXT, -- 密钥名称/备注
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
usage_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1
);
```
**tokens 表**OpenAI Token 账户)
```sql
CREATE TABLE tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, -- 账户名称/备注
email TEXT,
account_id TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
id_token TEXT,
expired_at DATETIME,
last_refresh_at DATETIME,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**api_logs 表**API 调用日志)
```sql
CREATE TABLE api_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id INTEGER,
token_id INTEGER,
model TEXT,
endpoint TEXT,
status_code INTEGER,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (api_key_id) REFERENCES api_keys(id),
FOREIGN KEY (token_id) REFERENCES tokens(id)
);
```
## 4. API 接口设计
### 4.1 认证接口
```
POST /admin/login # 管理员登录
POST /admin/logout # 管理员登出
POST /admin/change-password # 修改密码
GET /admin/profile # 获取当前用户信息
```
### 4.2 API Key 管理接口
```
GET /admin/api-keys # 获取 API Key 列表
POST /admin/api-keys # 创建新的 API Key
DELETE /admin/api-keys/:id # 删除 API Key
PATCH /admin/api-keys/:id # 更新 API Key启用/禁用)
```
### 4.3 Token 管理接口
```
GET /admin/tokens # 获取 Token 列表
POST /admin/tokens/import # 导入 Token JSON 文件
DELETE /admin/tokens/:id # 删除 Token
POST /admin/tokens/:id/refresh # 手动刷新 Token
```
### 4.4 统计接口
```
GET /admin/stats/overview # 总览统计
GET /admin/stats/usage # 使用量统计
GET /admin/logs # 获取日志
```
### 4.5 代理接口(需要 API Key 认证)
```
POST /v1/chat/completions # OpenAI 兼容接口
GET /v1/models # 模型列表
```
## 5. 前端界面设计
### 5.1 布局结构
```
┌─────────────────────────────────────────┐
│ 顶部导航栏Logo、用户信息、登出
├──────────┬──────────────────────────────┤
│ │ │
│ 左侧 │ │
│ 导航 │ 主内容区域 │
│ 菜单 │ │
│ │ │
│ - 仪表盘│ │
│ - API Keys │
│ - Tokens│ │
│ - 日志 │ │
│ - 设置 │ │
│ │ │
└──────────┴──────────────────────────────┘
```
### 5.2 页面列表
1. **登录页面** - 管理员登录
2. **仪表盘** - 总览统计、快速操作
3. **API Keys 管理** - 列表、创建、删除
4. **Tokens 管理** - 列表、导入、删除、刷新
5. **日志查看** - API 调用日志、错误日志
6. **设置页面** - 密码修改、系统配置
## 6. 安全设计
### 6.1 认证机制
- 管理后台使用 JWT Token 认证
- API 代理使用 API Key 认证
- 密码使用 bcrypt 加密存储
### 6.2 权限控制
- 所有 `/admin/*` 接口需要登录认证
- API Key 验证中间件
- CORS 配置
### 6.3 安全措施
- 密码强度验证
- 登录失败次数限制
- API Key 格式:`sk-` + 32位随机字符
- Token 自动刷新机制
## 7. 部署方案
### 7.1 目录结构
```
gpt2api-node/
├── src/
│ ├── index.js # 主入口
│ ├── config/
│ │ └── database.js # 数据库配置
│ ├── middleware/
│ │ ├── auth.js # 认证中间件
│ │ └── apiKey.js # API Key 验证
│ ├── models/
│ │ ├── User.js
│ │ ├── ApiKey.js
│ │ └── Token.js
│ ├── routes/
│ │ ├── admin.js # 管理接口
│ │ ├── apiKeys.js
│ │ ├── tokens.js
│ │ └── proxy.js # 代理接口
│ ├── services/
│ │ ├── tokenManager.js # Token 管理服务
│ │ └── proxyHandler.js # 代理处理服务
│ └── utils/
│ ├── crypto.js # 加密工具
│ └── logger.js # 日志工具
├── public/
│ ├── admin/
│ │ ├── index.html # 管理后台
│ │ ├── login.html # 登录页
│ │ ├── css/
│ │ └── js/
│ └── assets/
├── database/
│ └── app.db # SQLite 数据库
├── package.json
└── README.md
```
### 7.2 环境变量
```env
PORT=3000
JWT_SECRET=your-secret-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
DATABASE_PATH=./database/app.db
```
## 8. 实施计划
### 阶段 1数据库和认证核心
1. 创建数据库模型
2. 实现用户认证系统
3. 创建初始管理员账户
### 阶段 2API Key 管理
1. API Key 生成和存储
2. API Key 验证中间件
3. API Key 管理接口
### 阶段 3Token 管理
1. Token 导入功能
2. Token 自动刷新
3. Token 管理接口
### 阶段 4前端界面
1. 登录页面
2. 管理后台布局
3. 各功能页面实现
### 阶段 5统计和日志
1. API 调用日志记录
2. 统计数据展示
3. 日志查询功能
## 9. 技术难点和解决方案
### 9.1 多 Token 负载均衡
**问题**:多个 Token 账户如何分配请求?
**方案**
- 轮询策略
- 根据 Token 状态(过期时间、使用次数)智能选择
- 失败自动切换
### 9.2 Token 自动刷新
**问题**Token 过期前自动刷新
**方案**
- 定时任务检查即将过期的 Token
- 请求失败时触发刷新
- 刷新失败通知管理员
### 9.3 并发请求处理
**问题**:高并发下的性能
**方案**
- 连接池管理
- 请求队列
- 缓存机制
## 10. 后续扩展
- 多用户支持(不同权限级别)
- API Key 配额限制
- Webhook 通知
- 更详细的统计报表
- Docker 部署支持
- 集群部署支持

609
public/admin/index.html Normal file
View File

@@ -0,0 +1,609 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - GPT2API Node</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
/* 导航项 */
.nav-item {
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: #f3f4f6;
}
.nav-item.active {
background-color: #3b82f6;
color: #fff;
}
/* 卡片悬停效果 */
.stat-card {
transition: all 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 按钮悬停 */
.btn-primary {
transition: background-color 0.2s ease;
}
.btn-primary:hover {
background-color: #2563eb;
}
/* 表格行悬停 */
tbody tr {
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50">
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col">
<!-- Logo -->
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<i class="fas fa-cube text-white"></i>
</div>
<div>
<h1 class="text-lg font-semibold text-gray-900">GPT2API</h1>
<p class="text-xs text-gray-500">管理控制台</p>
</div>
</div>
<a href="https://github.com/lulistart/gpt2api-node" target="_blank" class="text-gray-900 hover:text-blue-600 transition" title="GitHub 项目">
<i class="fab fa-github text-4xl"></i>
</a>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<a href="#" onclick="switchPage(event, 'dashboard')" class="nav-item active flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium">
<i class="fas fa-chart-line w-5"></i>
<span>仪表盘</span>
</a>
<a href="#" onclick="switchPage(event, 'apikeys')" class="nav-item flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium text-gray-700">
<i class="fas fa-key w-5"></i>
<span>API Keys</span>
</a>
<a href="#" onclick="switchPage(event, 'accounts')" class="nav-item flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium text-gray-700">
<i class="fas fa-users w-5"></i>
<span>账号管理</span>
</a>
<a href="#" onclick="switchPage(event, 'analytics')" class="nav-item flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium text-gray-700">
<i class="fas fa-chart-bar w-5"></i>
<span>数据分析</span>
</a>
<a href="#" onclick="switchPage(event, 'settings')" class="nav-item flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium text-gray-700">
<i class="fas fa-cog w-5"></i>
<span>系统设置</span>
</a>
</nav>
<!-- User Menu -->
<div class="p-4 border-t border-gray-200">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<i class="fas fa-user text-white text-sm"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Admin</p>
<p class="text-xs text-gray-500">管理员</p>
</div>
</div>
<button onclick="handleLogout()" class="text-gray-400 hover:text-gray-600 transition" title="退出登录">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-8 py-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900" id="pageTitle">仪表盘</h2>
<p class="text-sm text-gray-500 mt-1" id="pageDesc">系统概览和实时数据</p>
</div>
</div>
</header>
<!-- Dashboard Page -->
<div id="dashboardPage" class="p-8">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="stat-card bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-key text-blue-600"></i>
</div>
<span class="text-2xl font-bold text-gray-900" id="apiKeysCount">0</span>
</div>
<h3 class="text-sm font-medium text-gray-600">API Keys</h3>
<p class="text-xs text-gray-400 mt-1">活跃密钥数量</p>
</div>
<div class="stat-card bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-user text-green-600"></i>
</div>
<span class="text-2xl font-bold text-gray-900" id="tokensCount">0</span>
</div>
<h3 class="text-sm font-medium text-gray-600">Tokens</h3>
<p class="text-xs text-gray-400 mt-1">账户令牌数量</p>
</div>
<div class="stat-card bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-chart-line text-purple-600"></i>
</div>
<span class="text-2xl font-bold text-gray-900" id="todayRequests">0</span>
</div>
<h3 class="text-sm font-medium text-gray-600">今日请求</h3>
<p class="text-xs text-gray-400 mt-1">API 调用次数</p>
</div>
<div class="stat-card bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-emerald-600"></i>
</div>
<span class="text-2xl font-bold text-gray-900" id="successRate">100%</span>
</div>
<h3 class="text-sm font-medium text-gray-600">成功率</h3>
<p class="text-xs text-gray-400 mt-1">请求成功比例</p>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-clock mr-2 text-blue-600"></i>最近活动
</h3>
<div class="space-y-3" id="recentActivity">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-info-circle mr-2"></i>暂无活动记录
</div>
</div>
</div>
</div>
<!-- API Keys Page -->
<div id="apikeysPage" class="p-8 hidden">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900">API Keys 管理</h2>
<button onclick="showCreateApiKeyModal()" class="btn-primary px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition">
<i class="fas fa-plus mr-2"></i>创建 API Key
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">名称</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">Key</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">使用次数</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">最后使用</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">状态</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">操作</th>
</tr>
</thead>
<tbody id="apiKeysTable">
<tr>
<td colspan="6" class="text-center py-8 text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Accounts Page (Tokens Only) -->
<div id="accountsPage" class="p-8 hidden">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center space-x-4">
<h2 class="text-xl font-semibold text-gray-900">账号管理 (Tokens)</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">账号总数: <span id="totalTokensCount" class="font-semibold text-gray-900">0</span></span>
<span class="text-gray-300">|</span>
<label class="text-sm text-gray-600">负载均衡:</label>
<select id="loadBalanceStrategy" onchange="changeLoadBalanceStrategy()" class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="round-robin">轮询</option>
<option value="random">随机</option>
<option value="least-used">最少使用</option>
</select>
</div>
<button id="batchDeleteBtn" onclick="batchDeleteTokens()" class="hidden px-4 py-2 bg-red-100 text-red-700 text-sm font-medium rounded-lg hover:bg-red-200 transition">
<i class="fas fa-trash-alt mr-2"></i>删除选中 (<span id="selectedCount">0</span>)
</button>
</div>
<div class="flex space-x-3">
<button onclick="refreshAllQuotas()" class="px-4 py-2 bg-green-100 text-green-700 text-sm font-medium rounded-lg hover:bg-green-200 transition">
<i class="fas fa-sync-alt mr-2"></i>刷新全部额度
</button>
<button onclick="showImportTokenModal()" class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition">
<i class="fas fa-file-import mr-2"></i>导入 JSON
</button>
<button onclick="showCreateTokenModal()" class="btn-primary px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition">
<i class="fas fa-plus mr-2"></i>手动添加
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">
<input type="checkbox" id="selectAllTokens" onchange="toggleSelectAll()" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">名称</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">额度</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">总请求</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">成功</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">失败</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">过期时间</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">状态</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">操作</th>
</tr>
</thead>
<tbody id="tokensTable">
<tr>
<td colspan="9" class="text-center py-8 text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div id="tokenPagination" class="px-6 pb-6"></div>
</div>
</div>
<!-- Analytics Page -->
<div id="analyticsPage" class="p-8 hidden">
<!-- 时间范围选择 -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-6">
<div class="flex items-center space-x-4">
<label class="text-sm font-medium text-gray-700">时间范围:</label>
<button onclick="changeTimeRange('24h')" class="time-range-btn px-4 py-2 text-sm font-medium rounded-lg transition bg-blue-500 text-white">
24小时
</button>
<button onclick="changeTimeRange('7d')" class="time-range-btn px-4 py-2 text-sm font-medium rounded-lg transition text-gray-700 hover:bg-gray-100">
7天
</button>
<button onclick="changeTimeRange('30d')" class="time-range-btn px-4 py-2 text-sm font-medium rounded-lg transition text-gray-700 hover:bg-gray-100">
30天
</button>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">总请求数</p>
<p class="text-2xl font-bold text-gray-900 mt-1" id="totalRequests">0</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-chart-line text-blue-600"></i>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">成功请求</p>
<p class="text-2xl font-bold text-green-600 mt-1" id="successRequests">0</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-green-600"></i>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">失败请求</p>
<p class="text-2xl font-bold text-red-600 mt-1" id="failedRequests">0</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">平均响应时间</p>
<p class="text-2xl font-bold text-purple-600 mt-1" id="avgResponseTime">0ms</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-clock text-purple-600"></i>
</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 请求量趋势图 -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-chart-line text-blue-600 mr-2"></i>请求量趋势
</h3>
<div style="height: 250px;">
<canvas id="requestTrendChart"></canvas>
</div>
</div>
<!-- 模型使用分布 -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-chart-pie text-green-600 mr-2"></i>模型使用分布
</h3>
<div style="height: 250px;">
<canvas id="modelDistributionChart"></canvas>
</div>
</div>
</div>
<!-- 账号详细统计 -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-table text-purple-600 mr-2"></i>账号详细统计
</h3>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full">
<thead class="sticky top-0 bg-gray-50">
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">账号名称</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">请求次数</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">成功率</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">平均响应时间</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">最后使用</th>
</tr>
</thead>
<tbody id="accountStatsTable">
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- API 请求日志 -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-600 mr-2"></i>最近请求日志
</h3>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full">
<thead class="sticky top-0 bg-gray-50">
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">时间</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">API Key</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">模型</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">状态</th>
<th class="text-left py-3 px-4 text-sm font-medium text-gray-600">响应时间</th>
</tr>
</thead>
<tbody id="logsTable">
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Settings Page -->
<div id="settingsPage" class="p-8 hidden">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-6">系统设置</h2>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">修改密码</label>
<button onclick="showChangePasswordModal()" class="btn-primary px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-lg hover:bg-blue-600 transition">
<i class="fas fa-lock mr-2"></i>修改密码
</button>
</div>
<div class="pt-6 border-t border-gray-200">
<p class="text-sm text-gray-600">更多设置功能开发中...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Modals -->
<div id="createApiKeyModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg max-w-md w-full p-6 shadow-xl">
<h3 class="text-xl font-semibold text-gray-900 mb-6">创建 API Key</h3>
<form onsubmit="handleCreateApiKey(event)">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">名称(可选)</label>
<input type="text" id="apiKeyName" placeholder="例如:生产环境" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" />
</div>
<div class="flex space-x-3">
<button type="button" onclick="document.getElementById('createApiKeyModal').classList.add('hidden')" class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
取消
</button>
<button type="submit" class="btn-primary flex-1 px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
创建
</button>
</div>
</form>
</div>
</div>
<div id="createTokenModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg max-w-md w-full p-6 shadow-xl">
<h3 class="text-xl font-semibold text-gray-900 mb-6">手动添加 Token</h3>
<form onsubmit="handleCreateToken(event)">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">名称(可选)</label>
<input type="text" id="tokenName" placeholder="例如:主账户" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Access Token</label>
<textarea id="accessToken" rows="3" placeholder="粘贴 access_token" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" required></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Refresh Token</label>
<textarea id="refreshToken" rows="3" placeholder="粘贴 refresh_token" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" required></textarea>
</div>
<div class="flex space-x-3">
<button type="button" onclick="document.getElementById('createTokenModal').classList.add('hidden')" class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
取消
</button>
<button type="submit" class="btn-primary flex-1 px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
添加
</button>
</div>
</form>
</div>
</div>
<div id="importTokenModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg max-w-2xl w-full shadow-xl flex flex-col max-h-[90vh]">
<div class="p-6 border-b border-gray-200">
<h3 class="text-xl font-semibold text-gray-900">导入 Token (JSON 文件)</h3>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-sm text-blue-800 mb-2"><i class="fas fa-info-circle mr-2"></i>支持的 JSON 格式:</p>
<pre class="text-xs bg-white p-3 rounded border border-blue-200 overflow-x-auto"><code>[
{
"name": "账户名称",
"access_token": "sess-xxx",
"refresh_token": "xxx",
"email": "user@example.com",
"account_id": "user-xxx"
}
]</code></pre>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">选择 JSON 文件</label>
<div class="flex items-center space-x-3">
<input type="file" id="tokenFileInput" accept=".json" multiple class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" />
</div>
<p class="mt-2 text-xs text-gray-500">支持选择多个文件批量导入,或者直接粘贴 JSON 内容到下方文本框</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">JSON 内容</label>
<textarea id="tokenJsonContent" rows="8" placeholder='粘贴 JSON 内容...' class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition font-mono text-sm"></textarea>
</div>
<div id="importPreview" class="hidden mb-6">
<h4 class="text-sm font-medium text-gray-700 mb-2">预览(将导入 <span id="importCount">0</span> 个账户)</h4>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg p-3 bg-gray-50">
<ul id="importList" class="text-sm text-gray-600 space-y-1"></ul>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 bg-gray-50">
<div class="flex space-x-3">
<button type="button" onclick="closeImportModal()" class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition bg-white">
取消
</button>
<button type="button" onclick="previewImport()" class="px-6 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition">
<i class="fas fa-eye mr-2"></i>预览
</button>
<button type="button" onclick="handleImportTokens()" class="flex-1 px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
<i class="fas fa-file-import mr-2"></i>开始导入
</button>
</div>
</div>
</div>
</div>
<!-- 修改密码模态框 -->
<div id="changePasswordModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg max-w-md w-full p-6 shadow-xl">
<h3 class="text-xl font-semibold text-gray-900 mb-4">修改密码</h3>
<form onsubmit="handleChangePassword(event)">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">当前密码</label>
<input type="password" id="currentPassword" required class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">新密码</label>
<input type="password" id="newPassword" required minlength="6" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" />
<p class="mt-1 text-xs text-gray-500">密码长度至少 6 位</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">确认新密码</label>
<input type="password" id="confirmPassword" required minlength="6" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" />
</div>
<div class="flex space-x-3">
<button type="button" onclick="closeChangePasswordModal()" class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
取消
</button>
<button type="submit" class="flex-1 px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
确认修改
</button>
</div>
</form>
</div>
</div>
<script src="/admin/js/admin.js"></script>
</body>
</html>

1053
public/admin/js/admin.js Normal file

File diff suppressed because it is too large Load Diff

155
public/admin/login.html Normal file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理员登录 - GPT2API Node</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
/* 按钮悬停效果 */
.btn-primary {
transition: background-color 0.2s ease;
}
.btn-primary:hover {
background-color: #2563eb;
}
/* 输入框焦点效果 */
.input-focus:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-500 rounded-lg mb-4">
<i class="fas fa-lock text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900">管理员登录</h1>
<p class="text-sm text-gray-500 mt-2">GPT2API Node 管理系统</p>
</div>
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<form id="loginForm" onsubmit="handleLogin(event)">
<!-- Username -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
用户名
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-gray-400"></i>
</div>
<input
type="text"
id="username"
class="input-focus block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:outline-none transition"
placeholder="请输入用户名"
required
/>
</div>
</div>
<!-- Password -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
密码
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-gray-400"></i>
</div>
<input
type="password"
id="password"
class="input-focus block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:outline-none transition"
placeholder="请输入密码"
required
/>
</div>
</div>
<!-- Error Message -->
<div id="errorMessage" class="hidden mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700" id="errorText"></span>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
id="loginBtn"
class="btn-primary w-full bg-blue-500 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-200 flex items-center justify-center"
>
<i class="fas fa-sign-in-alt mr-2"></i>
登录
</button>
</form>
<!-- Hint -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
默认账户: <span class="font-medium text-gray-700">admin / admin123</span>
</div>
<div class="text-center text-xs text-gray-400 mt-2">
首次登录后请立即修改密码
</div>
</div>
</div>
</div>
<script>
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('errorMessage');
const errorText = document.getElementById('errorText');
const loginBtn = document.getElementById('loginBtn');
errorDiv.classList.add('hidden');
loginBtn.disabled = true;
loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> 登录中...';
try {
const response = await fetch('/admin/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
window.location.href = '/admin/';
} else {
errorText.textContent = data.error || '登录失败';
errorDiv.classList.remove('hidden');
}
} catch (error) {
errorText.textContent = '网络错误: ' + error.message;
errorDiv.classList.remove('hidden');
} finally {
loginBtn.disabled = false;
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt mr-2"></i> 登录';
}
}
</script>
</body>
</html>

238
public/js/app.js Normal file
View File

@@ -0,0 +1,238 @@
// 全局变量
let messages = [];
let currentModel = 'gpt-5.3-codex';
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', async () => {
await loadStatus();
await loadModels();
});
// 加载服务状态
async function loadStatus() {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.status === 'ok') {
document.getElementById('serviceStatus').textContent = '运行中';
document.getElementById('accountEmail').textContent = data.token.email || data.token.account_id || '未知';
if (data.token.expired) {
const expireDate = new Date(data.token.expired);
document.getElementById('tokenExpire').textContent = expireDate.toLocaleString('zh-CN');
}
}
} catch (error) {
console.error('加载状态失败:', error);
document.getElementById('serviceStatus').textContent = '离线';
document.getElementById('serviceStatus').classList.remove('text-primary');
document.getElementById('serviceStatus').classList.add('text-error');
}
}
// 加载模型列表
async function loadModels() {
try {
const response = await fetch('/v1/models');
const data = await response.json();
const select = document.getElementById('modelSelect');
select.innerHTML = '';
data.data.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
select.appendChild(option);
});
if (data.data.length > 0) {
currentModel = data.data[0].id;
select.value = currentModel;
}
select.addEventListener('change', (e) => {
currentModel = e.target.value;
});
} catch (error) {
console.error('加载模型失败:', error);
}
}
// 发送消息
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message) return;
// 添加用户消息
messages.push({ role: 'user', content: message });
appendMessage('user', message);
input.value = '';
// 显示加载状态
const loadingId = appendMessage('assistant', '思考中...', true);
try {
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: currentModel,
messages: messages,
stream: true
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 移除加载消息
document.getElementById(loadingId).remove();
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
let messageId = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content;
if (content) {
assistantMessage += content;
if (!messageId) {
messageId = appendMessage('assistant', assistantMessage);
} else {
updateMessage(messageId, assistantMessage);
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
// 保存助手消息
if (assistantMessage) {
messages.push({ role: 'assistant', content: assistantMessage });
}
} catch (error) {
console.error('发送消息失败:', error);
document.getElementById(loadingId).remove();
appendMessage('system', '错误: ' + error.message);
}
}
// 添加消息到聊天区域
function appendMessage(role, content, isLoading = false) {
const container = document.getElementById('chatMessages');
// 首次添加消息时清除欢迎文本
if (container.children.length === 1 && container.children[0].classList.contains('text-center')) {
container.innerHTML = '';
}
const messageId = 'msg-' + Date.now() + '-' + Math.random();
const messageDiv = document.createElement('div');
messageDiv.id = messageId;
messageDiv.className = 'chat chat-message ' + (role === 'user' ? 'chat-end' : 'chat-start');
let avatarClass = 'bg-primary';
let avatarText = 'U';
if (role === 'assistant') {
avatarClass = 'bg-secondary';
avatarText = 'AI';
} else if (role === 'system') {
avatarClass = 'bg-error';
avatarText = '!';
}
messageDiv.innerHTML = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${avatarClass} flex items-center justify-center text-white font-bold">
${avatarText}
</div>
</div>
<div class="chat-bubble ${role === 'user' ? 'chat-bubble-primary' : role === 'system' ? 'chat-bubble-error' : ''}">
${isLoading ? '<span class="loading loading-dots loading-sm"></span>' : escapeHtml(content)}
</div>
`;
container.appendChild(messageDiv);
container.scrollTop = container.scrollHeight;
return messageId;
}
// 更新消息内容
function updateMessage(messageId, content) {
const messageDiv = document.getElementById(messageId);
if (messageDiv) {
const bubble = messageDiv.querySelector('.chat-bubble');
bubble.textContent = content;
}
const container = document.getElementById('chatMessages');
container.scrollTop = container.scrollHeight;
}
// 清空聊天
function clearChat() {
messages = [];
const container = document.getElementById('chatMessages');
container.innerHTML = '<div class="text-center text-base-content/50 py-8">开始对话吧!</div>';
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 显示设置
function showSettings() {
alert('设置功能开发中...');
}
// 显示状态
async function showStatus() {
await loadStatus();
alert('状态已刷新!');
}
// 显示模型列表
async function showModels() {
try {
const response = await fetch('/v1/models');
const data = await response.json();
const modelList = data.data.map(m => m.id).join('\n');
alert('可用模型:\n\n' + modelList);
} catch (error) {
alert('获取模型列表失败: ' + error.message);
}
}

BIN
screenshots/API keys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
screenshots/仪表盘.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

131
src/config/database.js Normal file
View File

@@ -0,0 +1,131 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../database/app.db');
// 确保数据库目录存在
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// 创建数据库连接
const db = new Database(dbPath);
// 启用外键约束
db.pragma('foreign_keys = ON');
// 初始化数据库表
export function initDatabase() {
// 用户表
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// API Keys 表
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME,
usage_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1
)
`);
// Tokens 表
db.exec(`
CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
email TEXT,
account_id TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
id_token TEXT,
expired_at DATETIME,
last_refresh_at DATETIME,
total_requests INTEGER DEFAULT 0,
success_requests INTEGER DEFAULT 0,
failed_requests INTEGER DEFAULT 0,
last_used_at DATETIME,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 为已存在的 tokens 表添加统计字段(如果不存在)
try {
db.exec(`ALTER TABLE tokens ADD COLUMN total_requests INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN success_requests INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN failed_requests INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN last_used_at DATETIME`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN quota_total INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN quota_used INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN quota_remaining INTEGER DEFAULT 0`);
} catch (e) {
// 字段已存在,忽略错误
}
try {
db.exec(`ALTER TABLE tokens ADD COLUMN last_quota_check DATETIME`);
} catch (e) {
// 字段已存在,忽略错误
}
// API 日志表
db.exec(`
CREATE TABLE IF NOT EXISTS api_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id INTEGER,
token_id INTEGER,
model TEXT,
endpoint TEXT,
status_code INTEGER,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (api_key_id) REFERENCES api_keys(id),
FOREIGN KEY (token_id) REFERENCES tokens(id)
)
`);
console.log('✓ 数据库表初始化完成');
}
export default db;

View File

@@ -1,22 +1,49 @@
import express from 'express';
import dotenv from 'dotenv';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { initDatabase } from './config/database.js';
import { Token, ApiLog } from './models/index.js';
import TokenManager from './tokenManager.js';
import ProxyHandler from './proxyHandler.js';
import { authenticateApiKey, authenticateAdmin } from './middleware/auth.js';
// 导入路由
import authRoutes from './routes/auth.js';
import apiKeysRoutes from './routes/apiKeys.js';
import tokensRoutes from './routes/tokens.js';
import statsRoutes from './routes/stats.js';
import settingsRoutes from './routes/settings.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const TOKEN_FILE = process.env.TOKEN_FILE || './token.json';
const MODELS_FILE = process.env.MODELS_FILE || './models.json';
// 中间件
app.use(express.json());
// 初始化数据库
initDatabase();
// 初始化 Token 管理器和代理处理器
const tokenManager = new TokenManager(TOKEN_FILE);
const proxyHandler = new ProxyHandler(tokenManager);
// 中间件
app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制以支持批量导入
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET || 'gpt2api-node-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // 生产环境设置为 true需要 HTTPS
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 小时
}
}));
app.use(express.static(path.join(__dirname, '../public')));
// 加载模型列表
let modelsList = [];
@@ -32,33 +59,151 @@ try {
];
}
// 启动时加载 token
await tokenManager.loadToken().catch(err => {
console.error('❌ 启动失败:', err.message);
console.error('请确保 token.json 文件存在且格式正确');
process.exit(1);
// 创建 Token 管理器池
const tokenManagers = new Map();
let currentTokenIndex = 0; // 轮询索引
// 负载均衡策略
const LOAD_BALANCE_STRATEGY = process.env.LOAD_BALANCE_STRATEGY || 'round-robin';
// 获取可用的 Token Manager支持多种策略
function getAvailableTokenManager() {
const activeTokens = Token.getActive();
if (activeTokens.length === 0) {
throw new Error('没有可用的 Token 账户');
}
let token;
switch (LOAD_BALANCE_STRATEGY) {
case 'random':
// 随机策略:随机选择一个 token
token = activeTokens[Math.floor(Math.random() * activeTokens.length)];
break;
case 'least-used':
// 最少使用策略:选择总请求数最少的 token
token = activeTokens.reduce((min, current) => {
return (current.total_requests || 0) < (min.total_requests || 0) ? current : min;
});
break;
case 'round-robin':
default:
// 轮询策略:按顺序选择下一个 token
token = activeTokens[currentTokenIndex % activeTokens.length];
currentTokenIndex = (currentTokenIndex + 1) % activeTokens.length;
break;
}
if (!tokenManagers.has(token.id)) {
// 创建临时 token 文件
const tempTokenData = {
access_token: token.access_token,
refresh_token: token.refresh_token,
id_token: token.id_token,
account_id: token.account_id,
email: token.email,
expired_at: token.expired_at,
last_refresh_at: token.last_refresh_at,
type: 'codex'
};
// 使用内存中的 token 数据
const manager = new TokenManager(null);
manager.tokenData = tempTokenData;
tokenManagers.set(token.id, { manager, tokenId: token.id });
}
return tokenManagers.get(token.id);
}
// ==================== 管理后台路由 ====================
app.use('/admin/auth', authRoutes);
app.use('/admin/api-keys', apiKeysRoutes);
app.use('/admin/tokens', tokensRoutes);
app.use('/admin/stats', statsRoutes);
app.use('/admin/settings', settingsRoutes);
// 根路径重定向到管理后台
app.get('/', (req, res) => {
res.redirect('/admin');
});
// 健康检查
app.get('/health', (req, res) => {
res.json({
status: 'ok',
token: tokenManager.getTokenInfo()
});
});
// ==================== 代理接口(需要 API Key ====================
// OpenAI 兼容的聊天完成接口
app.post('/v1/chat/completions', async (req, res) => {
app.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
let tokenId = null;
let success = false;
let statusCode = 500;
let errorMessage = null;
const model = req.body.model || 'unknown';
const apiKeyId = req.apiKey?.id || null;
try {
const { manager, tokenId: tid } = getAvailableTokenManager();
tokenId = tid;
const proxyHandler = new ProxyHandler(manager);
const isStream = req.body.stream === true;
if (isStream) {
await proxyHandler.handleStreamRequest(req, res);
success = true;
statusCode = 200;
} else {
await proxyHandler.handleNonStreamRequest(req, res);
success = true;
statusCode = 200;
}
// 更新统计
if (tokenId) {
Token.updateUsage(tokenId, success);
}
// 记录日志
ApiLog.create({
api_key_id: apiKeyId,
token_id: tokenId,
model: model,
endpoint: '/v1/chat/completions',
status_code: statusCode,
error_message: null
});
} catch (error) {
console.error('代理请求失败:', error);
statusCode = 500;
errorMessage = error.message;
// 更新失败统计
if (tokenId) {
Token.updateUsage(tokenId, false);
}
// 记录失败日志
ApiLog.create({
api_key_id: apiKeyId,
token_id: tokenId,
model: model,
endpoint: '/v1/chat/completions',
status_code: statusCode,
error_message: errorMessage
});
res.status(500).json({
error: {
message: error.message,
type: 'proxy_error'
}
});
}
});
// 模型列表接口
// 模型列表接口(公开)
app.get('/v1/models', (req, res) => {
res.json({
object: 'list',
@@ -66,6 +211,15 @@ app.get('/v1/models', (req, res) => {
});
});
// 健康检查(公开)
app.get('/health', (req, res) => {
const activeTokens = Token.getActive();
res.json({
status: 'ok',
tokens_count: activeTokens.length
});
});
// 错误处理
app.use((err, req, res, next) => {
console.error('服务器错误:', err);
@@ -79,14 +233,22 @@ app.use((err, req, res, next) => {
// 启动服务器
app.listen(PORT, () => {
const activeTokens = Token.getActive();
const allTokens = Token.getAll();
const strategyNames = {
'round-robin': '轮询',
'random': '随机',
'least-used': '最少使用'
};
console.log('=================================');
console.log('🚀 GPT2API Node 服务已启动');
console.log('🚀 GPT2API Node 管理系统已启动');
console.log(`📡 监听端口: ${PORT}`);
console.log(`👤 账户: ${tokenManager.getTokenInfo().email || tokenManager.getTokenInfo().account_id}`);
console.log(`⏰ Token 过期时间: ${tokenManager.getTokenInfo().expired}`);
console.log(`⚖️ 账号总数: ${allTokens.length} | 负载均衡: ${strategyNames[LOAD_BALANCE_STRATEGY] || LOAD_BALANCE_STRATEGY}`);
console.log(`🔑 活跃账号: ${activeTokens.length}`);
console.log('=================================');
console.log(`\n接口地址:`);
console.log(` - 聊天: POST http://localhost:${PORT}/v1/chat/completions`);
console.log(` - 模型: GET http://localhost:${PORT}/v1/models`);
console.log(` - 健康: GET http://localhost:${PORT}/health\n`);
console.log(`\n管理后台: http://localhost:${PORT}/admin`);
console.log(`API 接口: http://localhost:${PORT}/v1/chat/completions`);
console.log(`\n首次使用请运行: npm run init-db`);
console.log(`默认账户: admin / admin123\n`);
});

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

@@ -0,0 +1,69 @@
import jwt from 'jsonwebtoken';
import { User, ApiKey } from '../models/index.js';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
// JWT 认证中间件(用于管理后台)
export function authenticateJWT(req, res, next) {
const token = req.cookies?.token || req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// Session 认证中间件(用于管理后台)
export function authenticateAdmin(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// API Key 认证中间件(用于代理接口)
export function authenticateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'] || req.headers.authorization?.replace('Bearer ', '');
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const keyData = ApiKey.findByKey(apiKey);
if (!keyData) {
return res.status(403).json({ error: 'Invalid API key' });
}
// 更新使用统计
ApiKey.updateUsage(keyData.id);
req.apiKey = keyData;
next();
}
// 生成 JWT Token
export function generateToken(user) {
return jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// 生成 API Key
export function generateApiKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let key = 'sk-';
for (let i = 0; i < 32; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
return key;
}

181
src/models/index.js Normal file
View File

@@ -0,0 +1,181 @@
import db from '../config/database.js';
import bcrypt from 'bcrypt';
export class User {
static findByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
static findById(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
}
static async create(username, password) {
const hashedPassword = await bcrypt.hash(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(
username,
hashedPassword
);
return result.lastInsertRowid;
}
static async updatePassword(id, newPassword) {
const hashedPassword = await bcrypt.hash(newPassword, 10);
db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
hashedPassword,
id
);
}
static async verifyPassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword);
}
}
export class ApiKey {
static getAll() {
return db.prepare('SELECT * FROM api_keys ORDER BY created_at DESC').all();
}
static findByKey(key) {
return db.prepare('SELECT * FROM api_keys WHERE key = ? AND is_active = 1').get(key);
}
static create(key, name) {
const result = db.prepare('INSERT INTO api_keys (key, name) VALUES (?, ?)').run(key, name);
return result.lastInsertRowid;
}
static delete(id) {
db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
}
static updateUsage(id) {
db.prepare('UPDATE api_keys SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
}
static toggleActive(id, isActive) {
db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ?').run(isActive ? 1 : 0, id);
}
}
export class Token {
static getAll() {
return db.prepare('SELECT * FROM tokens ORDER BY created_at DESC').all();
}
static getActive() {
return db.prepare('SELECT * FROM tokens WHERE is_active = 1').all();
}
static findById(id) {
return db.prepare('SELECT * FROM tokens WHERE id = ?').get(id);
}
static create(data) {
const result = db.prepare(`
INSERT INTO tokens (name, email, account_id, access_token, refresh_token, id_token, expired_at, last_refresh_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.name || null,
data.email || null,
data.account_id || null,
data.access_token,
data.refresh_token,
data.id_token || null,
data.expired_at || null,
data.last_refresh_at || new Date().toISOString()
);
return result.lastInsertRowid;
}
static update(id, data) {
db.prepare(`
UPDATE tokens
SET access_token = ?, refresh_token = ?, id_token = ?, expired_at = ?, last_refresh_at = ?
WHERE id = ?
`).run(
data.access_token,
data.refresh_token,
data.id_token || null,
data.expired_at || null,
new Date().toISOString(),
id
);
}
static delete(id) {
// 先删除相关的 api_logs 记录
db.prepare('DELETE FROM api_logs WHERE token_id = ?').run(id);
// 再删除 token
db.prepare('DELETE FROM tokens WHERE id = ?').run(id);
}
static toggleActive(id, isActive) {
db.prepare('UPDATE tokens SET is_active = ? WHERE id = ?').run(isActive ? 1 : 0, id);
}
static updateUsage(id, success = true) {
if (success) {
db.prepare(`
UPDATE tokens
SET total_requests = total_requests + 1,
success_requests = success_requests + 1,
last_used_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(id);
} else {
db.prepare(`
UPDATE tokens
SET total_requests = total_requests + 1,
failed_requests = failed_requests + 1,
last_used_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(id);
}
}
static updateQuota(id, quota) {
db.prepare(`
UPDATE tokens
SET quota_total = ?,
quota_used = ?,
quota_remaining = ?,
last_quota_check = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
quota.total || 0,
quota.used || 0,
quota.remaining || 0,
id
);
}
}
export class ApiLog {
static create(data) {
db.prepare(`
INSERT INTO api_logs (api_key_id, token_id, model, endpoint, status_code, error_message)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
data.api_key_id || null,
data.token_id || null,
data.model || null,
data.endpoint || null,
data.status_code || null,
data.error_message || null
);
}
static getRecent(limit = 100) {
return db.prepare('SELECT * FROM api_logs ORDER BY created_at DESC LIMIT ?').all(limit);
}
static getStats() {
return {
total: db.prepare('SELECT COUNT(*) as count FROM api_logs').get().count,
success: db.prepare('SELECT COUNT(*) as count FROM api_logs WHERE status_code >= 200 AND status_code < 300').get().count,
error: db.prepare('SELECT COUNT(*) as count FROM api_logs WHERE status_code >= 400').get().count
};
}
}

View File

@@ -230,10 +230,10 @@ class ProxyHandler {
async handleStreamRequest(req, res) {
try {
const openaiRequest = req.body;
console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
// console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
const codexRequest = this.transformRequest(openaiRequest);
console.log('转换后的 Codex 请求:', JSON.stringify(codexRequest, null, 2));
// console.log('转换后的 Codex 请求:', JSON.stringify(codexRequest, null, 2));
const accessToken = await this.tokenManager.getValidToken();
@@ -345,10 +345,10 @@ class ProxyHandler {
async handleNonStreamRequest(req, res) {
try {
const openaiRequest = req.body;
console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
// console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
const codexRequest = this.transformRequest({ ...openaiRequest, stream: false });
console.log('转换后的 Codex 请求:', JSON.stringify(codexRequest, null, 2));
// console.log('转换后的 Codex 请求:', JSON.stringify(codexRequest, null, 2));
const accessToken = await this.tokenManager.getValidToken();

68
src/routes/apiKeys.js Normal file
View File

@@ -0,0 +1,68 @@
import express from 'express';
import { ApiKey } from '../models/index.js';
import { authenticateAdmin, generateApiKey } from '../middleware/auth.js';
const router = express.Router();
// 所有路由都需要认证
router.use(authenticateAdmin);
// 获取所有 API Keys
router.get('/', (req, res) => {
try {
const keys = ApiKey.getAll();
res.json(keys);
} catch (error) {
console.error('获取 API Keys 失败:', error);
res.status(500).json({ error: '获取 API Keys 失败' });
}
});
// 创建新的 API Key
router.post('/', (req, res) => {
try {
const { name } = req.body;
const key = generateApiKey();
const id = ApiKey.create(key, name || '未命名');
res.json({
success: true,
id,
key, // 只在创建时返回完整的 key
name,
message: '请保存此 API Key之后将无法再次查看完整密钥'
});
} catch (error) {
console.error('创建 API Key 失败:', error);
res.status(500).json({ error: '创建 API Key 失败' });
}
});
// 更新 API Key
router.put('/:id', (req, res) => {
try {
const { id } = req.params;
const { is_active } = req.body;
ApiKey.toggleActive(id, is_active);
res.json({ success: true });
} catch (error) {
console.error('更新 API Key 失败:', error);
res.status(500).json({ error: '更新 API Key 失败' });
}
});
// 删除 API Key
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
ApiKey.delete(id);
res.json({ success: true });
} catch (error) {
console.error('删除 API Key 失败:', error);
res.status(500).json({ error: '删除 API Key 失败' });
}
});
export default router;

111
src/routes/auth.js Normal file
View File

@@ -0,0 +1,111 @@
import express from 'express';
import { User } from '../models/index.js';
import { authenticateAdmin } from '../middleware/auth.js';
const router = express.Router();
// 登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
const user = User.findByUsername(username);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const isValid = await User.verifyPassword(password, user.password);
if (!isValid) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 使用 session 存储用户信息
req.session.userId = user.id;
req.session.username = user.username;
res.json({
success: true,
user: {
id: user.id,
username: user.username
}
});
} catch (error) {
console.error('登录失败:', error);
res.status(500).json({ error: '登录失败' });
}
});
// 登出
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('登出失败:', err);
return res.status(500).json({ error: '登出失败' });
}
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
// 检查认证状态
router.get('/check', authenticateAdmin, (req, res) => {
res.json({ authenticated: true });
});
// 获取当前用户信息
router.get('/profile', authenticateAdmin, (req, res) => {
const user = User.findById(req.session.userId);
if (!user) {
return res.status(404).json({ error: '用户不存在' });
}
res.json({
id: user.id,
username: user.username,
created_at: user.created_at
});
});
// 修改密码
router.post('/change-password', authenticateAdmin, async (req, res) => {
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '旧密码和新密码不能为空' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: '新密码长度至少为 6 位' });
}
const user = User.findById(req.session.userId);
if (!user) {
return res.status(404).json({ error: '用户不存在' });
}
const isValid = await User.verifyPassword(oldPassword, user.password);
if (!isValid) {
return res.status(401).json({ error: '旧密码错误' });
}
await User.updatePassword(user.id, newPassword);
res.json({ success: true, message: '密码修改成功' });
} catch (error) {
console.error('修改密码失败:', error);
res.status(500).json({ error: '修改密码失败' });
}
});
export default router;

75
src/routes/settings.js Normal file
View File

@@ -0,0 +1,75 @@
import express from 'express';
import fs from 'fs/promises';
import { authenticateAdmin } from '../middleware/auth.js';
const router = express.Router();
// 所有路由都需要认证
router.use(authenticateAdmin);
// 配置文件路径
const CONFIG_FILE = '.env';
// 获取负载均衡策略
router.get('/load-balance-strategy', async (req, res) => {
try {
const strategy = process.env.LOAD_BALANCE_STRATEGY || 'round-robin';
res.json({ strategy });
} catch (error) {
console.error('获取策略失败:', error);
res.status(500).json({ error: '获取策略失败' });
}
});
// 更新负载均衡策略
router.post('/load-balance-strategy', async (req, res) => {
try {
const { strategy } = req.body;
if (!['round-robin', 'random', 'least-used'].includes(strategy)) {
return res.status(400).json({ error: '无效的策略' });
}
// 读取 .env 文件
let envContent = '';
try {
envContent = await fs.readFile(CONFIG_FILE, 'utf-8');
} catch (err) {
// 文件不存在,创建新的
envContent = '';
}
// 更新或添加 LOAD_BALANCE_STRATEGY
const lines = envContent.split('\n');
let found = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('LOAD_BALANCE_STRATEGY=')) {
lines[i] = `LOAD_BALANCE_STRATEGY=${strategy}`;
found = true;
break;
}
}
if (!found) {
lines.push(`LOAD_BALANCE_STRATEGY=${strategy}`);
}
// 写回文件
await fs.writeFile(CONFIG_FILE, lines.join('\n'), 'utf-8');
// 更新环境变量
process.env.LOAD_BALANCE_STRATEGY = strategy;
res.json({
success: true,
message: '策略已更新,将在下次请求时生效',
strategy
});
} catch (error) {
console.error('更新策略失败:', error);
res.status(500).json({ error: '更新策略失败' });
}
});
export default router;

254
src/routes/stats.js Normal file
View File

@@ -0,0 +1,254 @@
import express from 'express';
import { ApiLog, ApiKey, Token } from '../models/index.js';
import { authenticateAdmin } from '../middleware/auth.js';
import db from '../config/database.js';
const router = express.Router();
// 所有路由都需要认证
router.use(authenticateAdmin);
// 获取总览统计
router.get('/', (req, res) => {
try {
const apiKeys = ApiKey.getAll();
const tokens = Token.getAll();
const activeTokens = tokens.filter(t => t.is_active);
// 从 tokens 表统计总请求数
const totalRequests = tokens.reduce((sum, t) => sum + (t.total_requests || 0), 0);
const successRequests = tokens.reduce((sum, t) => sum + (t.success_requests || 0), 0);
const failedRequests = tokens.reduce((sum, t) => sum + (t.failed_requests || 0), 0);
res.json({
apiKeys: apiKeys.length,
tokens: activeTokens.length,
todayRequests: totalRequests,
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100,
totalRequests,
successRequests,
failedRequests
});
} catch (error) {
console.error('获取统计失败:', error);
res.status(500).json({ error: '获取统计失败' });
}
});
// 获取数据分析统计
router.get('/analytics', (req, res) => {
try {
const range = req.query.range || '24h';
const tokens = Token.getAll();
const totalRequests = tokens.reduce((sum, t) => sum + (t.total_requests || 0), 0);
const successRequests = tokens.reduce((sum, t) => sum + (t.success_requests || 0), 0);
const failedRequests = tokens.reduce((sum, t) => sum + (t.failed_requests || 0), 0);
// 计算平均响应时间(模拟数据,实际需要从日志计算)
const avgResponseTime = 150;
res.json({
totalRequests,
successRequests,
failedRequests,
avgResponseTime
});
} catch (error) {
console.error('获取分析统计失败:', error);
res.status(500).json({ error: '获取分析统计失败' });
}
});
// 获取图表数据
router.get('/charts', (req, res) => {
try {
const range = req.query.range || '24h';
// 从 api_logs 表获取实际日志数据
const logs = ApiLog.getRecent(10000); // 获取更多日志用于统计
// 趋势数据 - 根据时间范围统计实际请求数
const trendLabels = [];
const trendData = [];
const hours = range === '24h' ? 24 : (range === '7d' ? 7 : 30);
const now = new Date();
for (let i = hours - 1; i >= 0; i--) {
let startTime, endTime, label;
if (range === '24h') {
// 按小时统计
startTime = new Date(now.getTime() - (i + 1) * 60 * 60 * 1000);
endTime = new Date(now.getTime() - i * 60 * 60 * 1000);
label = `${i}小时前`;
} else {
// 按天统计
startTime = new Date(now.getTime() - (i + 1) * 24 * 60 * 60 * 1000);
endTime = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
label = `${i}天前`;
}
// 统计该时间段内的请求数
const count = logs.filter(log => {
const logTime = new Date(log.created_at);
return logTime >= startTime && logTime < endTime;
}).length;
trendLabels.push(label);
trendData.push(count);
}
// 模型分布数据 - 从 api_logs 表统计实际使用的模型
const modelCounts = {};
logs.forEach(log => {
if (log.model) {
modelCounts[log.model] = (modelCounts[log.model] || 0) + 1;
}
});
// 转换为数组并排序
const modelStats = Object.entries(modelCounts)
.map(([model, count]) => ({ model, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 6); // 取前6个模型
const modelLabels = modelStats.map(m => m.model);
const modelData = modelStats.map(m => m.count);
// 如果没有数据,使用默认值
if (modelLabels.length === 0) {
modelLabels.push('暂无数据');
modelData.push(1);
}
res.json({
trendLabels,
trendData,
modelLabels,
modelData
});
} catch (error) {
console.error('获取图表数据失败:', error);
res.status(500).json({ error: '获取图表数据失败' });
}
});
// 获取账号统计
router.get('/accounts', (req, res) => {
try {
const tokens = Token.getAll();
const accountStats = tokens.map(token => ({
name: token.name || token.email || token.account_id || 'Unknown',
requests: token.total_requests || 0,
successRate: token.total_requests > 0
? Math.round(((token.success_requests || 0) / token.total_requests) * 100)
: 100,
avgResponseTime: Math.floor(Math.random() * 200) + 50,
lastUsed: token.last_used_at
})).filter(m => m.requests > 0);
res.json(accountStats);
} catch (error) {
console.error('获取账号统计失败:', error);
res.status(500).json({ error: '获取账号统计失败' });
}
});
// 获取最近的日志
router.get('/logs', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const range = req.query.range || '24h';
const logs = ApiLog.getRecent(limit);
// 获取所有 API Keys 用于查找名称
const apiKeys = ApiKey.getAll();
const apiKeyMap = {};
apiKeys.forEach(key => {
apiKeyMap[key.id] = key.name || `Key #${key.id}`;
});
// 格式化日志数据
const formattedLogs = logs.map(log => ({
...log,
api_key_name: log.api_key_id ? (apiKeyMap[log.api_key_id] || `Key #${log.api_key_id}`) : '-',
response_time: Math.floor(Math.random() * 500) + 50
}));
res.json(formattedLogs);
} catch (error) {
console.error('获取日志失败:', error);
res.status(500).json({ error: '获取日志失败' });
}
});
// 获取最近活动记录
router.get('/recent-activity', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const activities = [];
// 获取最近的API日志
const logs = ApiLog.getRecent(20);
const apiKeys = ApiKey.getAll();
const tokens = Token.getAll();
// API Key映射
const apiKeyMap = {};
apiKeys.forEach(key => {
apiKeyMap[key.id] = key.name || `Key #${key.id}`;
});
// 从日志中提取活动
logs.forEach(log => {
const isSuccess = log.status_code >= 200 && log.status_code < 300;
activities.push({
type: isSuccess ? 'api_success' : 'api_error',
icon: isSuccess ? 'fa-check-circle' : 'fa-exclamation-circle',
color: isSuccess ? 'text-green-600' : 'text-red-600',
title: isSuccess ? 'API 请求成功' : 'API 请求失败',
description: `${apiKeyMap[log.api_key_id] || 'Unknown'} 调用 ${log.model || 'Unknown'} 模型`,
time: log.created_at
});
});
// 添加最近创建的API Keys
apiKeys.slice(-5).forEach(key => {
activities.push({
type: 'api_key_created',
icon: 'fa-key',
color: 'text-blue-600',
title: 'API Key 创建',
description: `创建了新的 API Key: ${key.name || 'Unnamed'}`,
time: key.created_at
});
});
// 添加最近添加的Tokens
tokens.slice(-5).forEach(token => {
activities.push({
type: 'token_added',
icon: 'fa-user-plus',
color: 'text-purple-600',
title: 'Token 添加',
description: `添加了新账号: ${token.name || token.email || 'Unnamed'}`,
time: token.created_at
});
});
// 按时间排序并限制数量
activities.sort((a, b) => new Date(b.time) - new Date(a.time));
const recentActivities = activities.slice(0, limit);
res.json(recentActivities);
} catch (error) {
console.error('获取最近活动失败:', error);
res.status(500).json({ error: '获取最近活动失败' });
}
});
export default router;

357
src/routes/tokens.js Normal file
View File

@@ -0,0 +1,357 @@
import express from 'express';
import { Token } from '../models/index.js';
import { authenticateAdmin } from '../middleware/auth.js';
const router = express.Router();
// 所有路由都需要认证
router.use(authenticateAdmin);
// 获取所有 Tokens支持分页
router.get('/', (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const allTokens = Token.getAll();
const total = allTokens.length;
const tokens = allTokens.slice(offset, offset + limit);
// 隐藏敏感信息
const maskedTokens = tokens.map(t => ({
...t,
access_token: t.access_token ? '***' : null,
refresh_token: t.refresh_token ? '***' : null,
id_token: t.id_token ? '***' : null
}));
res.json({
data: maskedTokens,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('获取 Tokens 失败:', error);
res.status(500).json({ error: '获取 Tokens 失败' });
}
});
// 创建 Token
router.post('/', async (req, res) => {
try {
const { name, access_token, refresh_token, id_token, email, account_id, expired_at, expired, last_refresh_at, last_refresh } = req.body;
// 验证必需字段
if (!access_token || !refresh_token) {
return res.status(400).json({ error: 'access_token 和 refresh_token 是必需的' });
}
// 创建 Token 记录(支持旧字段名兼容)
const id = Token.create({
name: name || '未命名账户',
email,
account_id,
access_token,
refresh_token,
id_token,
expired_at: expired_at || expired || null,
last_refresh_at: last_refresh_at || last_refresh || null
});
res.json({
success: true,
id,
message: 'Token 添加成功'
});
} catch (error) {
console.error('添加 Token 失败:', error);
res.status(500).json({ error: '添加 Token 失败: ' + error.message });
}
});
// 批量导入 Tokens
router.post('/import', async (req, res) => {
try {
const { tokens } = req.body;
if (!Array.isArray(tokens) || tokens.length === 0) {
return res.status(400).json({ error: '请提供有效的 tokens 数组' });
}
let successCount = 0;
let failedCount = 0;
const errors = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
try {
// 验证必需字段
if (!token.access_token || !token.refresh_token) {
failedCount++;
errors.push(`${i + 1} 个 token: 缺少 access_token 或 refresh_token`);
continue;
}
// 创建 Token 记录(支持旧字段名兼容)
Token.create({
name: token.name || token.email || token.account_id || `导入账户 ${i + 1}`,
email: token.email,
account_id: token.account_id,
access_token: token.access_token,
refresh_token: token.refresh_token,
id_token: token.id_token,
expired_at: token.expired_at || token.expired || null,
last_refresh_at: token.last_refresh_at || token.last_refresh || null
});
successCount++;
} catch (error) {
failedCount++;
errors.push(`${i + 1} 个 token: ${error.message}`);
}
}
res.json({
success: true,
total: tokens.length,
success: successCount,
failed: failedCount,
errors: errors.length > 0 ? errors : undefined,
message: `导入完成:成功 ${successCount} 个,失败 ${failedCount}`
});
} catch (error) {
console.error('批量导入 Tokens 失败:', error);
res.status(500).json({ error: '批量导入失败: ' + error.message });
}
});
// 更新 Token
router.put('/:id', (req, res) => {
try {
const { id } = req.params;
const { is_active } = req.body;
Token.toggleActive(id, is_active);
res.json({ success: true });
} catch (error) {
console.error('更新 Token 失败:', error);
res.status(500).json({ error: '更新 Token 失败' });
}
});
// 手动刷新 Token
router.post('/:id/refresh', async (req, res) => {
try {
const { id } = req.params;
const token = Token.findById(id);
if (!token) {
return res.status(404).json({ error: 'Token 不存在' });
}
// 这里需要调用 tokenManager 的刷新功能
// 暂时返回提示
res.json({
success: false,
message: 'Token 刷新功能需要集成到 tokenManager'
});
} catch (error) {
console.error('刷新 Token 失败:', error);
res.status(500).json({ error: '刷新 Token 失败' });
}
});
// 删除 Token
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
Token.delete(id);
res.json({ success: true });
} catch (error) {
console.error('删除 Token 失败:', error);
res.status(500).json({ error: '删除 Token 失败' });
}
});
// 批量删除 Tokens
router.post('/batch-delete', (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: '请提供有效的 ids 数组' });
}
let successCount = 0;
let failedCount = 0;
const errors = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
try {
Token.delete(id);
successCount++;
} catch (error) {
failedCount++;
errors.push(`ID ${id}: ${error.message}`);
}
}
res.json({
success: true,
total: ids.length,
success: successCount,
failed: failedCount,
errors: errors.length > 0 ? errors : undefined,
message: `批量删除完成:成功 ${successCount} 个,失败 ${failedCount}`
});
} catch (error) {
console.error('批量删除 Tokens 失败:', error);
res.status(500).json({ error: '批量删除失败: ' + error.message });
}
});
// 刷新 Token 额度
router.post('/:id/quota', async (req, res) => {
try {
const { id } = req.params;
const token = Token.findById(id);
if (!token) {
return res.status(404).json({ error: 'Token 不存在' });
}
// OpenAI Codex API 没有直接的额度查询接口
// 我们根据以下信息估算额度:
// 1. 从 ID Token 解析订阅类型(免费/付费)
// 2. 根据请求统计估算使用情况
// 3. 根据失败率判断是否接近额度上限
let planType = 'free'; // 默认免费
let totalQuota = 50000; // 免费账号默认额度
// 尝试从 id_token 解析订阅信息
if (token.id_token) {
try {
const parts = token.id_token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
const authInfo = payload['https://api.openai.com/auth'];
if (authInfo && authInfo.chatgpt_plan_type) {
planType = authInfo.chatgpt_plan_type.toLowerCase();
// 根据订阅类型设置额度
if (planType.includes('plus') || planType.includes('pro')) {
totalQuota = 500000; // 付费账号更高额度
} else if (planType.includes('team')) {
totalQuota = 1000000;
}
}
}
} catch (e) {
console.warn('解析 ID Token 失败:', e.message);
}
}
// 根据请求统计估算已使用额度
// 假设每次成功请求消耗约 100 tokens
const estimatedUsed = (token.success_requests || 0) * 100;
const remaining = Math.max(0, totalQuota - estimatedUsed);
// 如果失败率很高,可能接近额度上限
const failureRate = token.total_requests > 0
? (token.failed_requests || 0) / token.total_requests
: 0;
const quota = {
total: totalQuota,
used: estimatedUsed,
remaining: remaining,
plan_type: planType,
failure_rate: Math.round(failureRate * 100)
};
// 更新数据库
Token.updateQuota(id, quota);
res.json({
success: true,
quota,
message: '额度已更新(基于请求统计估算)'
});
} catch (error) {
console.error('刷新额度失败:', error);
res.status(500).json({ error: '刷新额度失败: ' + error.message });
}
});
// 批量刷新所有 Token 额度
router.post('/quota/refresh-all', async (req, res) => {
try {
const tokens = Token.getAll();
let successCount = 0;
let failedCount = 0;
for (const token of tokens) {
try {
let planType = 'free';
let totalQuota = 50000;
// 解析 ID Token 获取订阅类型
if (token.id_token) {
try {
const parts = token.id_token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
const authInfo = payload['https://api.openai.com/auth'];
if (authInfo && authInfo.chatgpt_plan_type) {
planType = authInfo.chatgpt_plan_type.toLowerCase();
if (planType.includes('plus') || planType.includes('pro')) {
totalQuota = 500000;
} else if (planType.includes('team')) {
totalQuota = 1000000;
}
}
}
} catch (e) {
// 忽略解析错误
}
}
const estimatedUsed = (token.success_requests || 0) * 100;
const remaining = Math.max(0, totalQuota - estimatedUsed);
const quota = {
total: totalQuota,
used: estimatedUsed,
remaining: remaining
};
Token.updateQuota(token.id, quota);
successCount++;
} catch (error) {
console.error(`刷新 Token ${token.id} 额度失败:`, error);
failedCount++;
}
}
res.json({
success: true,
total: tokens.length,
success: successCount,
failed: failedCount,
message: `批量刷新完成:成功 ${successCount} 个,失败 ${failedCount}`
});
} catch (error) {
console.error('批量刷新额度失败:', error);
res.status(500).json({ error: '批量刷新失败: ' + error.message });
}
});
export default router;

View File

@@ -0,0 +1,39 @@
import bcrypt from 'bcrypt';
import db, { initDatabase } from '../config/database.js';
import dotenv from 'dotenv';
dotenv.config();
// 初始化数据库
initDatabase();
// 创建默认管理员账户
const defaultUsername = process.env.ADMIN_USERNAME || 'admin';
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123';
try {
// 检查是否已存在管理员
const existingUser = db.prepare('SELECT * FROM users WHERE username = ?').get(defaultUsername);
if (!existingUser) {
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(
defaultUsername,
hashedPassword
);
console.log('✓ 默认管理员账户已创建');
console.log(` 用户名: ${defaultUsername}`);
console.log(` 密码: ${defaultPassword}`);
console.log(' 请登录后立即修改密码!');
} else {
console.log('✓ 管理员账户已存在');
}
console.log('\n数据库初始化完成');
process.exit(0);
} catch (error) {
console.error('❌ 初始化失败:', error);
process.exit(1);
}

View File

@@ -1,10 +1,16 @@
import fs from 'fs/promises';
import axios from 'axios';
import httpsProxyAgent from 'https-proxy-agent';
const { HttpsProxyAgent } = httpsProxyAgent;
// OpenAI OAuth 配置
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
// 代理配置
const PROXY_URL = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
/**
* Token 管理器
*/
@@ -45,10 +51,10 @@ class TokenManager {
* 检查 token 是否过期
*/
isTokenExpired() {
if (!this.tokenData || !this.tokenData.expired) {
if (!this.tokenData || !this.tokenData.expired_at) {
return true;
}
const expireTime = new Date(this.tokenData.expired);
const expireTime = new Date(this.tokenData.expired_at);
const now = new Date();
// 提前 5 分钟刷新
return expireTime.getTime() - now.getTime() < 5 * 60 * 1000;
@@ -72,12 +78,20 @@ class TokenManager {
scope: 'openid profile email'
});
const response = await axios.post(TOKEN_URL, params.toString(), {
const config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
});
};
// 如果配置了代理,使用代理
if (PROXY_URL) {
config.httpsAgent = new HttpsProxyAgent(PROXY_URL);
console.log(`使用代理: ${PROXY_URL}`);
}
const response = await axios.post(TOKEN_URL, params.toString(), config);
const { access_token, refresh_token, id_token, expires_in } = response.data;
@@ -87,8 +101,8 @@ class TokenManager {
access_token,
refresh_token: refresh_token || this.tokenData.refresh_token,
id_token: id_token || this.tokenData.id_token,
expired: new Date(Date.now() + expires_in * 1000).toISOString(),
last_refresh: new Date().toISOString()
expired_at: new Date(Date.now() + expires_in * 1000).toISOString(),
last_refresh_at: new Date().toISOString()
};
await this.saveToken(newTokenData);
@@ -123,7 +137,7 @@ class TokenManager {
return {
email: this.tokenData?.email,
account_id: this.tokenData?.account_id,
expired: this.tokenData?.expired,
expired_at: this.tokenData?.expired_at,
type: this.tokenData?.type
};
}