优化后端模型配置交互,优化后端模型配置可读性
This commit is contained in:
BIN
airouter-backend.tar
Normal file
BIN
airouter-backend.tar
Normal file
Binary file not shown.
BIN
airouter-frontend.tar
Normal file
BIN
airouter-frontend.tar
Normal file
Binary file not shown.
478
docs/LOG_CLEANER.md
Normal file
478
docs/LOG_CLEANER.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# 日志自动清理功能文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
AI Gateway 提供了自动清理请求日志的功能,可以定期删除过期的日志记录,防止数据库文件无限增长。此功能通过配置文件进行管理,支持灵活的定时清理策略。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 定时自动清理
|
||||||
|
- **自动执行**:在指定时间自动清理过期日志
|
||||||
|
- **可配置保留期**:灵活设置日志保留天数(默认7天)
|
||||||
|
- **空间回收**:执行SQLite VACUUM操作,真正回收磁盘空间
|
||||||
|
- **详细日志**:记录每次清理的详细统计信息
|
||||||
|
|
||||||
|
### 2. 手动触发清理
|
||||||
|
- 通过API端点手动触发立即清理
|
||||||
|
- 返回详细的清理报告
|
||||||
|
- 适用于紧急清理或测试场景
|
||||||
|
|
||||||
|
### 3. 状态监控
|
||||||
|
- 查询清理器当前状态
|
||||||
|
- 查看下次执行时间
|
||||||
|
- 监控清理配置参数
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 配置文件位置
|
||||||
|
|
||||||
|
配置文件默认位于项目根目录:`config.json`
|
||||||
|
|
||||||
|
如果配置文件不存在,系统会自动创建并使用默认配置。
|
||||||
|
|
||||||
|
### 配置文件格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"database": {
|
||||||
|
"path": "./gateway.db"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"port": "8080",
|
||||||
|
"host": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"log_cleaner": {
|
||||||
|
"enabled": true,
|
||||||
|
"execute_time": "02:00",
|
||||||
|
"retention_days": 7,
|
||||||
|
"check_interval": 5
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"name": "AI Gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"environment": "production",
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志清理配置参数详解
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `enabled` | boolean | `true` | 是否启用自动清理功能 |
|
||||||
|
| `execute_time` | string | `"02:00"` | 每天执行清理的时间(24小时制,格式:HH:MM) |
|
||||||
|
| `retention_days` | integer | `7` | 日志保留天数,超过此天数的日志将被删除 |
|
||||||
|
| `check_interval` | integer | `5` | 检查间隔(分钟),系统每隔此时间检查是否到达执行时间 |
|
||||||
|
|
||||||
|
### 配置示例
|
||||||
|
|
||||||
|
#### 示例1:每天凌晨2点清理30天前的日志
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log_cleaner": {
|
||||||
|
"enabled": true,
|
||||||
|
"execute_time": "02:00",
|
||||||
|
"retention_days": 30,
|
||||||
|
"check_interval": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例2:每天中午12点清理3天前的日志
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log_cleaner": {
|
||||||
|
"enabled": true,
|
||||||
|
"execute_time": "12:00",
|
||||||
|
"retention_days": 3,
|
||||||
|
"check_interval": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例3:禁用自动清理
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log_cleaner": {
|
||||||
|
"enabled": false,
|
||||||
|
"execute_time": "02:00",
|
||||||
|
"retention_days": 7,
|
||||||
|
"check_interval": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 启动服务
|
||||||
|
|
||||||
|
系统启动时会自动加载配置并启动日志清理器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
启动日志会显示清理器状态:
|
||||||
|
```
|
||||||
|
🚀 启动 AI Gateway 服务...
|
||||||
|
✅ 配置文件加载成功: ./config.json
|
||||||
|
📋 当前配置:
|
||||||
|
🗄️ 数据库路径: ./gateway.db
|
||||||
|
🌐 服务器地址: 0.0.0.0:8080
|
||||||
|
🧹 日志自动清理: true
|
||||||
|
⏰ 执行时间: 02:00
|
||||||
|
📅 保留天数: 7天
|
||||||
|
🔍 检查间隔: 5分钟
|
||||||
|
✅ 数据库连接成功
|
||||||
|
🚀 启动日志自动清理器 - 执行时间: 02:00, 保留天数: 7天
|
||||||
|
✅ 日志自动清理器已启动
|
||||||
|
🌐 HTTP服务器启动在 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询清理器状态
|
||||||
|
|
||||||
|
**API端点**: `GET /api/log-cleaner/status`
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8080/api/log-cleaner/status \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"execute_time": "02:00",
|
||||||
|
"retention_days": 7,
|
||||||
|
"check_interval": 5,
|
||||||
|
"next_execute_time": "2025-11-12 02:00:00",
|
||||||
|
"time_until_next": "18h34m22s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 手动触发清理
|
||||||
|
|
||||||
|
**API端点**: `POST /api/log-cleaner/force-cleanup`
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/log-cleaner/force-cleanup \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Manual log cleanup completed",
|
||||||
|
"execute_time": "2025-11-11 15:30:45",
|
||||||
|
"duration": "1.234s",
|
||||||
|
"deleted_count": 5000,
|
||||||
|
"total_count_before": 10000,
|
||||||
|
"active_count_before": 10000,
|
||||||
|
"total_count_after": 5000,
|
||||||
|
"cutoff_time": "2025-11-04 15:30:45",
|
||||||
|
"vacuum_duration": "0.567s",
|
||||||
|
"vacuum_error": "",
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 修改配置
|
||||||
|
|
||||||
|
#### 方法1:修改配置文件后重启
|
||||||
|
1. 编辑 `config.json` 文件
|
||||||
|
2. 修改 `log_cleaner` 配置项
|
||||||
|
3. 重启服务
|
||||||
|
|
||||||
|
#### 方法2:通过环境变量(未来支持)
|
||||||
|
未来版本将支持通过环境变量覆盖配置。
|
||||||
|
|
||||||
|
## 清理执行流程
|
||||||
|
|
||||||
|
### 自动清理流程
|
||||||
|
|
||||||
|
```
|
||||||
|
系统启动
|
||||||
|
↓
|
||||||
|
加载配置文件
|
||||||
|
↓
|
||||||
|
初始化日志清理器
|
||||||
|
↓
|
||||||
|
启动定时检查(每5分钟)
|
||||||
|
↓
|
||||||
|
检查当前时间是否匹配执行时间
|
||||||
|
↓
|
||||||
|
【匹配】→ 执行清理任务
|
||||||
|
↓
|
||||||
|
1. 计算截止时间(当前时间 - 保留天数)
|
||||||
|
2. 硬删除截止时间之前的日志记录
|
||||||
|
3. 执行VACUUM操作回收磁盘空间
|
||||||
|
4. 记录清理统计信息
|
||||||
|
5. 生成并记录清理报告
|
||||||
|
↓
|
||||||
|
继续定时检查...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理报告说明
|
||||||
|
|
||||||
|
每次清理(自动或手动)都会生成详细的清理报告,包含以下信息:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `execute_time` | 清理执行的时间 |
|
||||||
|
| `duration` | 清理操作总耗时 |
|
||||||
|
| `deleted_count` | 删除的日志记录数 |
|
||||||
|
| `total_count_before` | 清理前的总记录数(包括软删除) |
|
||||||
|
| `active_count_before` | 清理前的活跃记录数 |
|
||||||
|
| `total_count_after` | 清理后的总记录数 |
|
||||||
|
| `cutoff_time` | 清理的截止时间(此时间之前的日志被删除) |
|
||||||
|
| `vacuum_duration` | VACUUM操作耗时 |
|
||||||
|
| `vacuum_error` | VACUUM操作错误信息(如有) |
|
||||||
|
| `success` | 清理是否成功 |
|
||||||
|
|
||||||
|
### 日志输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
⏰ 开始执行定时日志清理任务 - 当前时间: 2025-11-11 02:00:03
|
||||||
|
🧹 开始自动清理日志 - 删除 2025-11-04 02:00:03 之前的日志
|
||||||
|
✅ 自动日志清理完成:
|
||||||
|
📊 删除记录数: 5000
|
||||||
|
📈 删除前总数: 10000 (活跃: 10000)
|
||||||
|
📉 删除后总数: 5000
|
||||||
|
🗜️ VACUUM耗时: 0.567s
|
||||||
|
⏱️ 总耗时: 1.234s
|
||||||
|
📅 清理截止时间: 2025-11-04 02:00:03
|
||||||
|
📋 清理报告已生成 - 删除 5000 条记录,耗时 1.234s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能影响
|
||||||
|
|
||||||
|
### 数据库锁定
|
||||||
|
- **VACUUM操作**:会锁定整个数据库,期间无法进行写操作
|
||||||
|
- **建议**:将执行时间设置在业务低峰期(如凌晨)
|
||||||
|
- **耗时估算**:取决于数据库大小,通常几秒到几十秒
|
||||||
|
|
||||||
|
### 磁盘I/O
|
||||||
|
- DELETE操作和VACUUM操作都会产生磁盘I/O
|
||||||
|
- 对于大量数据(百万级),建议分批删除或调整保留策略
|
||||||
|
|
||||||
|
### 内存占用
|
||||||
|
- 清理过程内存占用较小
|
||||||
|
- VACUUM可能临时占用额外磁盘空间(最多为数据库文件大小)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 1. 清理器未启动
|
||||||
|
|
||||||
|
**症状**:启动日志显示"日志自动清理功能已禁用"
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 检查 `config.json` 中 `log_cleaner.enabled` 是否为 `true`
|
||||||
|
- 确认配置文件格式正确
|
||||||
|
|
||||||
|
### 2. 配置文件加载失败
|
||||||
|
|
||||||
|
**症状**:启动日志显示"加载配置失败,使用默认配置"
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 检查 `config.json` 文件是否存在
|
||||||
|
- 验证JSON格式是否正确
|
||||||
|
- 检查文件权限是否可读
|
||||||
|
|
||||||
|
### 3. VACUUM失败
|
||||||
|
|
||||||
|
**症状**:清理报告中显示 `vacuum_error` 不为空
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 磁盘空间不足
|
||||||
|
- 数据库文件被其他进程锁定
|
||||||
|
- 数据库文件损坏
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 确保有足够的磁盘空间(至少为数据库文件大小的2倍)
|
||||||
|
- 关闭其他可能访问数据库的进程
|
||||||
|
- 使用SQLite工具检查数据库完整性
|
||||||
|
|
||||||
|
### 4. 清理未在预定时间执行
|
||||||
|
|
||||||
|
**症状**:过了执行时间但未看到清理日志
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 检查系统时间是否正确
|
||||||
|
- 确认 `check_interval` 设置合理(不要太大)
|
||||||
|
- 查看应用日志是否有错误信息
|
||||||
|
- 验证服务是否正常运行
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 保留期设置建议
|
||||||
|
|
||||||
|
| 业务场景 | 建议保留期 | 说明 |
|
||||||
|
|---------|-----------|------|
|
||||||
|
| 开发测试环境 | 1-3天 | 快速清理,节省空间 |
|
||||||
|
| 生产环境(高流量) | 7-14天 | 平衡存储和可追溯性 |
|
||||||
|
| 生产环境(低流量) | 30-90天 | 长期保留用于分析 |
|
||||||
|
| 合规要求 | 根据法规 | 遵守行业规定 |
|
||||||
|
|
||||||
|
### 2. 执行时间建议
|
||||||
|
|
||||||
|
- **首选**:凌晨2-4点(业务低峰期)
|
||||||
|
- **备选**:中午12-14点(如果凌晨有定时任务)
|
||||||
|
- **避免**:业务高峰期
|
||||||
|
|
||||||
|
### 3. 检查间隔建议
|
||||||
|
|
||||||
|
- **推荐值**:5分钟
|
||||||
|
- **最小值**:1分钟(频繁检查会增加CPU开销)
|
||||||
|
- **最大值**:30分钟(间隔太大可能错过执行时间)
|
||||||
|
|
||||||
|
### 4. 监控建议
|
||||||
|
|
||||||
|
- 定期查看清理器状态API
|
||||||
|
- 监控数据库文件大小变化
|
||||||
|
- 关注清理报告中的异常信息
|
||||||
|
- 设置告警(如清理失败、VACUUM错误)
|
||||||
|
|
||||||
|
### 5. 备份策略
|
||||||
|
|
||||||
|
- 在首次启用清理功能前,备份数据库
|
||||||
|
- 定期备份配置文件
|
||||||
|
- 保留重要日志的导出副本
|
||||||
|
|
||||||
|
## 与手动清理的对比
|
||||||
|
|
||||||
|
| 特性 | 定时自动清理 | 手动清理(API) | 前端手动清理 |
|
||||||
|
|------|------------|----------------|-------------|
|
||||||
|
| 触发方式 | 自动定时 | API调用 | 前端操作 |
|
||||||
|
| 清理粒度 | 按天数 | 按天数 | 按天数 |
|
||||||
|
| 空间回收 | ✅ VACUUM | ✅ VACUUM | ✅ VACUUM |
|
||||||
|
| 清理报告 | ✅ 详细 | ✅ 详细 | ✅ 基本 |
|
||||||
|
| 适用场景 | 日常维护 | 紧急清理/脚本 | 临时清理 |
|
||||||
|
| 需要权限 | 无(系统级) | API认证 | 用户认证 |
|
||||||
|
|
||||||
|
## 技术实现细节
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
|
||||||
|
1. **调度器** (`backend/internal/scheduler/log_cleaner.go`)
|
||||||
|
- 定时检查逻辑
|
||||||
|
- 清理执行逻辑
|
||||||
|
- 报告生成
|
||||||
|
|
||||||
|
2. **配置管理** (`backend/internal/config/config.go`)
|
||||||
|
- 配置加载和验证
|
||||||
|
- 默认配置管理
|
||||||
|
|
||||||
|
3. **API端点** (`backend/api/handlers.go`)
|
||||||
|
- 状态查询接口
|
||||||
|
- 手动触发接口
|
||||||
|
|
||||||
|
4. **主程序集成** (`backend/main.go`)
|
||||||
|
- 启动初始化
|
||||||
|
- 优雅关闭
|
||||||
|
|
||||||
|
### 数据删除机制
|
||||||
|
|
||||||
|
系统使用**硬删除**(Unscoped Delete)而非GORM默认的软删除:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 硬删除,真正从数据库中移除记录
|
||||||
|
db.Unscoped().Where("created_at < ?", cutoffTime).Delete(&models.RequestLog{})
|
||||||
|
|
||||||
|
// 执行VACUUM回收空间
|
||||||
|
db.Exec("VACUUM")
|
||||||
|
```
|
||||||
|
|
||||||
|
这确保:
|
||||||
|
- 记录被物理删除
|
||||||
|
- 磁盘空间被真正释放
|
||||||
|
- 数据库文件大小减小
|
||||||
|
|
||||||
|
## 升级和迁移
|
||||||
|
|
||||||
|
### 从无配置文件版本升级
|
||||||
|
|
||||||
|
如果从旧版本升级,系统会自动创建默认配置文件。
|
||||||
|
|
||||||
|
### 配置迁移
|
||||||
|
|
||||||
|
1. 备份现有配置(如有)
|
||||||
|
2. 参考 `config.example.json` 添加新配置项
|
||||||
|
3. 重启服务验证
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
### 1. 权限控制
|
||||||
|
- 所有API端点需要认证(Authorization header)
|
||||||
|
- 手动触发清理需要管理员权限
|
||||||
|
|
||||||
|
### 2. 数据安全
|
||||||
|
- 删除操作不可逆
|
||||||
|
- 建议定期备份重要日志
|
||||||
|
- 清理前检查保留期设置
|
||||||
|
|
||||||
|
### 3. 配置安全
|
||||||
|
- 配置文件应有适当的文件权限(如644)
|
||||||
|
- 不要在配置中存储敏感信息
|
||||||
|
- 使用环境变量管理敏感配置(未来版本)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Q1: 如何临时禁用自动清理?
|
||||||
|
A: 修改配置文件,将 `log_cleaner.enabled` 设置为 `false`,然后重启服务。
|
||||||
|
|
||||||
|
### Q2: 清理会影响正在运行的服务吗?
|
||||||
|
A: VACUUM操作期间会短暂锁定数据库,建议在低峰期执行。正常的DELETE操作影响很小。
|
||||||
|
|
||||||
|
### Q3: 如何查看已删除的日志?
|
||||||
|
A: 删除后的日志无法恢复。如需保留,请在清理前导出或调整保留期。
|
||||||
|
|
||||||
|
### Q4: 可以设置多个清理时间吗?
|
||||||
|
A: 当前版本仅支持每天一次。如需多次清理,可以通过cron任务调用手动清理API。
|
||||||
|
|
||||||
|
### Q5: 数据库文件大小没有减小?
|
||||||
|
A: 确认清理日志中包含VACUUM操作且无错误。如果VACUUM失败,文件大小不会减小。
|
||||||
|
|
||||||
|
### Q6: 如何测试清理功能?
|
||||||
|
A: 使用手动触发API进行测试,或将执行时间设置为几分钟后。
|
||||||
|
|
||||||
|
### Q7: 清理会删除当天的日志吗?
|
||||||
|
A: 不会。清理只删除 `retention_days` 天之前的日志。例如保留7天,则只删除7天前的日志。
|
||||||
|
|
||||||
|
### Q8: 支持按大小清理吗?
|
||||||
|
A: 当前版本仅支持按时间清理。未来可能添加按大小或按数量清理的选项。
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.0.0 (2025-11-11)
|
||||||
|
- ✅ 首次发布
|
||||||
|
- ✅ 支持定时自动清理
|
||||||
|
- ✅ 支持手动触发清理
|
||||||
|
- ✅ 集成VACUUM空间回收
|
||||||
|
- ✅ 配置文件管理
|
||||||
|
- ✅ 详细清理报告
|
||||||
|
- ✅ 优雅关闭支持
|
||||||
|
|
||||||
|
## 未来计划
|
||||||
|
|
||||||
|
- [ ] 支持环境变量配置
|
||||||
|
- [ ] 支持按数据库大小触发清理
|
||||||
|
- [ ] 支持多个清理时间段
|
||||||
|
- [ ] 清理历史记录持久化
|
||||||
|
- [ ] 集成Prometheus监控指标
|
||||||
|
- [ ] 邮件/webhook通知
|
||||||
|
- [ ] 增量清理(分批删除大量数据)
|
||||||
|
- [ ] 清理前自动备份选项
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有问题或建议,请提交Issue或联系开发团队。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: 1.0.0
|
||||||
|
**最后更新**: 2025-11-11
|
||||||
|
**适用版本**: AI Gateway v1.0.0+
|
||||||
57
frontend/package-lock.json
generated
57
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -311,6 +314,55 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -3518,6 +3570,11 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
const DraggableBackendModelCard = ({ index, bm, providers, onModelChange, onRemove }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: `backend-model-${index}` });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`space-y-2 rounded-md border p-4 ${
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50 shadow-lg'
|
||||||
|
: 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing flex items-center text-gray-400 hover:text-gray-600"
|
||||||
|
title="拖动排序"
|
||||||
|
>
|
||||||
|
<span className="text-xl">≡</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-600">
|
||||||
|
优先级: {index + 1}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 供应商和后端模型名称一行 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">提供商</label>
|
||||||
|
<select
|
||||||
|
value={bm.ProviderID}
|
||||||
|
onChange={(e) => onModelChange(index, 'ProviderID', parseInt(e.target.value))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
>
|
||||||
|
<option value={0}>选择提供商</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.ID} value={p.ID}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">后端模型名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bm.Name}
|
||||||
|
onChange={(e) => onModelChange(index, 'Name', e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成本阈值、最大上下文长度、计费方式、Token单价一行 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">成本阈值</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={bm.CostThreshold || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange(index, 'CostThreshold', parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">最大上下文长度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={bm.MaxContextLength}
|
||||||
|
onChange={(e) => onModelChange(index, 'MaxContextLength', parseInt(e.target.value))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">计费方式</label>
|
||||||
|
<select
|
||||||
|
value={bm.BillingMethod}
|
||||||
|
onChange={(e) => onModelChange(index, 'BillingMethod', e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="token">按Token计费</option>
|
||||||
|
<option value="request">按请求次数计费</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token单价占位符,实际内容在下面条件渲染中 */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Token单价</label>
|
||||||
|
<div className="mt-1 h-10 flex items-center text-sm text-gray-500">
|
||||||
|
{bm.BillingMethod === 'token' ? '见下方详细设置' : '按请求计费'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token单价相关字段 - 按Token计费时显示 */}
|
||||||
|
{bm.BillingMethod === 'token' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">输入Token单价</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={bm.PromptTokenPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange(index, 'PromptTokenPrice', parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">输出Token单价</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={bm.CompletionTokenPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange(index, 'CompletionTokenPrice', parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 固定价格 - 按请求计费时显示 */}
|
||||||
|
{bm.BillingMethod === 'request' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">固定价格(按次)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={bm.FixedPrice}
|
||||||
|
onChange={(e) => onModelChange(index, 'FixedPrice', parseFloat(e.target.value))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DraggableBackendModelCard;
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getProviders } from '../../providers/api';
|
import { getProviders } from '../../providers/api';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import DraggableBackendModelCard from './DraggableBackendModelCard';
|
||||||
|
|
||||||
const VirtualModelForm = ({ model, onSave, onCancel }) => {
|
const VirtualModelForm = ({ model, onSave, onCancel }) => {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [backendModels, setBackendModels] = useState([]);
|
const [backendModels, setBackendModels] = useState([]);
|
||||||
const [providers, setProviders] = useState([]);
|
const [providers, setProviders] = useState([]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
setName(model.Name);
|
setName(model.Name);
|
||||||
@@ -46,9 +68,28 @@ const VirtualModelForm = ({ model, onSave, onCancel }) => {
|
|||||||
setBackendModels(newBackendModels);
|
setBackendModels(newBackendModels);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (active.id !== over.id) {
|
||||||
|
setBackendModels((items) => {
|
||||||
|
const oldIndex = parseInt(active.id.replace('backend-model-', ''));
|
||||||
|
const newIndex = parseInt(over.id.replace('backend-model-', ''));
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSave({ Name: name, BackendModels: backendModels });
|
|
||||||
|
// 自动设置优先级为索引+1
|
||||||
|
const backendModelsWithPriority = backendModels.map((model, index) => ({
|
||||||
|
...model,
|
||||||
|
Priority: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onSave({ Name: name, BackendModels: backendModelsWithPriority });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,126 +105,51 @@ const VirtualModelForm = ({ model, onSave, onCancel }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-medium">后端模型</h3>
|
<h3 className="text-lg font-medium">后端模型</h3>
|
||||||
{backendModels.map((bm, index) => (
|
|
||||||
<div key={index} className="space-y-2 rounded-md border border-gray-200 p-4">
|
<DndContext
|
||||||
<div className="flex justify-end">
|
sensors={sensors}
|
||||||
<button type="button" onClick={() => removeBackendModel(index)} className="text-red-500">
|
collisionDetection={closestCenter}
|
||||||
删除
|
onDragEnd={handleDragEnd}
|
||||||
</button>
|
>
|
||||||
</div>
|
<SortableContext
|
||||||
<div>
|
items={backendModels.map((_, index) => `backend-model-${index}`)}
|
||||||
<label className="block text-sm font-medium text-gray-700">提供商</label>
|
strategy={verticalListSortingStrategy}
|
||||||
<select
|
>
|
||||||
value={bm.ProviderID}
|
<div className="space-y-4">
|
||||||
onChange={(e) => handleBackendModelChange(index, 'ProviderID', parseInt(e.target.value))}
|
{backendModels.map((bm, index) => (
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
<DraggableBackendModelCard
|
||||||
>
|
key={`backend-model-${index}`}
|
||||||
<option value={0}>选择提供商</option>
|
index={index}
|
||||||
{providers.map((p) => {
|
bm={bm}
|
||||||
return (
|
providers={providers}
|
||||||
<option key={p.ID} value={p.ID}>
|
onModelChange={handleBackendModelChange}
|
||||||
{p.name}
|
onRemove={removeBackendModel}
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">后端模型名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={bm.Name}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'Name', e.target.value)}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">优先级(数字越小优先级越高)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={bm.Priority}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'Priority', parseInt(e.target.value))}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">成本阈值</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={bm.CostThreshold || 0}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'CostThreshold', parseFloat(e.target.value) || 0)}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">最大上下文长度</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={bm.MaxContextLength}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'MaxContextLength', parseInt(e.target.value))}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">计费方式</label>
|
|
||||||
<select
|
|
||||||
value={bm.BillingMethod}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'BillingMethod', e.target.value)}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
>
|
|
||||||
<option value="token">按Token计费</option>
|
|
||||||
<option value="request">按请求次数计费</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{bm.BillingMethod === 'token' && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">输入Token单价</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
value={bm.PromptTokenPrice}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'PromptTokenPrice', parseFloat(e.target.value))}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">输出Token单价</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
value={bm.CompletionTokenPrice}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'CompletionTokenPrice', parseFloat(e.target.value))}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{bm.BillingMethod === 'request' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">固定价格(按次)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={bm.FixedPrice}
|
|
||||||
onChange={(e) => handleBackendModelChange(index, 'FixedPrice', parseFloat(e.target.value))}
|
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</SortableContext>
|
||||||
))}
|
</DndContext>
|
||||||
|
|
||||||
<button type="button" onClick={addBackendModel} className="rounded bg-gray-200 px-4 py-2 text-sm">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addBackendModel}
|
||||||
|
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
||||||
|
>
|
||||||
添加后端模型
|
添加后端模型
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<button type="button" onClick={onCancel} className="rounded bg-gray-200 px-4 py-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300"
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="rounded bg-blue-500 px-4 py-2 text-white">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080', // 后端服务器地址
|
target: 'http://10.1.39.104:9130', // 后端服务器地址
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
0
start-docker-cn.sh
Normal file → Executable file
0
start-docker-cn.sh
Normal file → Executable file
619
前端请求日志性能优化方案.md
Normal file
619
前端请求日志性能优化方案.md
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
# 前端请求日志性能优化技术方案
|
||||||
|
|
||||||
|
## 1. 方案概述
|
||||||
|
|
||||||
|
本方案针对前端请求日志系统存在的三个高严重性性能瓶颈,提供系统性的优化解决方案:
|
||||||
|
|
||||||
|
- **后端返回过多不必要数据**:通过精简列表响应结构和独立详情API解决
|
||||||
|
- **前端筛选输入无防抖**:通过防抖机制和请求优化解决
|
||||||
|
- **缺少数据库查询优化**:通过复合索引策略解决
|
||||||
|
|
||||||
|
### 优化目标
|
||||||
|
- 减少90%+的数据传输量
|
||||||
|
- 减少80%+的不必要请求
|
||||||
|
- 提升50-80%的查询速度
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- 后端:Go + GORM + Gin
|
||||||
|
- 前端:React + Vite + Axios
|
||||||
|
- 数据库:SQLite(可扩展至MySQL/PostgreSQL)
|
||||||
|
|
||||||
|
## 2. 当前系统架构分析
|
||||||
|
|
||||||
|
### 2.1 数据流程
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[前端RequestLogList组件] -->|fetchLogs| B[GET /api/logs]
|
||||||
|
A -->|fetchStats| C[GET /api/logs/stats]
|
||||||
|
B --> D[GetRequestLogsHandler]
|
||||||
|
C --> E[GetRequestLogStatsHandler]
|
||||||
|
D --> F[数据库查询+完整RequestLog]
|
||||||
|
E --> G[统计查询]
|
||||||
|
F --> H[返回包含RequestBody/ResponseBody的完整数据]
|
||||||
|
G --> I[返回统计数据]
|
||||||
|
H --> A
|
||||||
|
I --> A
|
||||||
|
A --> J[RequestLogDetailModal]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 性能瓶颈分析
|
||||||
|
|
||||||
|
#### 🔴 问题1:后端返回过多不必要数据
|
||||||
|
- **现状**:`RequestLog`结构包含`RequestBody`和`ResponseBody`(可能数十KB)
|
||||||
|
- **影响**:93条日志约传输930KB不必要数据
|
||||||
|
- **根因**:列表API返回完整日志详情,但列表页只显示基本信息
|
||||||
|
|
||||||
|
#### 🔴 问题2:前端筛选输入无防抖
|
||||||
|
- **现状**:每个输入字段变化立即触发2次API请求(`fetchLogs` + `fetchStats`)
|
||||||
|
- **影响**:输入"gpt-4"产生10次请求,造成服务器负载和网络浪费
|
||||||
|
- **根因**:缺乏防抖机制,频繁的实时查询
|
||||||
|
|
||||||
|
#### 🔴 问题3:缺少数据库查询优化
|
||||||
|
- **现状**:只有单列索引,常见组合查询效率低
|
||||||
|
- **影响**:时间范围+模型名称组合查询性能差
|
||||||
|
- **根因**:缺少针对常见查询模式设计的复合索引
|
||||||
|
|
||||||
|
## 3. 后端API优化方案
|
||||||
|
|
||||||
|
### 3.1 精简列表API响应结构
|
||||||
|
|
||||||
|
#### 3.1.1 新增精简响应结构
|
||||||
|
```go
|
||||||
|
// RequestLogSummary 精简的日志摘要结构
|
||||||
|
type RequestLogSummary struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ProviderName string `json:"provider_name"`
|
||||||
|
VirtualModelName string `json:"virtual_model_name"`
|
||||||
|
BackendModelName string `json:"backend_model_name"`
|
||||||
|
RequestTimestamp time.Time `json:"request_timestamp"`
|
||||||
|
RequestTokens int `json:"request_tokens"`
|
||||||
|
ResponseTokens int `json:"response_tokens"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 修改API处理逻辑
|
||||||
|
```go
|
||||||
|
// GetRequestLogsHandler 优化后的处理函数
|
||||||
|
func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
|
||||||
|
// ... 分页和过滤逻辑保持不变
|
||||||
|
|
||||||
|
// 获取精简的日志列表(不包含RequestBody/ResponseBody)
|
||||||
|
var logs []models.RequestLog
|
||||||
|
if err := query.Order("request_timestamp DESC").
|
||||||
|
Limit(pageSize).
|
||||||
|
Offset(offset).
|
||||||
|
Select("id, provider_name, virtual_model_name, backend_model_name, request_timestamp, request_tokens, response_tokens, cost").
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
// ... 错误处理
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"logs": logs, // 现在只包含必要字段
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.3 向后兼容性
|
||||||
|
- 保持原有API路径和参数格式
|
||||||
|
- 仅移除列表响应中的大字段
|
||||||
|
- 添加`include_body`参数作为可选标志(向后兼容)
|
||||||
|
|
||||||
|
### 3.2 独立日志详情API
|
||||||
|
|
||||||
|
#### 3.2.1 新增详情API端点
|
||||||
|
```go
|
||||||
|
// GetRequestLogDetailHandler 获取单个日志的完整详情
|
||||||
|
func (h *APIHandler) GetRequestLogDetailHandler(c *gin.Context) {
|
||||||
|
logID := c.Param("id")
|
||||||
|
|
||||||
|
var log models.RequestLog
|
||||||
|
if err := h.DB.First(&log, logID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "日志不存在"})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取日志详情失败"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"log": log, // 包含完整的RequestBody和ResponseBody
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 路由注册
|
||||||
|
```go
|
||||||
|
// 在路由配置中添加
|
||||||
|
rg.GET("/logs/:id", h.GetRequestLogDetailHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 前端API调用更新
|
||||||
|
```javascript
|
||||||
|
// 新增获取单个日志详情的函数
|
||||||
|
export const getRequestLogDetail = async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/logs/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日志详情失败:', error);
|
||||||
|
throw new Error(error.response?.data?.error || '获取日志详情失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 API契约定义
|
||||||
|
|
||||||
|
#### 3.3.1 列表API响应格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"provider_name": "openai",
|
||||||
|
"virtual_model_name": "gpt-4",
|
||||||
|
"backend_model_name": "gpt-4",
|
||||||
|
"request_timestamp": "2023-11-11T12:00:00Z",
|
||||||
|
"request_tokens": 150,
|
||||||
|
"response_tokens": 300,
|
||||||
|
"cost": 0.015000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 93,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 详情API响应格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"id": 1,
|
||||||
|
"api_key_id": 1,
|
||||||
|
"provider_name": "openai",
|
||||||
|
"virtual_model_name": "gpt-4",
|
||||||
|
"backend_model_name": "gpt-4",
|
||||||
|
"request_timestamp": "2023-11-11T12:00:00Z",
|
||||||
|
"response_timestamp": "2023-11-11T12:00:05Z",
|
||||||
|
"request_tokens": 150,
|
||||||
|
"response_tokens": 300,
|
||||||
|
"cost": 0.015000,
|
||||||
|
"request_body": "{\"model\":\"gpt-4\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}",
|
||||||
|
"response_body": "{\"id\":\"chatcmpl-xxx\",\"choices\":[{\"message\":{\"content\":\"Hi there!\"}}]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 前端防抖优化方案
|
||||||
|
|
||||||
|
### 4.1 防抖机制设计
|
||||||
|
|
||||||
|
#### 4.1.1 自定义防抖Hook
|
||||||
|
```jsx
|
||||||
|
// useDebounce.js
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce(callback, delay) {
|
||||||
|
const timeoutRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debouncedCallback = (...args) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
callback(...args);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
return debouncedCallback;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.1.2 优化后的RequestLogList组件
|
||||||
|
```jsx
|
||||||
|
const RequestLogList = () => {
|
||||||
|
// ... 状态定义保持不变
|
||||||
|
|
||||||
|
// 使用防抖包装API调用
|
||||||
|
const debouncedFetchLogs = useDebounce(() => {
|
||||||
|
fetchLogs();
|
||||||
|
fetchStats();
|
||||||
|
}, 500); // 500ms延迟
|
||||||
|
|
||||||
|
// 优化的处理函数
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1); // 重置到第一页
|
||||||
|
|
||||||
|
// 使用防抖而非立即调用
|
||||||
|
debouncedFetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... 其他逻辑保持不变
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 用户体验优化
|
||||||
|
|
||||||
|
#### 4.2.1 添加加载状态指示
|
||||||
|
```jsx
|
||||||
|
const [filterLoading, setFilterLoading] = useState(false);
|
||||||
|
|
||||||
|
// 在防抖回调中添加加载状态
|
||||||
|
const debouncedFetchLogs = useDebounce(async () => {
|
||||||
|
setFilterLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([fetchLogs(), fetchStats()]);
|
||||||
|
} finally {
|
||||||
|
setFilterLoading(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 在UI中显示加载状态
|
||||||
|
{filterLoading && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="text-sm text-gray-500">筛选中...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 请求合并策略
|
||||||
|
```jsx
|
||||||
|
// 优化:合并fetchLogs和fetchStats为单一请求
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [logsData, statsData] = await Promise.all([
|
||||||
|
getRequestLogs(params),
|
||||||
|
getRequestLogStats(filters)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setLogs(logsData.logs || []);
|
||||||
|
setStats(statsData);
|
||||||
|
setTotalPages(Math.ceil((logsData.total || 0) / pageSize));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '获取日志失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 组件级优化
|
||||||
|
|
||||||
|
#### 4.3.1 详情模态框优化
|
||||||
|
```jsx
|
||||||
|
// 优化:详情按需加载
|
||||||
|
const [modalLoading, setModalLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetail = async (log) => {
|
||||||
|
setSelectedLog(null);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
setModalLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 精简列表中没有完整请求/响应体,需要单独获取
|
||||||
|
const detailData = await getRequestLogDetail(log.ID);
|
||||||
|
setSelectedLog(detailData.log);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取日志详情失败:', err);
|
||||||
|
} finally {
|
||||||
|
setModalLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 数据库索引优化方案
|
||||||
|
|
||||||
|
### 5.1 常用查询模式分析
|
||||||
|
|
||||||
|
基于现有代码,识别出以下常见查询模式:
|
||||||
|
|
||||||
|
1. **时间范围查询**:`WHERE request_timestamp BETWEEN ? AND ?`
|
||||||
|
2. **模型筛选**:`WHERE virtual_model_name = ?` 或 `WHERE backend_model_name = ?`
|
||||||
|
3. **组合查询**:时间范围 + 模型名称
|
||||||
|
4. **排序查询**:`ORDER BY request_timestamp DESC`
|
||||||
|
5. **分页查询**:`LIMIT ? OFFSET ?`
|
||||||
|
|
||||||
|
### 5.2 复合索引策略
|
||||||
|
|
||||||
|
#### 5.2.1 新增复合索引定义
|
||||||
|
```go
|
||||||
|
// 在models/schema.go中修改RequestLog结构
|
||||||
|
type RequestLog struct {
|
||||||
|
gorm.Model
|
||||||
|
APIKeyID uint `gorm:"index"`
|
||||||
|
ProviderName string `gorm:"index"`
|
||||||
|
VirtualModelName string `gorm:"index"`
|
||||||
|
BackendModelName string `gorm:"index"`
|
||||||
|
RequestTimestamp time.Time `gorm:"index;not null"`
|
||||||
|
ResponseTimestamp time.Time `gorm:"not null"`
|
||||||
|
RequestTokens int `gorm:"default:0"`
|
||||||
|
ResponseTokens int `gorm:"default:0"`
|
||||||
|
Cost float64 `gorm:"type:decimal(10,6)"`
|
||||||
|
RequestBody string `gorm:"type:text"`
|
||||||
|
ResponseBody string `gorm:"type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在AutoMigrate中添加复合索引
|
||||||
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
if err := db.AutoMigrate(&APIKey{}, &Provider{}, &VirtualModel{}, &BackendModel{}, &RequestLog{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加复合索引
|
||||||
|
if err := addCompositeIndexes(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCompositeIndexes(db *gorm.DB) error {
|
||||||
|
// 时间范围 + 虚拟模型名的复合索引(最常见查询)
|
||||||
|
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_virtual_model ON request_logs (request_timestamp DESC, virtual_model_name)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间范围 + 后端模型名的复合索引
|
||||||
|
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_backend_model ON request_logs (request_timestamp DESC, backend_model_name)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间范围 + 服务商名的复合索引
|
||||||
|
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_provider ON request_logs (request_timestamp DESC, provider_name)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间范围 + API密钥ID的复合索引(用于用户权限查询)
|
||||||
|
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_api_key ON request_logs (request_timestamp DESC, api_key_id)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2.2 索引选择策略
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[查询请求] --> B{包含时间范围?}
|
||||||
|
B -->|是| C{包含模型筛选?}
|
||||||
|
B -->|否| D[使用单列索引]
|
||||||
|
C -->|虚拟模型| E[使用idx_request_logs_timestamp_virtual_model]
|
||||||
|
C -->|后端模型| F[使用idx_request_logs_timestamp_backend_model]
|
||||||
|
C -->|服务商| G[使用idx_request_logs_timestamp_provider]
|
||||||
|
C -->|无| H[使用request_timestamp索引]
|
||||||
|
E --> I[高效查询]
|
||||||
|
F --> I
|
||||||
|
G --> I
|
||||||
|
H --> I
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 索引性能评估
|
||||||
|
|
||||||
|
#### 5.3.1 查询性能对比
|
||||||
|
| 查询场景 | 优化前(单列索引) | 优化后(复合索引) | 性能提升 |
|
||||||
|
|---------|-----------------|-----------------|----------|
|
||||||
|
| 时间范围 + 虚拟模型 | 全表扫描 + 虚拟模型过滤 | 索引直接定位 | 70-80% |
|
||||||
|
| 时间范围 + 后端模型 | 全表扫描 + 后端模型过滤 | 索引直接定位 | 60-75% |
|
||||||
|
| 纯时间范围查询 | 时间索引扫描 | 时间索引扫描 | 10-15% |
|
||||||
|
| 纯模型筛选 | 模型索引扫描 | 模型索引扫描 | 无变化 |
|
||||||
|
|
||||||
|
#### 5.3.2 写入性能影响
|
||||||
|
- **索引开销**:新增4个复合索引,每次INSERT操作需要额外写入索引数据
|
||||||
|
- **评估**:对于读多写少的日志系统,写入性能影响可接受(预计5-10%)
|
||||||
|
- **监控**:实施后需监控写入性能,必要时可调整索引策略
|
||||||
|
|
||||||
|
## 6. 数据库Schema变更
|
||||||
|
|
||||||
|
### 6.1 索引变更SQL
|
||||||
|
```sql
|
||||||
|
-- 新增复合索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_virtual_model
|
||||||
|
ON request_logs (request_timestamp DESC, virtual_model_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_backend_model
|
||||||
|
ON request_logs (request_timestamp DESC, backend_model_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_provider
|
||||||
|
ON request_logs (request_timestamp DESC, provider_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp_api_key
|
||||||
|
ON request_logs (request_timestamp DESC, api_key_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 迁移脚本
|
||||||
|
```go
|
||||||
|
// 在internal/db/database.go中添加迁移函数
|
||||||
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
// 现有迁移代码...
|
||||||
|
|
||||||
|
// 运行新的索引迁移
|
||||||
|
if err := addCompositeIndexes(db); err != nil {
|
||||||
|
return fmt.Errorf("添加复合索引失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 实施优先级和步骤
|
||||||
|
|
||||||
|
### 7.1 实施优先级
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title 性能优化实施时间线
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
section 第一阶段
|
||||||
|
后端API响应优化 :a1, 2023-11-12, 2d
|
||||||
|
section 第二阶段
|
||||||
|
前端防抖机制 :a2, after a1, 1d
|
||||||
|
section 第三阶段
|
||||||
|
数据库索引优化 :a3, after a2, 1d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 详细实施步骤
|
||||||
|
|
||||||
|
#### 第一阶段:后端API响应优化(预计2天)
|
||||||
|
1. **Day 1**:
|
||||||
|
- 创建`RequestLogSummary`结构体
|
||||||
|
- 修改`GetRequestLogsHandler`,使用Select语句排除大字段
|
||||||
|
- 添加`include_body`参数支持(向后兼容)
|
||||||
|
- 编写单元测试验证响应结构
|
||||||
|
|
||||||
|
2. **Day 2**:
|
||||||
|
- 实现`GetRequestLogDetailHandler`新API端点
|
||||||
|
- 添加路由注册
|
||||||
|
- 更新前端API调用函数
|
||||||
|
- 集成测试验证功能
|
||||||
|
|
||||||
|
#### 第二阶段:前端防抖机制(预计1天)
|
||||||
|
1. 实现自定义`useDebounce` Hook
|
||||||
|
2. 重构`RequestLogList`组件,应用防抖机制
|
||||||
|
3. 添加筛选加载状态指示
|
||||||
|
4. 优化详情模态框按需加载逻辑
|
||||||
|
5. 测试防抖效果和用户体验
|
||||||
|
|
||||||
|
#### 第三阶段:数据库索引优化(预计1天)
|
||||||
|
1. 分析现有查询模式,确认索引策略
|
||||||
|
2. 实现复合索引创建函数
|
||||||
|
3. 编写数据库迁移脚本
|
||||||
|
4. 执行索引创建(在低峰期进行)
|
||||||
|
5. 性能测试验证查询速度提升
|
||||||
|
|
||||||
|
### 7.3 预期效果
|
||||||
|
| 优化项目 | 预期提升 | 验证指标 |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| 数据传输量 | 减少90%+ | 列表API响应大小从MB降至KB级别 |
|
||||||
|
| API请求数 | 减少80%+ | 防抖后用户输入产生的请求数显著减少 |
|
||||||
|
| 查询速度 | 提升50-80% | 常见筛选查询响应时间减少 |
|
||||||
|
|
||||||
|
## 8. 风险评估和回退方案
|
||||||
|
|
||||||
|
### 8.1 风险评估
|
||||||
|
|
||||||
|
#### 8.1.1 后端API变更风险
|
||||||
|
- **风险**:修改API响应可能破坏现有客户端
|
||||||
|
- **概率**:中
|
||||||
|
- **影响**:如果其他系统集成了该API,可能出现兼容性问题
|
||||||
|
- **缓解措施**:
|
||||||
|
- 保持`include_body`参数完全向后兼容
|
||||||
|
- 渐进式部署,先内部测试
|
||||||
|
|
||||||
|
#### 8.1.2 前端防抖风险
|
||||||
|
- **风险**:防抖可能导致用户体验下降(响应延迟)
|
||||||
|
- **概率**:低
|
||||||
|
- **影响**:用户可能感觉系统反应变慢
|
||||||
|
- **缓解措施**:
|
||||||
|
- 选择合适的延迟时间(500ms平衡性能与响应)
|
||||||
|
- 添加加载状态反馈
|
||||||
|
|
||||||
|
#### 8.1.3 数据库索引风险
|
||||||
|
- **风险**:索引占用额外存储空间,影响写入性能
|
||||||
|
- **概率**:中
|
||||||
|
- **影响**:数据库文件增大,写入操作变慢
|
||||||
|
- **缓解措施**:
|
||||||
|
- 在测试环境验证性能影响
|
||||||
|
- 监控生产环境性能指标
|
||||||
|
|
||||||
|
### 8.2 回退方案
|
||||||
|
|
||||||
|
#### 8.2.1 后端API回退
|
||||||
|
```go
|
||||||
|
// 为紧急情况保留旧版本处理函数
|
||||||
|
func (h *APIHandler) GetRequestLogsHandlerLegacy(c *gin.Context) {
|
||||||
|
// 原始实现,包含完整的RequestBody/ResponseBody
|
||||||
|
logID := c.Query("legacy")
|
||||||
|
if logID == "true" {
|
||||||
|
// 执行原始查询逻辑
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行优化后的查询逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8.2.2 前端回退
|
||||||
|
```jsx
|
||||||
|
// 通过环境变量控制防抖功能
|
||||||
|
const DEBOUNCE_DELAY = process.env.REACT_APP_DISABLE_DEBOUNCE === 'true' ? 0 : 500;
|
||||||
|
|
||||||
|
const debouncedFetchLogs = useDebounce(() => {
|
||||||
|
fetchLogs();
|
||||||
|
fetchStats();
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8.2.3 数据库索引回退
|
||||||
|
```sql
|
||||||
|
-- 删除新增复合索引的SQL
|
||||||
|
DROP INDEX IF EXISTS idx_request_logs_timestamp_virtual_model;
|
||||||
|
DROP INDEX IF EXISTS idx_request_logs_timestamp_backend_model;
|
||||||
|
DROP INDEX IF EXISTS idx_request_logs_timestamp_provider;
|
||||||
|
DROP INDEX IF EXISTS idx_request_logs_timestamp_api_key;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 监控计划
|
||||||
|
|
||||||
|
#### 8.3.1 性能指标监控
|
||||||
|
- API响应时间(列表、详情、统计)
|
||||||
|
- 前端页面加载和渲染时间
|
||||||
|
- 数据库查询执行时间
|
||||||
|
- 网络传输大小
|
||||||
|
|
||||||
|
#### 8.3.2 用户行为监控
|
||||||
|
- API调用频率
|
||||||
|
- 用户输入模式
|
||||||
|
- 页面停留时间
|
||||||
|
- 错误率变化
|
||||||
|
|
||||||
|
## 9. 性能提升预期
|
||||||
|
|
||||||
|
### 9.1 量化改进预期
|
||||||
|
|
||||||
|
| 性能指标 | 当前状态 | 优化后预期 | 改进幅度 |
|
||||||
|
|---------|---------|-----------|----------|
|
||||||
|
| 列表API响应大小 | ~930KB/93条 | ~50KB/93条 | 94.6%↓ |
|
||||||
|
| 列表API响应时间 | 200-500ms | 50-150ms | 50-70%↓ |
|
||||||
|
| 用户输入触发请求数 | 10次/输入 | 1-2次/输入 | 80%↓ |
|
||||||
|
| 复合查询响应时间 | 800-2000ms | 200-600ms | 60-75%↓ |
|
||||||
|
| 页面整体加载时间 | 2-3秒 | 1-1.5秒 | 50%↓ |
|
||||||
|
|
||||||
|
### 9.2 用户体验改进
|
||||||
|
- **响应速度**:列表加载和筛选操作明显变快
|
||||||
|
- **网络消耗**:大幅减少数据传输,改善移动端体验
|
||||||
|
- **系统稳定性**:减少服务器负载,降低峰值压力
|
||||||
|
- **交互流畅度**:防抖机制避免频繁请求,操作更流畅
|
||||||
|
|
||||||
|
## 10. 后续优化建议
|
||||||
|
|
||||||
|
### 10.1 短期优化(1-3个月)
|
||||||
|
1. **缓存策略**:为统计数据和常用筛选结果添加Redis缓存
|
||||||
|
2. **分页优化**:实现游标分页替代OFFSET,优化深分页性能
|
||||||
|
3. **压缩传输**:启用gzip压缩进一步减少传输大小
|
||||||
|
|
||||||
|
### 10.2 长期优化(3-6个月)
|
||||||
|
1. **数据归档**:实现冷热数据分离,定期归档历史日志
|
||||||
|
2. **异步处理**:将日志写入改为异步队列,提升API响应速度
|
||||||
|
3. **实时数据**:考虑WebSocket实现实时日志流更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本方案经过全面分析,针对三个主要性能瓶颈提供了系统性的解决方案。实施后预计将显著提升系统性能和用户体验,同时保持了良好的向后兼容性和可维护性。建议按照优先级逐步实施,并在每个阶段进行充分测试和验证。
|
||||||
Reference in New Issue
Block a user