1320 lines
38 KiB
Markdown
1320 lines
38 KiB
Markdown
# 摄影作品集网站 - 日志管理方案
|
||
|
||
## 🎯 方案概述
|
||
|
||
这是一个**简单实用的日志管理方案**,专注于日志收集和问题修复。日志查看功能集成到管理后台,提供友好的Web界面。
|
||
|
||
### 设计原则
|
||
- **集成化**: 日志查看功能集成到管理后台
|
||
- **用户友好**: 提供美观易用的Web界面
|
||
- **问题导向**: 专注于快速定位和修复问题
|
||
- **低维护成本**: 几乎零维护的方案
|
||
- **渐进式**: 后续可以根据需要扩展
|
||
|
||
## 📋 核心组件
|
||
|
||
### 日志管理架构
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 后端应用日志 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ 错误日志 │ │ 访问日志 │ │ 业务日志 │ │
|
||
│ │ (JSON格式) │ │ (HTTP日志) │ │ (操作日志) │ │
|
||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 管理后台日志模块 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ 日志查看器 │ │ 实时监控 │ │ 统计分析 │ │
|
||
│ │ (Web界面) │ │ (自动刷新) │ │ (图表展示) │ │
|
||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ API接口层 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ 日志查询API │ │ 统计API │ │ 搜索API │ │
|
||
│ │(/api/logs) │ │(/api/stats) │ │(/api/search)│ │
|
||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## 🔧 技术选择
|
||
|
||
### 日志方案
|
||
- **日志存储**: 本地文件 (JSON格式)
|
||
- **日志轮转**: lumberjack.v2
|
||
- **日志查看**: 管理后台Web界面
|
||
- **Trace ID**: 集成OpenTracing (已在架构文档中添加)
|
||
- **权限控制**: 基于管理后台的用户权限
|
||
|
||
### 集成方式
|
||
- **前端**: 管理后台的日志管理模块
|
||
- **后端**: Gin路由提供日志查询API
|
||
- **认证**: 复用管理后台的登录认证
|
||
- **权限**: 仅管理员可访问日志功能
|
||
|
||
## 📝 日志配置
|
||
|
||
### 1. 应用日志配置
|
||
|
||
#### 日志配置文件 (config.yaml)
|
||
```yaml
|
||
# config/config.yaml
|
||
logger:
|
||
level: "info"
|
||
format: "json"
|
||
output: "file"
|
||
filename: "/app/logs/app.log"
|
||
max_size: 100 # MB
|
||
max_age: 7 # days
|
||
compress: true
|
||
|
||
# 可选:如果需要链路追踪
|
||
tracing:
|
||
enabled: true
|
||
service_name: "photography-backend"
|
||
jaeger:
|
||
endpoint: "http://localhost:14268/api/traces"
|
||
sampling_rate: 1.0
|
||
```
|
||
|
||
### 2. 日志格式标准化
|
||
|
||
#### 统一的日志格式
|
||
```json
|
||
{
|
||
"timestamp": "2024-01-15T10:30:00Z",
|
||
"level": "info",
|
||
"message": "Photo created successfully",
|
||
"service": "photography-backend",
|
||
"trace_id": "abc123def456",
|
||
"request_id": "req-789",
|
||
"user_id": "user-123",
|
||
"operation": "create_photo",
|
||
"photo_id": 1001,
|
||
"duration": 0.5,
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
### 3. 日志分类
|
||
|
||
#### 三种核心日志类型
|
||
```bash
|
||
# 日志目录结构
|
||
logs/
|
||
├── app.log # 应用日志 (所有级别)
|
||
├── error.log # 错误日志 (ERROR级别)
|
||
└── access.log # HTTP访问日志
|
||
```
|
||
|
||
#### 日志级别使用
|
||
```go
|
||
// 日志级别使用指南
|
||
logger.Info("正常业务操作") // 记录重要的业务操作
|
||
logger.Warn("需要关注的情况") // 记录警告信息
|
||
logger.Error("错误情况") // 记录错误信息
|
||
logger.Debug("调试信息") // 开发调试用
|
||
```
|
||
|
||
## 🔍 问题诊断工具
|
||
|
||
### 1. 命令行工具
|
||
|
||
#### 快速查看错误
|
||
```bash
|
||
#!/bin/bash
|
||
# scripts/check-errors.sh
|
||
|
||
echo "📋 最近的错误日志 (最近1小时):"
|
||
tail -f /app/logs/app.log | grep -i error | jq -r '.timestamp + " " + .message'
|
||
|
||
echo "📊 错误统计:"
|
||
grep -i error /app/logs/app.log | jq -r '.message' | sort | uniq -c | sort -nr | head -10
|
||
```
|
||
|
||
#### 按trace ID查找日志
|
||
```bash
|
||
#!/bin/bash
|
||
# scripts/find-by-trace.sh
|
||
|
||
TRACE_ID=$1
|
||
if [ -z "$TRACE_ID" ]; then
|
||
echo "用法: ./find-by-trace.sh <trace_id>"
|
||
exit 1
|
||
fi
|
||
|
||
echo "🔍 查找 trace ID: $TRACE_ID"
|
||
grep "$TRACE_ID" /app/logs/app.log | jq -r '.timestamp + " [" + .level + "] " + .message'
|
||
```
|
||
|
||
#### 实时监控错误
|
||
```bash
|
||
#!/bin/bash
|
||
# scripts/monitor-errors.sh
|
||
|
||
echo "🚨 实时错误监控 (按Ctrl+C退出):"
|
||
tail -f /app/logs/app.log | grep --line-buffered -i error | while read line; do
|
||
echo -e "\033[31m[ERROR]\033[0m $(echo "$line" | jq -r '.timestamp + " " + .message')"
|
||
done
|
||
```
|
||
|
||
### 2. 管理后台日志模块
|
||
|
||
#### 日志查看组件 (React/Vue)
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>📋 日志查看器</title>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Monaco', 'Consolas', monospace;
|
||
margin: 0; padding: 20px;
|
||
background: #f5f5f5;
|
||
}
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
.header {
|
||
background: white;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.controls {
|
||
display: flex;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.controls input, .controls select, .controls button {
|
||
padding: 8px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
.controls button {
|
||
background: #007bff;
|
||
color: white;
|
||
border: 1px solid #007bff;
|
||
cursor: pointer;
|
||
}
|
||
.controls button:hover { background: #0056b3; }
|
||
.controls button.active { background: #28a745; }
|
||
|
||
.stats {
|
||
background: white;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.logs-container {
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.log-entry {
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid #eee;
|
||
border-left: 4px solid #ddd;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
.log-entry:hover { background: #f8f9fa; }
|
||
.log-entry.error { border-left-color: #dc3545; background: #fff5f5; }
|
||
.log-entry.warn { border-left-color: #ffc107; background: #fff8e1; }
|
||
.log-entry.info { border-left-color: #28a745; background: #f0fff4; }
|
||
|
||
.log-time { color: #666; font-weight: bold; }
|
||
.log-level {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
margin: 0 8px;
|
||
}
|
||
.log-level.error { background: #dc3545; color: white; }
|
||
.log-level.warn { background: #ffc107; color: #212529; }
|
||
.log-level.info { background: #28a745; color: white; }
|
||
.log-level.debug { background: #6c757d; color: white; }
|
||
|
||
.log-trace {
|
||
color: #007bff;
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
margin: 0 8px;
|
||
}
|
||
.log-trace:hover { background: #e3f2fd; }
|
||
|
||
.log-message {
|
||
margin-top: 5px;
|
||
color: #333;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #666;
|
||
}
|
||
.error-msg {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #dc3545;
|
||
}
|
||
|
||
.search-highlight {
|
||
background: yellow;
|
||
padding: 1px 2px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>📋 日志查看器</h1>
|
||
<div class="controls">
|
||
<select id="levelFilter">
|
||
<option value="">🔍 所有级别</option>
|
||
<option value="error">❌ 错误</option>
|
||
<option value="warn">⚠️ 警告</option>
|
||
<option value="info">ℹ️ 信息</option>
|
||
<option value="debug">🐛 调试</option>
|
||
</select>
|
||
|
||
<input type="text" id="searchFilter" placeholder="🔍 搜索关键词..." style="min-width: 200px;">
|
||
<input type="text" id="traceFilter" placeholder="🔗 Trace ID" style="min-width: 150px;">
|
||
|
||
<select id="lineCount">
|
||
<option value="50">50 条</option>
|
||
<option value="100" selected>100 条</option>
|
||
<option value="200">200 条</option>
|
||
<option value="500">500 条</option>
|
||
</select>
|
||
|
||
<button onclick="loadLogs()" id="refreshBtn">🔄 刷新</button>
|
||
<button onclick="toggleAutoRefresh()" id="autoRefreshBtn">⏰ 自动刷新</button>
|
||
<button onclick="clearLogs()">🗑️ 清空</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats" id="stats">📊 正在加载统计...</div>
|
||
|
||
<div class="logs-container">
|
||
<div id="logs" class="loading">📱 正在加载日志...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let autoRefreshTimer = null;
|
||
let currentLogs = [];
|
||
|
||
// 模拟日志数据(实际应该从后端获取)
|
||
function generateMockLogs() {
|
||
const levels = ['info', 'warn', 'error', 'debug'];
|
||
const messages = [
|
||
'Photo created successfully',
|
||
'User authentication completed',
|
||
'Database connection established',
|
||
'Failed to process image',
|
||
'Cache miss for key: photos:list',
|
||
'HTTP request completed',
|
||
'Memory usage is high',
|
||
'File upload completed'
|
||
];
|
||
|
||
const logs = [];
|
||
for (let i = 0; i < 100; i++) {
|
||
const level = levels[Math.floor(Math.random() * levels.length)];
|
||
const message = messages[Math.floor(Math.random() * messages.length)];
|
||
const traceId = Math.random() > 0.7 ? `trace-${Math.random().toString(36).substr(2, 8)}` : null;
|
||
|
||
logs.push({
|
||
timestamp: new Date(Date.now() - Math.random() * 24 * 60 * 60 * 1000).toISOString(),
|
||
level: level,
|
||
message: message,
|
||
trace_id: traceId,
|
||
user_id: Math.random() > 0.8 ? `user-${Math.floor(Math.random() * 100)}` : null
|
||
});
|
||
}
|
||
|
||
return logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||
}
|
||
|
||
function loadLogs() {
|
||
const refreshBtn = document.getElementById('refreshBtn');
|
||
refreshBtn.textContent = '⏳ 加载中...';
|
||
refreshBtn.disabled = true;
|
||
|
||
// 模拟加载延迟
|
||
setTimeout(() => {
|
||
try {
|
||
// 这里应该是 fetch('/api/logs?...')
|
||
currentLogs = generateMockLogs();
|
||
filterAndDisplayLogs();
|
||
updateStats();
|
||
} catch (error) {
|
||
document.getElementById('logs').innerHTML =
|
||
'<div class="error-msg">❌ 加载失败: ' + error.message + '</div>';
|
||
}
|
||
|
||
refreshBtn.textContent = '🔄 刷新';
|
||
refreshBtn.disabled = false;
|
||
}, 500);
|
||
}
|
||
|
||
function filterAndDisplayLogs() {
|
||
const levelFilter = document.getElementById('levelFilter').value;
|
||
const searchFilter = document.getElementById('searchFilter').value.toLowerCase();
|
||
const traceFilter = document.getElementById('traceFilter').value;
|
||
const lineCount = parseInt(document.getElementById('lineCount').value);
|
||
|
||
let filteredLogs = currentLogs;
|
||
|
||
// 级别过滤
|
||
if (levelFilter) {
|
||
filteredLogs = filteredLogs.filter(log => log.level === levelFilter);
|
||
}
|
||
|
||
// 关键词搜索
|
||
if (searchFilter) {
|
||
filteredLogs = filteredLogs.filter(log =>
|
||
log.message.toLowerCase().includes(searchFilter) ||
|
||
(log.trace_id && log.trace_id.toLowerCase().includes(searchFilter))
|
||
);
|
||
}
|
||
|
||
// Trace ID 过滤
|
||
if (traceFilter) {
|
||
filteredLogs = filteredLogs.filter(log =>
|
||
log.trace_id && log.trace_id.includes(traceFilter)
|
||
);
|
||
}
|
||
|
||
// 限制条数
|
||
filteredLogs = filteredLogs.slice(0, lineCount);
|
||
|
||
displayLogs(filteredLogs, searchFilter);
|
||
}
|
||
|
||
function displayLogs(logs, searchTerm = '') {
|
||
const container = document.getElementById('logs');
|
||
|
||
if (logs.length === 0) {
|
||
container.innerHTML = '<div class="loading">📭 没有找到匹配的日志</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = logs.map(log => {
|
||
const time = new Date(log.timestamp).toLocaleString();
|
||
let message = log.message;
|
||
|
||
// 高亮搜索关键词
|
||
if (searchTerm) {
|
||
const regex = new RegExp(`(${searchTerm})`, 'gi');
|
||
message = message.replace(regex, '<span class="search-highlight">$1</span>');
|
||
}
|
||
|
||
return `
|
||
<div class="log-entry ${log.level}">
|
||
<div>
|
||
<span class="log-time">${time}</span>
|
||
<span class="log-level ${log.level}">${log.level.toUpperCase()}</span>
|
||
${log.trace_id ? `<span class="log-trace" onclick="filterByTrace('${log.trace_id}')">${log.trace_id}</span>` : ''}
|
||
</div>
|
||
<div class="log-message">${message}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateStats() {
|
||
const stats = {
|
||
total: currentLogs.length,
|
||
error: currentLogs.filter(l => l.level === 'error').length,
|
||
warn: currentLogs.filter(l => l.level === 'warn').length,
|
||
info: currentLogs.filter(l => l.level === 'info').length,
|
||
debug: currentLogs.filter(l => l.level === 'debug').length
|
||
};
|
||
|
||
document.getElementById('stats').innerHTML = `
|
||
📊 总计: <strong>${stats.total}</strong> 条 |
|
||
❌ 错误: <strong>${stats.error}</strong> |
|
||
⚠️ 警告: <strong>${stats.warn}</strong> |
|
||
ℹ️ 信息: <strong>${stats.info}</strong> |
|
||
🐛 调试: <strong>${stats.debug}</strong>
|
||
`;
|
||
}
|
||
|
||
function filterByTrace(traceId) {
|
||
document.getElementById('traceFilter').value = traceId;
|
||
filterAndDisplayLogs();
|
||
}
|
||
|
||
function toggleAutoRefresh() {
|
||
const btn = document.getElementById('autoRefreshBtn');
|
||
|
||
if (autoRefreshTimer) {
|
||
clearInterval(autoRefreshTimer);
|
||
autoRefreshTimer = null;
|
||
btn.textContent = '⏰ 自动刷新';
|
||
btn.classList.remove('active');
|
||
} else {
|
||
autoRefreshTimer = setInterval(loadLogs, 5000);
|
||
btn.textContent = '⏸️ 停止刷新';
|
||
btn.classList.add('active');
|
||
}
|
||
}
|
||
|
||
function clearLogs() {
|
||
if (confirm('确定要清空当前显示的日志吗?')) {
|
||
document.getElementById('logs').innerHTML = '<div class="loading">📭 日志已清空</div>';
|
||
currentLogs = [];
|
||
updateStats();
|
||
}
|
||
}
|
||
|
||
// 事件监听
|
||
document.getElementById('levelFilter').addEventListener('change', filterAndDisplayLogs);
|
||
document.getElementById('searchFilter').addEventListener('input', filterAndDisplayLogs);
|
||
document.getElementById('traceFilter').addEventListener('input', filterAndDisplayLogs);
|
||
document.getElementById('lineCount').addEventListener('change', filterAndDisplayLogs);
|
||
|
||
// 快捷键
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (e.key === 'r') {
|
||
e.preventDefault();
|
||
loadLogs();
|
||
}
|
||
if (e.key === 'f') {
|
||
e.preventDefault();
|
||
document.getElementById('searchFilter').focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 初始加载
|
||
loadLogs();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
#### 管理后台日志API接口
|
||
集成到现有的管理后台后端:
|
||
|
||
```go
|
||
// internal/api/handlers/admin/logs_handler.go
|
||
package admin
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"photography-backend/pkg/middleware"
|
||
)
|
||
|
||
// LogEntry 日志条目
|
||
type LogEntry struct {
|
||
Timestamp string `json:"timestamp"`
|
||
Level string `json:"level"`
|
||
Message string `json:"message"`
|
||
TraceID string `json:"trace_id,omitempty"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
Operation string `json:"operation,omitempty"`
|
||
}
|
||
|
||
// LogHandler 日志处理器
|
||
type LogHandler struct {
|
||
logFile string
|
||
}
|
||
|
||
// NewLogHandler 创建日志处理器
|
||
func NewLogHandler(logFile string) *LogHandler {
|
||
return &LogHandler{logFile: logFile}
|
||
}
|
||
|
||
// GetLogs 获取日志列表
|
||
// @Summary 获取系统日志
|
||
// @Description 获取系统日志列表,支持过滤和搜索
|
||
// @Tags 日志管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param level query string false "日志级别" Enums(error,warn,info,debug)
|
||
// @Param search query string false "搜索关键词"
|
||
// @Param trace_id query string false "Trace ID"
|
||
// @Param lines query int false "返回行数" default(100)
|
||
// @Success 200 {object} LogListResponse
|
||
// @Failure 401 {object} ErrorResponse
|
||
// @Failure 403 {object} ErrorResponse
|
||
// @Failure 500 {object} ErrorResponse
|
||
// @Router /admin/api/logs [get]
|
||
func (h *LogHandler) GetLogs(c *gin.Context) {
|
||
// 获取参数
|
||
levelFilter := c.Query("level")
|
||
searchFilter := c.Query("search")
|
||
traceID := c.Query("trace_id")
|
||
lines := 100
|
||
if l := c.Query("lines"); l != "" {
|
||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 {
|
||
lines = parsed
|
||
}
|
||
}
|
||
|
||
// 读取日志文件
|
||
logs, err := h.readLogs(lines, levelFilter, searchFilter, traceID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"error": "读取日志失败",
|
||
"details": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": gin.H{
|
||
"logs": logs,
|
||
"total": len(logs),
|
||
},
|
||
})
|
||
}
|
||
|
||
// GetLogStats 获取日志统计
|
||
// @Summary 获取日志统计信息
|
||
// @Description 获取各级别日志的统计数据
|
||
// @Tags 日志管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} LogStatsResponse
|
||
// @Failure 401 {object} ErrorResponse
|
||
// @Failure 403 {object} ErrorResponse
|
||
// @Failure 500 {object} ErrorResponse
|
||
// @Router /admin/api/logs/stats [get]
|
||
func (h *LogHandler) GetLogStats(c *gin.Context) {
|
||
stats := map[string]int{
|
||
"total": 0,
|
||
"error": 0,
|
||
"warn": 0,
|
||
"info": 0,
|
||
"debug": 0,
|
||
}
|
||
|
||
file, err := os.Open(h.logFile)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"error": "读取日志文件失败",
|
||
"details": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
scanner := bufio.NewScanner(file)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if line == "" {
|
||
continue
|
||
}
|
||
|
||
stats["total"]++
|
||
|
||
// 解析日志级别
|
||
var entry LogEntry
|
||
if err := json.Unmarshal([]byte(line), &entry); err == nil {
|
||
if count, exists := stats[entry.Level]; exists {
|
||
stats[entry.Level] = count + 1
|
||
}
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": stats,
|
||
})
|
||
}
|
||
|
||
// readLogs 读取日志文件
|
||
func (h *LogHandler) readLogs(maxLines int, levelFilter, searchFilter, traceFilter string) ([]LogEntry, error) {
|
||
file, err := os.Open(h.logFile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer file.Close()
|
||
|
||
var lines []string
|
||
scanner := bufio.NewScanner(file)
|
||
for scanner.Scan() {
|
||
lines = append(lines, scanner.Text())
|
||
}
|
||
|
||
// 从后往前读取最新的日志
|
||
start := len(lines) - maxLines*2 // 预读更多行用于过滤
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
var logs []LogEntry
|
||
for i := start; i < len(lines) && len(logs) < maxLines; i++ {
|
||
line := lines[i]
|
||
if line == "" {
|
||
continue
|
||
}
|
||
|
||
var entry LogEntry
|
||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||
continue // 跳过无法解析的行
|
||
}
|
||
|
||
// 应用过滤条件
|
||
if levelFilter != "" && entry.Level != levelFilter {
|
||
continue
|
||
}
|
||
if searchFilter != "" {
|
||
searchLower := strings.ToLower(searchFilter)
|
||
if !strings.Contains(strings.ToLower(entry.Message), searchLower) &&
|
||
!strings.Contains(strings.ToLower(entry.TraceID), searchLower) &&
|
||
!strings.Contains(strings.ToLower(entry.Operation), searchLower) {
|
||
continue
|
||
}
|
||
}
|
||
if traceFilter != "" && !strings.Contains(entry.TraceID, traceFilter) {
|
||
continue
|
||
}
|
||
|
||
logs = append(logs, entry)
|
||
}
|
||
|
||
// 反转数组,让最新的日志在前面
|
||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||
logs[i], logs[j] = logs[j], logs[i]
|
||
}
|
||
|
||
return logs, nil
|
||
}
|
||
|
||
// RegisterLogRoutes 注册日志相关路由
|
||
func RegisterLogRoutes(r *gin.RouterGroup, logFile string) {
|
||
logHandler := NewLogHandler(logFile)
|
||
|
||
// 需要管理员权限的路由组
|
||
adminGroup := r.Group("")
|
||
adminGroup.Use(middleware.RequireAuth()) // 需要登录
|
||
adminGroup.Use(middleware.RequireAdmin()) // 需要管理员权限
|
||
|
||
{
|
||
adminGroup.GET("/logs", logHandler.GetLogs)
|
||
adminGroup.GET("/logs/stats", logHandler.GetLogStats)
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 管理后台前端集成
|
||
在管理后台中添加日志管理页面:
|
||
|
||
```javascript
|
||
// admin/src/pages/Logs/LogViewer.jsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Card,
|
||
Table,
|
||
Select,
|
||
Input,
|
||
Button,
|
||
Tag,
|
||
Space,
|
||
Statistic,
|
||
Row,
|
||
Col,
|
||
message
|
||
} from 'antd';
|
||
import {
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
ExclamationCircleOutlined,
|
||
WarningOutlined,
|
||
InfoCircleOutlined,
|
||
BugOutlined
|
||
} from '@ant-design/icons';
|
||
import { adminApi } from '../../services/api';
|
||
|
||
const { Option } = Select;
|
||
const { Search } = Input;
|
||
|
||
const LogViewer = () => {
|
||
const [logs, setLogs] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [filters, setFilters] = useState({
|
||
level: '',
|
||
search: '',
|
||
trace_id: '',
|
||
lines: 100,
|
||
});
|
||
const [stats, setStats] = useState({});
|
||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||
|
||
// 日志级别配置
|
||
const levelConfig = {
|
||
error: { color: 'red', icon: <ExclamationCircleOutlined /> },
|
||
warn: { color: 'orange', icon: <WarningOutlined /> },
|
||
info: { color: 'blue', icon: <InfoCircleOutlined /> },
|
||
debug: { color: 'default', icon: <BugOutlined /> },
|
||
};
|
||
|
||
// 获取日志数据
|
||
const fetchLogs = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await adminApi.get('/logs', { params: filters });
|
||
setLogs(response.data.data.logs || []);
|
||
} catch (error) {
|
||
message.error('获取日志失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 获取统计数据
|
||
const fetchStats = async () => {
|
||
try {
|
||
const response = await adminApi.get('/logs/stats');
|
||
setStats(response.data.data || {});
|
||
} catch (error) {
|
||
console.error('获取统计数据失败', error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchLogs();
|
||
fetchStats();
|
||
}, [filters]);
|
||
|
||
// 自动刷新
|
||
useEffect(() => {
|
||
let interval;
|
||
if (autoRefresh) {
|
||
interval = setInterval(() => {
|
||
fetchLogs();
|
||
fetchStats();
|
||
}, 5000);
|
||
}
|
||
return () => interval && clearInterval(interval);
|
||
}, [autoRefresh, filters]);
|
||
|
||
// 表格列配置
|
||
const columns = [
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'timestamp',
|
||
key: 'timestamp',
|
||
width: 180,
|
||
render: (timestamp) => new Date(timestamp).toLocaleString(),
|
||
},
|
||
{
|
||
title: '级别',
|
||
dataIndex: 'level',
|
||
key: 'level',
|
||
width: 80,
|
||
render: (level) => {
|
||
const config = levelConfig[level] || levelConfig.info;
|
||
return (
|
||
<Tag color={config.color} icon={config.icon}>
|
||
{level.toUpperCase()}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: 'Trace ID',
|
||
dataIndex: 'trace_id',
|
||
key: 'trace_id',
|
||
width: 120,
|
||
render: (traceId) => traceId ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => setFilters(prev => ({ ...prev, trace_id: traceId }))}
|
||
>
|
||
{traceId}
|
||
</Button>
|
||
) : '-',
|
||
},
|
||
{
|
||
title: '消息',
|
||
dataIndex: 'message',
|
||
key: 'message',
|
||
ellipsis: true,
|
||
render: (message, record) => {
|
||
// 高亮搜索关键词
|
||
if (filters.search && message.toLowerCase().includes(filters.search.toLowerCase())) {
|
||
const regex = new RegExp(`(${filters.search})`, 'gi');
|
||
const parts = message.split(regex);
|
||
return parts.map((part, index) =>
|
||
part.toLowerCase() === filters.search.toLowerCase() ?
|
||
<mark key={index}>{part}</mark> : part
|
||
);
|
||
}
|
||
return message;
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
dataIndex: 'operation',
|
||
key: 'operation',
|
||
width: 120,
|
||
render: (operation) => operation || '-',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="log-viewer">
|
||
{/* 统计卡片 */}
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic title="总计" value={stats.total || 0} />
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="错误"
|
||
value={stats.error || 0}
|
||
valueStyle={{ color: '#ff4d4f' }}
|
||
prefix={<ExclamationCircleOutlined />}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="警告"
|
||
value={stats.warn || 0}
|
||
valueStyle={{ color: '#fa8c16' }}
|
||
prefix={<WarningOutlined />}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="信息"
|
||
value={stats.info || 0}
|
||
valueStyle={{ color: '#1890ff' }}
|
||
prefix={<InfoCircleOutlined />}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 过滤控件 */}
|
||
<Card style={{ marginBottom: 16 }}>
|
||
<Space wrap>
|
||
<Select
|
||
placeholder="选择日志级别"
|
||
style={{ width: 120 }}
|
||
value={filters.level}
|
||
onChange={(value) => setFilters(prev => ({ ...prev, level: value }))}
|
||
>
|
||
<Option value="">全部级别</Option>
|
||
<Option value="error">错误</Option>
|
||
<Option value="warn">警告</Option>
|
||
<Option value="info">信息</Option>
|
||
<Option value="debug">调试</Option>
|
||
</Select>
|
||
|
||
<Search
|
||
placeholder="搜索关键词"
|
||
style={{ width: 200 }}
|
||
value={filters.search}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||
prefix={<SearchOutlined />}
|
||
/>
|
||
|
||
<Input
|
||
placeholder="Trace ID"
|
||
style={{ width: 150 }}
|
||
value={filters.trace_id}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, trace_id: e.target.value }))}
|
||
/>
|
||
|
||
<Select
|
||
value={filters.lines}
|
||
style={{ width: 100 }}
|
||
onChange={(value) => setFilters(prev => ({ ...prev, lines: value }))}
|
||
>
|
||
<Option value={50}>50 条</Option>
|
||
<Option value={100}>100 条</Option>
|
||
<Option value={200}>200 条</Option>
|
||
<Option value={500}>500 条</Option>
|
||
</Select>
|
||
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
onClick={fetchLogs}
|
||
loading={loading}
|
||
>
|
||
刷新
|
||
</Button>
|
||
|
||
<Button
|
||
type={autoRefresh ? 'primary' : 'default'}
|
||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||
>
|
||
{autoRefresh ? '停止自动刷新' : '自动刷新'}
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
|
||
{/* 日志表格 */}
|
||
<Card>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={logs}
|
||
loading={loading}
|
||
rowKey={(record, index) => `${record.timestamp}-${index}`}
|
||
pagination={{
|
||
showSizeChanger: false,
|
||
showQuickJumper: true,
|
||
showTotal: (total) => `共 ${total} 条日志`,
|
||
}}
|
||
scroll={{ x: 800 }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default LogViewer;
|
||
```
|
||
|
||
## 🚀 集成到管理后台
|
||
|
||
### 1. 后端集成步骤
|
||
|
||
#### 在main.go中注册日志路由
|
||
```go
|
||
// cmd/server/main.go
|
||
func main() {
|
||
// ... 其他初始化代码
|
||
|
||
// 创建Gin引擎
|
||
r := gin.Default()
|
||
|
||
// 注册API路由
|
||
apiGroup := r.Group("/api")
|
||
{
|
||
// 其他API路由...
|
||
}
|
||
|
||
// 注册管理后台路由
|
||
adminGroup := r.Group("/admin/api")
|
||
{
|
||
// 注册日志管理路由
|
||
admin.RegisterLogRoutes(adminGroup, "logs/app.log")
|
||
|
||
// 其他管理后台路由...
|
||
}
|
||
|
||
r.Run(":8080")
|
||
}
|
||
```
|
||
|
||
#### 添加权限中间件
|
||
```go
|
||
// pkg/middleware/auth.go
|
||
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// RequireAuth 需要登录认证
|
||
func RequireAuth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 检查JWT token或session
|
||
token := c.GetHeader("Authorization")
|
||
if token == "" {
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"error": "未授权访问",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 验证token并获取用户信息
|
||
// ... token验证逻辑
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// RequireAdmin 需要管理员权限
|
||
func RequireAdmin() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 检查用户是否是管理员
|
||
userRole := c.GetString("user_role")
|
||
if userRole != "admin" {
|
||
c.JSON(http.StatusForbidden, gin.H{
|
||
"error": "需要管理员权限",
|
||
})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2. 前端集成步骤
|
||
|
||
#### 在管理后台路由中添加日志管理
|
||
```javascript
|
||
// admin/src/router/index.js
|
||
import LogViewer from '../pages/Logs/LogViewer.jsx';
|
||
|
||
const routes = [
|
||
// 其他路由...
|
||
{
|
||
path: '/admin',
|
||
component: AdminLayout,
|
||
children: [
|
||
{
|
||
path: 'logs',
|
||
component: LogViewer,
|
||
meta: {
|
||
title: '日志管理',
|
||
requireAuth: true,
|
||
requireAdmin: true
|
||
}
|
||
}
|
||
// 其他子路由...
|
||
]
|
||
}
|
||
];
|
||
```
|
||
|
||
#### 在管理后台菜单中添加日志入口
|
||
```javascript
|
||
// admin/src/layout/AdminLayout.jsx
|
||
const menuItems = [
|
||
{
|
||
key: 'dashboard',
|
||
icon: <DashboardOutlined />,
|
||
label: '仪表盘',
|
||
path: '/admin/dashboard'
|
||
},
|
||
{
|
||
key: 'photos',
|
||
icon: <PictureOutlined />,
|
||
label: '照片管理',
|
||
path: '/admin/photos'
|
||
},
|
||
{
|
||
key: 'logs',
|
||
icon: <FileTextOutlined />,
|
||
label: '日志管理',
|
||
path: '/admin/logs'
|
||
},
|
||
// 其他菜单项...
|
||
];
|
||
```
|
||
|
||
### 3. 配置更新
|
||
|
||
#### 更新应用配置文件
|
||
```yaml
|
||
# config/config.yaml
|
||
app:
|
||
name: "photography-backend"
|
||
version: "1.0.0"
|
||
environment: "production"
|
||
|
||
logger:
|
||
level: "info"
|
||
format: "json"
|
||
output: "file"
|
||
filename: "logs/app.log"
|
||
max_size: 100
|
||
max_age: 7
|
||
compress: true
|
||
|
||
tracing:
|
||
enabled: true
|
||
service_name: "photography-backend"
|
||
sampling_rate: 1.0
|
||
|
||
admin:
|
||
log_access: true # 启用日志访问功能
|
||
log_retention_days: 30 # 日志保留天数
|
||
```
|
||
|
||
### 4. 一键部署脚本
|
||
|
||
#### 部署管理后台日志功能
|
||
```bash
|
||
#!/bin/bash
|
||
# deploy-admin-logs.sh
|
||
|
||
echo "🚀 部署管理后台日志功能..."
|
||
|
||
# 创建必要目录
|
||
mkdir -p logs
|
||
mkdir -p admin/src/pages/Logs
|
||
|
||
# 确保日志文件存在
|
||
touch logs/app.log
|
||
chmod 664 logs/app.log
|
||
|
||
# 创建日志轮转配置
|
||
cat > /etc/logrotate.d/photography-backend << 'EOF'
|
||
/path/to/photography/logs/*.log {
|
||
daily
|
||
rotate 30
|
||
compress
|
||
delaycompress
|
||
missingok
|
||
notifempty
|
||
create 664 app app
|
||
postrotate
|
||
systemctl reload photography-backend
|
||
endscript
|
||
}
|
||
EOF
|
||
|
||
# 设置权限
|
||
chown -R app:app logs/
|
||
chmod 755 logs/
|
||
|
||
echo "✅ 管理后台日志功能部署完成!"
|
||
echo ""
|
||
echo "📋 访问方式:"
|
||
echo " 1. 登录管理后台: http://localhost:8080/admin"
|
||
echo " 2. 进入日志管理页面"
|
||
echo " 3. 使用管理员账号访问"
|
||
echo ""
|
||
echo "🔧 API地址:"
|
||
echo " 日志列表: GET /admin/api/logs"
|
||
echo " 日志统计: GET /admin/api/logs/stats"
|
||
```
|
||
|
||
## 🔧 故障排查流程
|
||
|
||
### 1. 问题诊断步骤
|
||
```bash
|
||
# 快速诊断脚本
|
||
#!/bin/bash
|
||
# scripts/quick-diagnosis.sh
|
||
|
||
echo "🔍 快速问题诊断"
|
||
|
||
# 1. 检查服务状态
|
||
echo "1. 检查服务状态..."
|
||
docker-compose ps
|
||
|
||
# 2. 检查最近错误
|
||
echo "2. 最近10条错误日志..."
|
||
tail -n 1000 logs/app.log | grep -i error | tail -10 | jq -r '.timestamp + " " + .message'
|
||
|
||
# 3. 检查磁盘空间
|
||
echo "3. 检查磁盘空间..."
|
||
df -h
|
||
|
||
# 4. 检查日志文件大小
|
||
echo "4. 检查日志文件大小..."
|
||
ls -lh logs/
|
||
|
||
# 5. 检查内存使用
|
||
echo "5. 检查内存使用..."
|
||
docker stats --no-stream photography-backend
|
||
|
||
echo "✅ 诊断完成"
|
||
```
|
||
|
||
### 2. 常见问题解决
|
||
|
||
#### 问题1:无法找到错误原因
|
||
```bash
|
||
# 1. 获取完整的错误上下文
|
||
grep -B 5 -A 5 "error_message" logs/app.log
|
||
|
||
# 2. 按时间范围查找
|
||
grep "2024-01-15T10:" logs/app.log | grep -i error
|
||
```
|
||
|
||
#### 问题2:需要追踪特定用户的操作
|
||
```bash
|
||
# 按用户ID查找
|
||
grep "user-123" logs/app.log | jq -r '.timestamp + " " + .message'
|
||
|
||
# 按操作类型查找
|
||
grep "create_photo" logs/app.log | jq -r '.timestamp + " " + .message'
|
||
```
|
||
|
||
#### 问题3:日志文件太大
|
||
```bash
|
||
# 手动轮转日志
|
||
mv logs/app.log logs/app.log.old
|
||
sudo systemctl restart photography-backend
|
||
|
||
# 或者使用logrotate
|
||
sudo logrotate -f /etc/logrotate.d/photography-backend
|
||
```
|
||
|
||
## 🎯 总结
|
||
|
||
这个集成到管理后台的日志方案提供了:
|
||
|
||
### ✨ 核心特性
|
||
- **🏢 集成化管理** - 完全集成到管理后台,统一的用户体验
|
||
- **🔐 权限控制** - 基于管理后台的认证和授权体系
|
||
- **📊 专业界面** - 使用Ant Design组件,美观且专业
|
||
- **🔍 强大搜索** - 支持关键词、级别、Trace ID多维度过滤
|
||
- **⏰ 实时监控** - 自动刷新功能,实时观察系统状态
|
||
- **📈 统计分析** - 直观的统计卡片,快速了解系统健康状况
|
||
|
||
### 🚀 部署简单
|
||
1. 运行部署脚本: `./deploy-admin-logs.sh`
|
||
2. 访问管理后台: `http://localhost:8080/admin`
|
||
3. 登录管理员账号,进入日志管理页面
|
||
|
||
### 💡 使用场景
|
||
- **错误排查**: 管理员快速定位和分析错误日志
|
||
- **性能监控**: 通过Trace ID追踪完整请求链路
|
||
- **运营监控**: 实时观察系统运行状态
|
||
- **历史分析**: 搜索和分析历史日志数据
|
||
- **团队协作**: 多个管理员可以同时查看和分析日志
|
||
|
||
### 🔧 技术优势
|
||
- **集成性**: 完全集成到现有管理后台
|
||
- **安全性**: 基于管理后台的权限控制
|
||
- **专业性**: 使用成熟的UI组件库
|
||
- **可扩展**: 易于添加新的日志分析功能
|
||
- **用户友好**: 直观的界面,无需学习成本
|
||
|
||
### 🔐 权限控制
|
||
- **认证**: 需要管理后台登录
|
||
- **授权**: 仅管理员可访问日志功能
|
||
- **安全**: API接口有完整的权限验证
|
||
|
||
### 📋 API接口
|
||
- `GET /admin/api/logs` - 获取日志列表
|
||
- `GET /admin/api/logs/stats` - 获取日志统计
|
||
|
||
这就是最适合生产环境的日志管理方案 - **专业、安全、易用**! |