优化日志查询性能

This commit is contained in:
2025-11-11 13:18:43 +08:00
parent 806b5c6846
commit 24cfa54869
8 changed files with 366 additions and 19 deletions

View File

@@ -18,6 +18,23 @@ type APIHandler struct {
DB *gorm.DB
}
// RequestLogListItem 精简的请求日志列表项不包含RequestBody和ResponseBody
type RequestLogListItem struct {
ID uint `json:"id"`
APIKeyID uint `json:"api_key_id"`
ProviderName string `json:"provider_name"`
VirtualModelName string `json:"virtual_model_name"`
BackendModelName string `json:"backend_model_name"`
RequestTimestamp time.Time `json:"request_timestamp"`
ResponseTimestamp time.Time `json:"response_timestamp"`
StatusCode int `json:"status_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
RequestTokens int `json:"request_tokens"`
ResponseTokens int `json:"response_tokens"`
Cost float64 `json:"cost"`
CreatedAt time.Time `json:"created_at"`
}
// HealthCheckHandler 健康检查端点(无需认证)
func (h *APIHandler) HealthCheckHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -182,7 +199,7 @@ func copyResponseHeaders(c *gin.Context, resp *http.Response) {
}
}
// GetRequestLogsHandler 获取请求日志列表
// GetRequestLogsHandler 获取请求日志列表精简版不包含RequestBody和ResponseBody
func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
// 获取分页参数
page := 1
@@ -232,7 +249,7 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
return
}
// 获取日志列表
// 获取日志列表(完整数据)
var logs []models.RequestLog
offset := (page - 1) * pageSize
if err := query.Order("request_timestamp DESC").Limit(pageSize).Offset(offset).Find(&logs).Error; err != nil {
@@ -240,8 +257,26 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
return
}
// 转换为精简DTO
logItems := make([]RequestLogListItem, len(logs))
for i, log := range logs {
logItems[i] = RequestLogListItem{
ID: log.ID,
APIKeyID: log.APIKeyID,
ProviderName: log.ProviderName,
VirtualModelName: log.VirtualModelName,
BackendModelName: log.BackendModelName,
RequestTimestamp: log.RequestTimestamp,
ResponseTimestamp: log.ResponseTimestamp,
RequestTokens: log.RequestTokens,
ResponseTokens: log.ResponseTokens,
Cost: log.Cost,
CreatedAt: log.CreatedAt,
}
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"logs": logItems,
"total": total,
"page": page,
"page_size": pageSize,
@@ -249,6 +284,30 @@ func (h *APIHandler) GetRequestLogsHandler(c *gin.Context) {
})
}
// GetRequestLogDetailHandler 获取单个请求日志的完整详情包含RequestBody和ResponseBody
func (h *APIHandler) GetRequestLogDetailHandler(c *gin.Context) {
// 获取日志ID
logID := c.Param("id")
if logID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Log ID is required"})
return
}
// 查询日志
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": "Log not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch log detail"})
return
}
// 返回完整的日志信息
c.JSON(http.StatusOK, log)
}
// GetRequestLogStatsHandler 获取请求日志统计信息
func (h *APIHandler) GetRequestLogStatsHandler(c *gin.Context) {
// 获取时间范围参数

View File

@@ -81,6 +81,7 @@ func main() {
// Request Logs
api_.GET("/logs", handler.GetRequestLogsHandler)
api_.GET("/logs/stats", handler.GetRequestLogStatsHandler)
api_.GET("/logs/:id", handler.GetRequestLogDetailHandler)
}
// 启动HTTP服务器

View File

@@ -11,7 +11,8 @@
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -59,7 +60,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1595,7 +1595,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1637,7 +1636,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1798,7 +1796,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2134,7 +2131,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3252,7 +3248,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3279,7 +3274,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3327,7 +3321,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3344,6 +3337,17 @@
"react": "^19.2.0"
}
},
"node_modules/react-json-view-lite": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz",
"integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3573,7 +3577,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -13,7 +13,8 @@
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -21,3 +21,14 @@ export const getRequestLogStats = async (params = {}) => {
throw new Error(error.response?.data?.error || '获取日志统计信息失败');
}
};
// 获取日志详情
export const getLogDetail = 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 || '获取日志详情失败');
}
};

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect, useMemo } from 'react';
import Modal from '../../../components/ui/Modal';
import { getLogDetail } from '../api';
import { JsonView } from 'react-json-view-lite';
import 'react-json-view-lite/dist/index.css';
const RequestLogDetailModal = ({ log, isOpen, onClose }) => {
const [copiedInput, setCopiedInput] = useState(false);
const [copiedOutput, setCopiedOutput] = useState(false);
const [detailLog, setDetailLog] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 按需加载日志详情
useEffect(() => {
if (isOpen && log && log.ID) {
const fetchLogDetail = async () => {
try {
setLoading(true);
setError(null);
const detailData = await getLogDetail(log.ID);
setDetailLog(detailData);
} catch (err) {
setError(err.message || '获取日志详情失败');
console.error('Error fetching log detail:', err);
} finally {
setLoading(false);
}
};
fetchLogDetail();
} else if (!isOpen) {
// 弹窗关闭时重置状态
setDetailLog(null);
setError(null);
setLoading(false);
}
}, [isOpen, log]);
// 格式化日期时间
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
// 格式化成本
const formatCost = (cost) => {
if (cost === null || cost === undefined) return 'N/A';
return `¥${parseFloat(cost).toFixed(6)}`;
};
// 使用 useMemo 优化 JSON 格式化
const formatJSON = useMemo(() => {
return (jsonString) => {
if (!jsonString || jsonString.trim() === '') {
return '无数据';
}
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, 2);
} catch (e) {
// 如果解析失败,返回原始文本
return jsonString;
}
};
}, []);
// 复制到剪贴板
const copyToClipboard = async (text, type) => {
try {
await navigator.clipboard.writeText(text);
if (type === 'input') {
setCopiedInput(true);
setTimeout(() => setCopiedInput(false), 2000);
} else {
setCopiedOutput(true);
setTimeout(() => setCopiedOutput(false), 2000);
}
} catch (err) {
console.error('复制失败:', err);
}
};
if (!log) return null;
// 使用详情数据或基本日志数据
const displayLog = detailLog || log;
return (
<Modal isOpen={isOpen} onClose={onClose} title="请求日志详情" size="xl">
<div className="space-y-6">
{/* 加载状态 */}
{loading && (
<div className="flex justify-center items-center py-8">
<div className="text-gray-600">加载中...</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{/* 基本信息区域 */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">基本信息</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-500">请求时间</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.RequestTimestamp)}</div>
</div>
<div>
<div className="text-sm text-gray-500">响应时间</div>
<div className="text-sm font-medium text-gray-900">{formatDate(displayLog.ResponseTimestamp)}</div>
</div>
<div>
<div className="text-sm text-gray-500">虚拟模型</div>
<div className="text-sm font-medium text-gray-900">{displayLog.VirtualModelName || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">后端模型</div>
<div className="text-sm font-medium text-gray-900">{displayLog.BackendModelName || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">服务商</div>
<div className="text-sm font-medium text-gray-900">{displayLog.ProviderName || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">成本</div>
<div className="text-sm font-medium text-gray-900">{formatCost(displayLog.Cost)}</div>
</div>
<div>
<div className="text-sm text-gray-500">请求Tokens</div>
<div className="text-sm font-medium text-gray-900">{displayLog.RequestTokens || 0}</div>
</div>
<div>
<div className="text-sm text-gray-500">响应Tokens</div>
<div className="text-sm font-medium text-gray-900">{displayLog.ResponseTokens || 0}</div>
</div>
</div>
</div>
{/* 请求内容 (Input) 区域 */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900">请求内容 (Input)</h3>
<button
onClick={() => copyToClipboard(displayLog.RequestBody || '', 'input')}
className={`px-3 py-1 text-sm rounded transition-colors ${copiedInput
? 'bg-gray-400 text-white cursor-default'
: 'bg-green-500 text-white hover:bg-green-600'
}`}
disabled={copiedInput || loading}
>
{copiedInput ? '已复制' : '复制'}
</button>
</div>
{/* <div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
<pre className="text-sm text-gray-100 text-left" style={{ fontFamily: "'Courier New', monospace" }}>
<code>{loading ? '加载中...' : formatJSON(displayLog.RequestBody)}</code>
</pre>
</div> */}
<div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
{loading ? '加载中' :
log.RequestBody ? (
<JsonView
data={JSON.parse(log.RequestBody)} // 解析字符串为对象
style={{ fontFamily: "'Courier New', monospace", fontSize: '0.875rem' }} // 匹配你的text-sm
collapsed={1} // 默认折叠1级深度0=全展开false=不折叠
collapseAll={true} // 初始全折叠
enableClipboard={false} // 禁用内置复制用你的按钮
shouldLogOnChange={false} // 禁用编辑只读模式
/>
) : (
<p className="text-gray-400 text-sm">无内容</p>
)
}
</div>
</div>
{/* 响应内容 (Response) 区域 */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900">响应内容 (Response)</h3>
<button
onClick={() => copyToClipboard(displayLog.ResponseBody || '', 'output')}
className={`px-3 py-1 text-sm rounded transition-colors ${copiedOutput
? 'bg-gray-400 text-white cursor-default'
: 'bg-green-500 text-white hover:bg-green-600'
}`}
disabled={copiedOutput || loading}
>
{copiedOutput ? '已复制' : '复制'}
</button>
</div>
<div className="bg-gray-800 rounded-lg p-3 overflow-auto" style={{ maxHeight: '300px' }}>
<pre className="text-sm text-gray-100 text-left" style={{ fontFamily: "'Courier New', monospace" }}>
<code>{loading ? '加载中...' : formatJSON(displayLog.ResponseBody)}</code>
</pre>
</div>
</div>
</div>
</Modal>
);
};
export default RequestLogDetailModal;

View File

@@ -1,5 +1,23 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { getRequestLogs, getRequestLogStats } from '../api';
import RequestLogDetailModal from './RequestLogDetailModal';
// 自定义防抖 Hook
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
const RequestLogList = () => {
const [logs, setLogs] = useState([]);
@@ -9,6 +27,8 @@ const RequestLogList = () => {
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [selectedLog, setSelectedLog] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
// 筛选条件
const [filters, setFilters] = useState({
@@ -18,13 +38,16 @@ const RequestLogList = () => {
endDate: ''
});
// 使用防抖的筛选条件
const debouncedFilters = useDebounce(filters, 500);
const fetchLogs = async () => {
try {
setLoading(true);
const params = {
page,
page_size: pageSize,
...filters
...debouncedFilters
};
// 移除空值
@@ -46,7 +69,7 @@ const RequestLogList = () => {
const fetchStats = async () => {
try {
const data = await getRequestLogStats(filters);
const data = await getRequestLogStats(debouncedFilters);
setStats(data);
} catch (err) {
console.error('Error fetching stats:', err);
@@ -56,13 +79,18 @@ const RequestLogList = () => {
useEffect(() => {
fetchLogs();
fetchStats();
}, [page, filters]);
}, [page, debouncedFilters]);
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPage(1); // 重置到第一页
};
const handleViewDetail = (log) => {
setSelectedLog(log);
setShowDetailModal(true);
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
@@ -201,12 +229,15 @@ const RequestLogList = () => {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
成本
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.length === 0 ? (
<tr>
<td colSpan="7" className="px-6 py-4 text-center text-gray-500">
<td colSpan="8" className="px-6 py-4 text-center text-gray-500">
暂无日志记录
</td>
</tr>
@@ -234,6 +265,14 @@ const RequestLogList = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCost(log.Cost)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => handleViewDetail(log)}
className="text-blue-600 hover:text-blue-800 font-medium transition-colors"
>
查看详情
</button>
</td>
</tr>
))
)}
@@ -264,6 +303,13 @@ const RequestLogList = () => {
</button>
</div>
)}
{/* 详情查看模态框 */}
<RequestLogDetailModal
log={selectedLog}
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
</div>
);
};

8
push_docker_image.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
docker compose -f docker-compose.cn.yml build
docker tag airouter-backend:latest nanako1092/airouter-backend:latest
docker tag airouter-frontend:latest nanako1092/airouter-frontend:latest
docker push nanako1092/airouter-backend:latest
docker push nanako1092/airouter-frontend:latest