feat: 添加批量删除、最近活动、界面优化等功能
This commit is contained in:
10
.env.exa
10
.env.exa
@@ -6,3 +6,13 @@ TOKEN_FILE=./token.json
|
|||||||
|
|
||||||
# 模型列表文件路径
|
# 模型列表文件路径
|
||||||
MODELS_FILE=./models.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
371
DEPLOYMENT.md
Normal 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
270
README.md
@@ -1,15 +1,55 @@
|
|||||||
# GPT2API Node
|
# 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 反向代理
|
- ✅ OpenAI Codex 反向代理
|
||||||
|
- ✅ 完整的 Web 管理后台
|
||||||
|
- ✅ 多账号管理和批量导入
|
||||||
- ✅ 自动 Token 刷新机制
|
- ✅ 自动 Token 刷新机制
|
||||||
|
- ✅ 负载均衡(轮询/随机/最少使用)
|
||||||
|
- ✅ API Key 管理和认证
|
||||||
|
- ✅ 请求统计和数据分析
|
||||||
- ✅ 支持流式和非流式响应
|
- ✅ 支持流式和非流式响应
|
||||||
- ✅ OpenAI API 兼容接口
|
- ✅ OpenAI API 兼容接口
|
||||||
- ✅ JSON 文件导入 Token
|
- ✅ 批量删除账号功能
|
||||||
- ✅ 简单易用的配置
|
- ✅ 实时活动记录
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -20,37 +60,17 @@ cd gpt2api-node
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置 Token
|
### 2. 初始化数据库
|
||||||
|
|
||||||
从 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` 并修改配置:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
npm run init-db
|
||||||
```
|
```
|
||||||
|
|
||||||
```env
|
默认管理员账户:
|
||||||
PORT=3000
|
- 用户名:`admin`
|
||||||
TOKEN_FILE=./token.json
|
- 密码:`admin123`
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 启动服务
|
### 3. 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
@@ -62,16 +82,71 @@ npm start
|
|||||||
npm run dev
|
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 接口
|
## API 接口
|
||||||
|
|
||||||
### 聊天完成接口
|
### 聊天完成接口
|
||||||
|
|
||||||
**端点**: `POST /v1/chat/completions`
|
**端点**: `POST /v1/chat/completions`
|
||||||
|
|
||||||
|
**请求头**:
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_API_KEY
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
**请求示例**:
|
**请求示例**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/v1/chat/completions \
|
curl http://localhost:3000/v1/chat/completions \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"model": "gpt-5.3-codex",
|
"model": "gpt-5.3-codex",
|
||||||
@@ -86,6 +161,7 @@ curl http://localhost:3000/v1/chat/completions \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/v1/chat/completions \
|
curl http://localhost:3000/v1/chat/completions \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"model": "gpt-5.3-codex",
|
"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` - GPT 5.3 Codex(最新)
|
||||||
- `gpt-5.3-codex-spark` - GPT 5.3 Codex Spark(超快速编码模型)
|
|
||||||
- `gpt-5.2` - GPT 5.2
|
- `gpt-5.2` - GPT 5.2
|
||||||
- `gpt-5.2-codex` - GPT 5.2 Codex
|
- `gpt-5.2-codex` - GPT 5.2 Codex
|
||||||
- `gpt-5.1` - GPT 5.1
|
- `gpt-5.1` - GPT 5.1
|
||||||
@@ -130,12 +205,12 @@ curl http://localhost:3000/health
|
|||||||
|
|
||||||
Cherry Studio 是一个支持多种 AI 服务的桌面客户端。配置步骤:
|
Cherry Studio 是一个支持多种 AI 服务的桌面客户端。配置步骤:
|
||||||
|
|
||||||
### 1. 启动代理服务
|
### 1. 创建 API Key
|
||||||
|
|
||||||
```bash
|
1. 访问管理后台:`http://localhost:3000/admin`
|
||||||
cd gpt2api-node
|
2. 进入 **API Keys** 页面
|
||||||
npm start
|
3. 点击 **创建 API Key**
|
||||||
```
|
4. 复制生成的 API Key(只显示一次)
|
||||||
|
|
||||||
### 2. 在 Cherry Studio 中配置
|
### 2. 在 Cherry Studio 中配置
|
||||||
|
|
||||||
@@ -145,22 +220,13 @@ npm start
|
|||||||
4. 填写配置:
|
4. 填写配置:
|
||||||
- **名称**: GPT2API Node(或自定义名称)
|
- **名称**: GPT2API Node(或自定义名称)
|
||||||
- **API 地址**: `http://localhost:3000/v1`
|
- **API 地址**: `http://localhost:3000/v1`
|
||||||
- **API Key**: 随意填写(如 `dummy`),不会被验证
|
- **API Key**: 粘贴刚才创建的 API Key
|
||||||
- **模型**: 选择或手动输入模型名称(如 `gpt-5.3-codex`)
|
- **模型**: 选择或手动输入模型名称(如 `gpt-5.3-codex`)
|
||||||
|
|
||||||
### 3. 开始使用
|
### 3. 开始使用
|
||||||
|
|
||||||
配置完成后,在 Cherry Studio 中选择刚才添加的提供商和模型,即可开始对话。
|
配置完成后,在 Cherry Studio 中选择刚才添加的提供商和模型,即可开始对话。
|
||||||
|
|
||||||
### 可用模型列表
|
|
||||||
|
|
||||||
在 Cherry Studio 中可以使用以下任意模型:
|
|
||||||
- `gpt-5.3-codex` - 推荐,最新版本
|
|
||||||
- `gpt-5.3-codex-spark` - 超快速编码
|
|
||||||
- `gpt-5.2-codex` - 稳定版本
|
|
||||||
- `gpt-5.1-codex` - 较旧版本
|
|
||||||
- 其他 GPT-5 系列模型
|
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -170,7 +236,7 @@ import openai
|
|||||||
|
|
||||||
client = openai.OpenAI(
|
client = openai.OpenAI(
|
||||||
base_url="http://localhost:3000/v1",
|
base_url="http://localhost:3000/v1",
|
||||||
api_key="dummy" # 不需要真实的 API key
|
api_key="YOUR_API_KEY"
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
@@ -190,7 +256,7 @@ import OpenAI from 'openai';
|
|||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
baseURL: 'http://localhost:3000/v1',
|
baseURL: 'http://localhost:3000/v1',
|
||||||
apiKey: 'dummy'
|
apiKey: 'YOUR_API_KEY'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.chat.completions.create({
|
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);
|
console.log(response.choices[0].message.content);
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
## Token 管理
|
||||||
|
|
||||||
```bash
|
### 批量导入
|
||||||
curl http://localhost:3000/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
1. 准备 JSON 文件,格式如下:
|
||||||
-d '{
|
|
||||||
"model": "gpt-5.3-codex",
|
```json
|
||||||
"messages": [
|
[
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
{
|
||||||
{"role": "user", "content": "What is the capital of France?"}
|
"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 文件格式
|
```env
|
||||||
|
PORT=3000
|
||||||
Token 文件必须包含以下字段:
|
SESSION_SECRET=your-secret-key-change-in-production
|
||||||
|
LOAD_BALANCE_STRATEGY=round-robin
|
||||||
- `access_token`: 访问令牌
|
MODELS_FILE=./models.json
|
||||||
- `refresh_token`: 刷新令牌
|
```
|
||||||
- `id_token`: ID 令牌(可选)
|
|
||||||
- `account_id`: 账户 ID(可选)
|
|
||||||
- `email`: 邮箱(可选)
|
|
||||||
- `expired`: 过期时间(ISO 8601 格式)
|
|
||||||
- `type`: 类型(固定为 "codex")
|
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -246,34 +318,66 @@ gpt2api-node/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── index.js # 主服务器文件
|
│ ├── index.js # 主服务器文件
|
||||||
│ ├── tokenManager.js # Token 管理模块
|
│ ├── 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
|
├── package.json
|
||||||
├── .env.example
|
|
||||||
├── token.example.json
|
|
||||||
├── .gitignore
|
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. **Token 安全**: 请妥善保管 `token.json` 文件,不要提交到版本控制系统
|
1. **安全性**:
|
||||||
|
- 首次登录后请立即修改管理员密码
|
||||||
|
- 妥善保管 API Keys
|
||||||
|
- 生产环境请使用 HTTPS
|
||||||
|
|
||||||
2. **网络要求**: 需要能够访问 `chatgpt.com` 和 `auth.openai.com`
|
2. **网络要求**: 需要能够访问 `chatgpt.com` 和 `auth.openai.com`
|
||||||
|
|
||||||
3. **Token 有效期**: Token 会自动刷新,但如果 refresh_token 失效,需要重新获取
|
3. **Token 有效期**: Token 会自动刷新,但如果 refresh_token 失效,需要重新获取
|
||||||
|
|
||||||
4. **并发限制**: 根据 OpenAI 账户限制,注意控制并发请求数量
|
4. **并发限制**: 根据 OpenAI 账户限制,注意控制并发请求数量
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
### Token 加载失败
|
### 无法访问管理后台
|
||||||
|
|
||||||
确保 `token.json` 文件存在且格式正确,参考 `token.example.json`。
|
确保服务已启动,访问 `http://localhost:3000/admin`
|
||||||
|
|
||||||
|
### 数据库初始化失败
|
||||||
|
|
||||||
|
删除 `database/app.db` 文件,重新运行 `npm run init-db`
|
||||||
|
|
||||||
### Token 刷新失败
|
### Token 刷新失败
|
||||||
|
|
||||||
可能是 refresh_token 已过期,需要重新从 CLIProxyAPI 获取新的 token。
|
可能是 refresh_token 已过期,需要重新导入新的 token
|
||||||
|
|
||||||
### 代理请求失败
|
### API 请求失败
|
||||||
|
|
||||||
检查网络连接,确保能够访问 OpenAI 服务。
|
1. 检查 API Key 是否正确
|
||||||
|
2. 确保有可用的 Token 账号
|
||||||
|
3. 查看管理后台的请求日志
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
99
UI.md
Normal file
99
UI.md
Normal 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
BIN
database/app.db
Normal file
Binary file not shown.
@@ -5,12 +5,6 @@
|
|||||||
"created": 1770307200,
|
"created": 1770307200,
|
||||||
"owned_by": "openai"
|
"owned_by": "openai"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "gpt-5.3-codex-spark",
|
|
||||||
"object": "model",
|
|
||||||
"created": 1770912000,
|
|
||||||
"owned_by": "openai"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "gpt-5.2",
|
"id": "gpt-5.2",
|
||||||
"object": "model",
|
"object": "model",
|
||||||
|
|||||||
1354
package-lock.json
generated
1354
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,19 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "gpt2api-node",
|
"name": "gpt2api-node",
|
||||||
"version": "1.0.0",
|
"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",
|
"main": "src/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"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": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"axios": "^1.6.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
306
plans/admin-system-architecture.md
Normal file
306
plans/admin-system-architecture.md
Normal 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(密码加密)
|
||||||
|
- jsonwebtoken(JWT 认证)
|
||||||
|
- multer(文件上传)
|
||||||
|
- express-session(会话管理)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 前端技术栈
|
||||||
|
```
|
||||||
|
- HTML5 + TailwindCSS + DaisyUI
|
||||||
|
- Vanilla JavaScript(无框架,保持轻量)
|
||||||
|
- Fetch API(HTTP 请求)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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. 创建初始管理员账户
|
||||||
|
|
||||||
|
### 阶段 2:API Key 管理
|
||||||
|
1. API Key 生成和存储
|
||||||
|
2. API Key 验证中间件
|
||||||
|
3. API Key 管理接口
|
||||||
|
|
||||||
|
### 阶段 3:Token 管理
|
||||||
|
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
609
public/admin/index.html
Normal 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
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
155
public/admin/login.html
Normal 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
238
public/js/app.js
Normal 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
BIN
screenshots/API keys.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
screenshots/仪表盘.png
Normal file
BIN
screenshots/仪表盘.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
screenshots/数据分析.png
Normal file
BIN
screenshots/数据分析.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
screenshots/管理员登录.png
Normal file
BIN
screenshots/管理员登录.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
screenshots/系统设置.png
Normal file
BIN
screenshots/系统设置.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
screenshots/账号管理.png
Normal file
BIN
screenshots/账号管理.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
131
src/config/database.js
Normal file
131
src/config/database.js
Normal 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;
|
||||||
216
src/index.js
216
src/index.js
@@ -1,22 +1,49 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs/promises';
|
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 TokenManager from './tokenManager.js';
|
||||||
import ProxyHandler from './proxyHandler.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();
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const TOKEN_FILE = process.env.TOKEN_FILE || './token.json';
|
|
||||||
const MODELS_FILE = process.env.MODELS_FILE || './models.json';
|
const MODELS_FILE = process.env.MODELS_FILE || './models.json';
|
||||||
|
|
||||||
// 中间件
|
// 初始化数据库
|
||||||
app.use(express.json());
|
initDatabase();
|
||||||
|
|
||||||
// 初始化 Token 管理器和代理处理器
|
// 中间件
|
||||||
const tokenManager = new TokenManager(TOKEN_FILE);
|
app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制以支持批量导入
|
||||||
const proxyHandler = new ProxyHandler(tokenManager);
|
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 = [];
|
let modelsList = [];
|
||||||
@@ -32,33 +59,151 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动时加载 token
|
// 创建 Token 管理器池
|
||||||
await tokenManager.loadToken().catch(err => {
|
const tokenManagers = new Map();
|
||||||
console.error('❌ 启动失败:', err.message);
|
let currentTokenIndex = 0; // 轮询索引
|
||||||
console.error('请确保 token.json 文件存在且格式正确');
|
|
||||||
process.exit(1);
|
// 负载均衡策略
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 健康检查
|
// ==================== 代理接口(需要 API Key) ====================
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
token: tokenManager.getTokenInfo()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// OpenAI 兼容的聊天完成接口
|
// 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;
|
const isStream = req.body.stream === true;
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
await proxyHandler.handleStreamRequest(req, res);
|
await proxyHandler.handleStreamRequest(req, res);
|
||||||
|
success = true;
|
||||||
|
statusCode = 200;
|
||||||
} else {
|
} else {
|
||||||
await proxyHandler.handleNonStreamRequest(req, res);
|
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) => {
|
app.get('/v1/models', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
object: 'list',
|
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) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error('服务器错误:', err);
|
console.error('服务器错误:', err);
|
||||||
@@ -79,14 +233,22 @@ app.use((err, req, res, next) => {
|
|||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
const activeTokens = Token.getActive();
|
||||||
|
const allTokens = Token.getAll();
|
||||||
|
const strategyNames = {
|
||||||
|
'round-robin': '轮询',
|
||||||
|
'random': '随机',
|
||||||
|
'least-used': '最少使用'
|
||||||
|
};
|
||||||
|
|
||||||
console.log('=================================');
|
console.log('=================================');
|
||||||
console.log('🚀 GPT2API Node 服务已启动');
|
console.log('🚀 GPT2API Node 管理系统已启动');
|
||||||
console.log(`📡 监听端口: ${PORT}`);
|
console.log(`📡 监听端口: ${PORT}`);
|
||||||
console.log(`👤 账户: ${tokenManager.getTokenInfo().email || tokenManager.getTokenInfo().account_id}`);
|
console.log(`⚖️ 账号总数: ${allTokens.length} | 负载均衡: ${strategyNames[LOAD_BALANCE_STRATEGY] || LOAD_BALANCE_STRATEGY}`);
|
||||||
console.log(`⏰ Token 过期时间: ${tokenManager.getTokenInfo().expired}`);
|
console.log(`🔑 活跃账号: ${activeTokens.length} 个`);
|
||||||
console.log('=================================');
|
console.log('=================================');
|
||||||
console.log(`\n接口地址:`);
|
console.log(`\n管理后台: http://localhost:${PORT}/admin`);
|
||||||
console.log(` - 聊天: POST http://localhost:${PORT}/v1/chat/completions`);
|
console.log(`API 接口: http://localhost:${PORT}/v1/chat/completions`);
|
||||||
console.log(` - 模型: GET http://localhost:${PORT}/v1/models`);
|
console.log(`\n首次使用请运行: npm run init-db`);
|
||||||
console.log(` - 健康: GET http://localhost:${PORT}/health\n`);
|
console.log(`默认账户: admin / admin123\n`);
|
||||||
});
|
});
|
||||||
|
|||||||
69
src/middleware/auth.js
Normal file
69
src/middleware/auth.js
Normal 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
181
src/models/index.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,10 +230,10 @@ class ProxyHandler {
|
|||||||
async handleStreamRequest(req, res) {
|
async handleStreamRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
const openaiRequest = req.body;
|
const openaiRequest = req.body;
|
||||||
console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
|
// console.log('收到请求:', JSON.stringify(openaiRequest, null, 2));
|
||||||
|
|
||||||
const codexRequest = this.transformRequest(openaiRequest);
|
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();
|
const accessToken = await this.tokenManager.getValidToken();
|
||||||
|
|
||||||
@@ -345,10 +345,10 @@ class ProxyHandler {
|
|||||||
async handleNonStreamRequest(req, res) {
|
async handleNonStreamRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
const openaiRequest = req.body;
|
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 });
|
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();
|
const accessToken = await this.tokenManager.getValidToken();
|
||||||
|
|
||||||
|
|||||||
68
src/routes/apiKeys.js
Normal file
68
src/routes/apiKeys.js
Normal 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
111
src/routes/auth.js
Normal 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
75
src/routes/settings.js
Normal 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
254
src/routes/stats.js
Normal 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
357
src/routes/tokens.js
Normal 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;
|
||||||
39
src/scripts/initDatabase.js
Normal file
39
src/scripts/initDatabase.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import httpsProxyAgent from 'https-proxy-agent';
|
||||||
|
|
||||||
|
const { HttpsProxyAgent } = httpsProxyAgent;
|
||||||
|
|
||||||
// OpenAI OAuth 配置
|
// OpenAI OAuth 配置
|
||||||
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||||
|
|
||||||
|
// 代理配置
|
||||||
|
const PROXY_URL = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token 管理器
|
* Token 管理器
|
||||||
*/
|
*/
|
||||||
@@ -45,10 +51,10 @@ class TokenManager {
|
|||||||
* 检查 token 是否过期
|
* 检查 token 是否过期
|
||||||
*/
|
*/
|
||||||
isTokenExpired() {
|
isTokenExpired() {
|
||||||
if (!this.tokenData || !this.tokenData.expired) {
|
if (!this.tokenData || !this.tokenData.expired_at) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const expireTime = new Date(this.tokenData.expired);
|
const expireTime = new Date(this.tokenData.expired_at);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// 提前 5 分钟刷新
|
// 提前 5 分钟刷新
|
||||||
return expireTime.getTime() - now.getTime() < 5 * 60 * 1000;
|
return expireTime.getTime() - now.getTime() < 5 * 60 * 1000;
|
||||||
@@ -72,12 +78,20 @@ class TokenManager {
|
|||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.post(TOKEN_URL, params.toString(), {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'Accept': 'application/json'
|
'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;
|
const { access_token, refresh_token, id_token, expires_in } = response.data;
|
||||||
|
|
||||||
@@ -87,8 +101,8 @@ class TokenManager {
|
|||||||
access_token,
|
access_token,
|
||||||
refresh_token: refresh_token || this.tokenData.refresh_token,
|
refresh_token: refresh_token || this.tokenData.refresh_token,
|
||||||
id_token: id_token || this.tokenData.id_token,
|
id_token: id_token || this.tokenData.id_token,
|
||||||
expired: new Date(Date.now() + expires_in * 1000).toISOString(),
|
expired_at: new Date(Date.now() + expires_in * 1000).toISOString(),
|
||||||
last_refresh: new Date().toISOString()
|
last_refresh_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.saveToken(newTokenData);
|
await this.saveToken(newTokenData);
|
||||||
@@ -123,7 +137,7 @@ class TokenManager {
|
|||||||
return {
|
return {
|
||||||
email: this.tokenData?.email,
|
email: this.tokenData?.email,
|
||||||
account_id: this.tokenData?.account_id,
|
account_id: this.tokenData?.account_id,
|
||||||
expired: this.tokenData?.expired,
|
expired_at: this.tokenData?.expired_at,
|
||||||
type: this.tokenData?.type
|
type: this.tokenData?.type
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user