diff --git a/airouter-backend.tar b/airouter-backend.tar new file mode 100644 index 0000000..7fb36b4 Binary files /dev/null and b/airouter-backend.tar differ diff --git a/airouter-frontend.tar b/airouter-frontend.tar new file mode 100644 index 0000000..ba86828 Binary files /dev/null and b/airouter-frontend.tar differ diff --git a/docs/LOG_CLEANER.md b/docs/LOG_CLEANER.md new file mode 100644 index 0000000..75f95f0 --- /dev/null +++ b/docs/LOG_CLEANER.md @@ -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+ \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec6a6cd..231cfee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.17", "axios": "^1.13.2", "react": "^19.1.1", @@ -311,6 +314,55 @@ "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3518,6 +3570,11 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 71b741b..fd4152d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.17", "axios": "^1.13.2", "react": "^19.1.1", diff --git a/frontend/src/features/virtual-models/components/DraggableBackendModelCard.jsx b/frontend/src/features/virtual-models/components/DraggableBackendModelCard.jsx new file mode 100644 index 0000000..2b7aa37 --- /dev/null +++ b/frontend/src/features/virtual-models/components/DraggableBackendModelCard.jsx @@ -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 ( +
+
+
+ +
+
+ 优先级: {index + 1} +
+ +
+ + {/* 供应商和后端模型名称一行 */} +
+
+ + +
+ +
+ + onModelChange(index, 'Name', e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+
+ + {/* 成本阈值、最大上下文长度、计费方式、Token单价一行 */} +
+
+ + + onModelChange(index, 'CostThreshold', parseFloat(e.target.value) || 0) + } + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+ +
+ + onModelChange(index, 'MaxContextLength', parseInt(e.target.value))} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+ +
+ + +
+ + {/* Token单价占位符,实际内容在下面条件渲染中 */} +
+ +
+ {bm.BillingMethod === 'token' ? '见下方详细设置' : '按请求计费'} +
+
+
+ + {/* Token单价相关字段 - 按Token计费时显示 */} + {bm.BillingMethod === 'token' && ( +
+
+ + + onModelChange(index, 'PromptTokenPrice', parseFloat(e.target.value)) + } + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+
+ + + onModelChange(index, 'CompletionTokenPrice', parseFloat(e.target.value)) + } + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+
+ )} + + {/* 固定价格 - 按请求计费时显示 */} + {bm.BillingMethod === 'request' && ( +
+
+ + onModelChange(index, 'FixedPrice', parseFloat(e.target.value))} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + /> +
+
+ )} +
+ ); +}; + +export default DraggableBackendModelCard; \ No newline at end of file diff --git a/frontend/src/features/virtual-models/components/VirtualModelForm.jsx b/frontend/src/features/virtual-models/components/VirtualModelForm.jsx index 1a9152a..1067ef5 100644 --- a/frontend/src/features/virtual-models/components/VirtualModelForm.jsx +++ b/frontend/src/features/virtual-models/components/VirtualModelForm.jsx @@ -1,11 +1,33 @@ import React, { useState, useEffect } from 'react'; 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 [name, setName] = useState(''); const [backendModels, setBackendModels] = useState([]); const [providers, setProviders] = useState([]); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + useEffect(() => { if (model) { setName(model.Name); @@ -46,9 +68,28 @@ const VirtualModelForm = ({ model, onSave, onCancel }) => { 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) => { e.preventDefault(); - onSave({ Name: name, BackendModels: backendModels }); + + // 自动设置优先级为索引+1 + const backendModelsWithPriority = backendModels.map((model, index) => ({ + ...model, + Priority: index + 1, + })); + + onSave({ Name: name, BackendModels: backendModelsWithPriority }); }; return ( @@ -64,126 +105,51 @@ const VirtualModelForm = ({ model, onSave, onCancel }) => {

后端模型

- {backendModels.map((bm, index) => ( -
-
- -
-
- - -
-
- - handleBackendModelChange(index, 'Name', e.target.value)} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
-
- - handleBackendModelChange(index, 'Priority', parseInt(e.target.value))} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
-
- - handleBackendModelChange(index, 'CostThreshold', parseFloat(e.target.value) || 0)} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
-
- - handleBackendModelChange(index, 'MaxContextLength', parseInt(e.target.value))} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
-
- - -
- {bm.BillingMethod === 'token' && ( - <> -
- - handleBackendModelChange(index, 'PromptTokenPrice', parseFloat(e.target.value))} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
-
- - handleBackendModelChange(index, 'CompletionTokenPrice', parseFloat(e.target.value))} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" - /> -
- - )} - {bm.BillingMethod === 'request' && ( -
- - handleBackendModelChange(index, 'FixedPrice', parseFloat(e.target.value))} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" + + + `backend-model-${index}`)} + strategy={verticalListSortingStrategy} + > +
+ {backendModels.map((bm, index) => ( + -
- )} -
- ))} + ))} +
+ + -
- -
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7d20252..a17e835 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -11,7 +11,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8080', // 后端服务器地址 + target: 'http://10.1.39.104:9130', // 后端服务器地址 changeOrigin: true, }, }, diff --git a/start-docker-cn.sh b/start-docker-cn.sh old mode 100644 new mode 100755 diff --git a/前端请求日志性能优化方案.md b/前端请求日志性能优化方案.md new file mode 100644 index 0000000..33a98db --- /dev/null +++ b/前端请求日志性能优化方案.md @@ -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 && ( +
+
筛选中...
+
+)} +``` + +#### 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实现实时日志流更新 + +--- + +本方案经过全面分析,针对三个主要性能瓶颈提供了系统性的解决方案。实施后预计将显著提升系统性能和用户体验,同时保持了良好的向后兼容性和可维护性。建议按照优先级逐步实施,并在每个阶段进行充分测试和验证。 \ No newline at end of file