9 changed files with 912 additions and 0 deletions
-
1.gitignore
-
43frontend/src/api/log.ts
-
1frontend/src/router/index.ts
-
1frontend/src/stores/navigation.ts
-
460frontend/src/views/LogViewer.vue
-
110src/main/java/com/wok/supportbot/controller/LogController.java
-
237src/main/java/com/wok/supportbot/service/LogViewerService.java
-
2src/main/resources/application-prod.yml
-
57src/main/resources/logback-spring.xml
@ -0,0 +1,43 @@ |
|||
import request from './request' |
|||
import type { ApiResponse } from '@/types/api' |
|||
|
|||
/** 日志文件信息 */ |
|||
export interface LogFileInfo { |
|||
name: string |
|||
size: number |
|||
sizeFormatted: string |
|||
lastModified: string |
|||
} |
|||
|
|||
/** 日志内容响应 */ |
|||
export interface LogContent { |
|||
content: string |
|||
fileSize: number |
|||
fileSizeFormatted: string |
|||
linesReturned: number |
|||
totalLinesInFile: number |
|||
hasMore: boolean |
|||
} |
|||
|
|||
/** |
|||
* 列出所有日志文件 |
|||
*/ |
|||
export function listLogFiles(): Promise<ApiResponse<LogFileInfo[]>> { |
|||
return request.get('/log/files').then(r => r.data) |
|||
} |
|||
|
|||
/** |
|||
* 查看日志文件尾部内容 |
|||
* @param fileName 日志文件名 |
|||
* @param lines 读取最后 N 行(默认 200) |
|||
* @param keyword 可选搜索关键词 |
|||
*/ |
|||
export function viewLog( |
|||
fileName: string, |
|||
lines: number = 200, |
|||
keyword?: string |
|||
): Promise<ApiResponse<LogContent>> { |
|||
let path = `/log/view/${encodeURIComponent(fileName)}?lines=${lines}` |
|||
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` |
|||
return request.get(path).then(r => r.data) |
|||
} |
|||
@ -0,0 +1,460 @@ |
|||
<template> |
|||
<t-card title="📜 系统日志" :bordered="false"> |
|||
<template #subtitle> |
|||
<span style="color: var(--td-text-color-secondary); font-size: 13px;"> |
|||
查看服务器运行日志,无需登录服务器即可排查问题。 |
|||
<span style="color: var(--td-warning-color);">日志可能包含敏感信息,请勿对外泄露。</span> |
|||
</span> |
|||
</template> |
|||
|
|||
<div class="log-layout"> |
|||
<!-- ========== 左侧:日志文件列表 ========== --> |
|||
<div class="file-panel"> |
|||
<div class="file-panel-header"> |
|||
<span>日志文件</span> |
|||
<t-button size="small" variant="text" @click="loadFiles" :loading="filesLoading">🔄</t-button> |
|||
</div> |
|||
<div class="file-list" v-loading="filesLoading"> |
|||
<div v-if="files.length === 0 && !filesLoading" class="file-empty"> |
|||
<t-empty description="暂无日志文件" /> |
|||
<p style="font-size: 12px; color: var(--td-text-color-placeholder); margin-top: 8px;"> |
|||
dev 环境不输出文件日志,请使用 prod 配置启动。 |
|||
</p> |
|||
</div> |
|||
<div |
|||
v-for="f in files" |
|||
:key="f.name" |
|||
class="file-item" |
|||
:class="{ active: selectedFile === f.name }" |
|||
@click="selectFile(f.name)" |
|||
> |
|||
<div class="file-name">{{ f.name }}</div> |
|||
<div class="file-meta"> |
|||
<t-tag size="small" variant="light" theme="default">{{ f.sizeFormatted }}</t-tag> |
|||
<span class="file-date">{{ f.lastModified }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- ========== 右侧:日志内容 ========== --> |
|||
<div class="content-panel"> |
|||
<!-- 工具栏 --> |
|||
<div class="content-toolbar"> |
|||
<t-space :size="8"> |
|||
<t-input |
|||
v-model="searchKeyword" |
|||
placeholder="搜索关键词..." |
|||
size="small" |
|||
style="width: 200px;" |
|||
clearable |
|||
@change="onSearchChange" |
|||
@enter="loadContent" |
|||
/> |
|||
<t-button size="small" variant="outline" @click="loadContent" :disabled="!selectedFile">搜索</t-button> |
|||
<t-select |
|||
v-model="displayLines" |
|||
:options="lineOptions" |
|||
size="small" |
|||
style="width: 90px;" |
|||
@change="loadContent" |
|||
/> |
|||
</t-space> |
|||
<t-space :size="8"> |
|||
<t-checkbox v-model="autoRefresh" @change="onAutoRefreshToggle">自动刷新</t-checkbox> |
|||
<t-select |
|||
v-if="autoRefresh" |
|||
v-model="refreshInterval" |
|||
:options="refreshOptions" |
|||
size="small" |
|||
style="width: 70px;" |
|||
@change="restartAutoRefresh" |
|||
/> |
|||
<t-button size="small" variant="outline" @click="loadContent" :disabled="!selectedFile" :loading="contentLoading">刷新</t-button> |
|||
<t-button size="small" variant="outline" @click="downloadFile" :disabled="!selectedFile">下载</t-button> |
|||
</t-space> |
|||
</div> |
|||
|
|||
<!-- 内容区 --> |
|||
<div class="content-area" ref="contentAreaRef"> |
|||
<!-- 空状态 --> |
|||
<div v-if="!selectedFile" class="content-empty"> |
|||
<t-empty description="请从左侧选择日志文件" /> |
|||
</div> |
|||
|
|||
<!-- loading --> |
|||
<div v-else-if="contentLoading && logContent === ''" class="content-loading"> |
|||
<t-loading text="加载中..." /> |
|||
</div> |
|||
|
|||
<!-- 搜索无结果 --> |
|||
<div v-else-if="logContent === '' && searchKeyword" class="content-empty"> |
|||
<t-empty description="未找到匹配" /> |
|||
</div> |
|||
|
|||
<!-- 日志内容 --> |
|||
<pre v-else class="log-content" v-html="highlightContent"></pre> |
|||
</div> |
|||
|
|||
<!-- 底部信息栏 --> |
|||
<div v-if="selectedFile" class="content-footer"> |
|||
<span>返回 {{ linesInView }} 行 | |
|||
{{ fileSizeFormatted }} | |
|||
{{ hasMore ? '已加载尾部,历史日志请下载查看' : '已加载全部' }} |
|||
</span> |
|||
<t-button v-if="hasMore" size="small" variant="text" @click="loadMore">加载更多</t-button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</t-card> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onUnmounted } from 'vue' |
|||
import { listLogFiles, viewLog, type LogFileInfo } from '@/api/log' |
|||
import request from '@/api/request' |
|||
import { toast } from '@/utils/toast' |
|||
import { useDebounce } from '@/composables/useDebounce' |
|||
|
|||
const { debounce } = useDebounce() |
|||
|
|||
// ==================== 状态 ==================== |
|||
const files = ref<LogFileInfo[]>([]) |
|||
const filesLoading = ref(false) |
|||
const selectedFile = ref('') |
|||
const logContent = ref('') |
|||
const contentLoading = ref(false) |
|||
const searchKeyword = ref('') |
|||
const displayLines = ref(200) |
|||
const linesInView = ref(0) |
|||
const hasMore = ref(false) |
|||
const fileSizeFormatted = ref('') |
|||
const autoRefresh = ref(false) |
|||
const refreshInterval = ref(30) |
|||
const contentAreaRef = ref<HTMLElement | null>(null) |
|||
|
|||
// offset 用于分页加载(从尾部倒数 offset 行) |
|||
const currentOffset = ref(0) |
|||
|
|||
// ==================== 选项 ==================== |
|||
const lineOptions = [ |
|||
{ label: '100 行', value: 100 }, |
|||
{ label: '200 行', value: 200 }, |
|||
{ label: '500 行', value: 500 }, |
|||
{ label: '1000 行', value: 1000 }, |
|||
] |
|||
const refreshOptions = [ |
|||
{ label: '10s', value: 10 }, |
|||
{ label: '30s', value: 30 }, |
|||
{ label: '60s', value: 60 }, |
|||
] |
|||
|
|||
let refreshTimer: ReturnType<typeof setInterval> | null = null |
|||
|
|||
// ==================== 高亮 ==================== |
|||
const highlightContent = computed(() => { |
|||
if (!logContent.value) return '' |
|||
// 先转义 HTML |
|||
const escaped = logContent.value |
|||
.replace(/&/g, '&') |
|||
.replace(/</g, '<') |
|||
.replace(/>/g, '>') |
|||
.replace(/"/g, '"') |
|||
|
|||
// 关键词高亮(安全:已在 v-html 前转义) |
|||
if (searchKeyword.value.trim()) { |
|||
const kw = searchKeyword.value.trim() |
|||
const regex = new RegExp(`(${escapeRegex(kw)})`, 'gi') |
|||
return escaped.replace(regex, '<mark style="background:#fff3cd;color:#000;">$1</mark>') |
|||
} |
|||
return escaped |
|||
}) |
|||
|
|||
function escapeRegex(s: string): string { |
|||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
|||
} |
|||
|
|||
// ==================== 生命周期 ==================== |
|||
onMounted(() => { |
|||
loadFiles() |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
stopAutoRefresh() |
|||
}) |
|||
|
|||
// ==================== 文件列表 ==================== |
|||
async function loadFiles() { |
|||
filesLoading.value = true |
|||
try { |
|||
const r = await listLogFiles() |
|||
if (r.success) { |
|||
files.value = r.data || [] |
|||
} |
|||
} catch (e: any) { |
|||
toast('加载文件列表失败:' + e.message, 'error') |
|||
} finally { |
|||
filesLoading.value = false |
|||
} |
|||
} |
|||
|
|||
function selectFile(name: string) { |
|||
selectedFile.value = name |
|||
currentOffset.value = 0 |
|||
searchKeyword.value = '' |
|||
logContent.value = '' |
|||
loadContent() |
|||
} |
|||
|
|||
// ==================== 内容加载 ==================== |
|||
async function loadContent() { |
|||
if (!selectedFile.value) return |
|||
contentLoading.value = true |
|||
try { |
|||
const r = await viewLog(selectedFile.value, displayLines.value, searchKeyword.value || undefined) |
|||
if (r.success && r.data) { |
|||
logContent.value = r.data.content || '' |
|||
linesInView.value = r.data.linesReturned || 0 |
|||
hasMore.value = r.data.hasMore || false |
|||
fileSizeFormatted.value = r.data.fileSizeFormatted || '' |
|||
currentOffset.value = displayLines.value |
|||
} |
|||
} catch (e: any) { |
|||
toast('读取日志失败:' + e.message, 'error') |
|||
} finally { |
|||
contentLoading.value = false |
|||
} |
|||
} |
|||
|
|||
/** 加载更多(向下翻页) */ |
|||
async function loadMore() { |
|||
if (!selectedFile.value) return |
|||
contentLoading.value = true |
|||
try { |
|||
// 增加行数重新读取 |
|||
const moreLines = currentOffset.value + displayLines.value |
|||
const r = await viewLog(selectedFile.value, moreLines, searchKeyword.value || undefined) |
|||
if (r.success && r.data) { |
|||
logContent.value = r.data.content || '' |
|||
linesInView.value = r.data.linesReturned || 0 |
|||
hasMore.value = r.data.hasMore || false |
|||
fileSizeFormatted.value = r.data.fileSizeFormatted || '' |
|||
currentOffset.value = moreLines |
|||
} |
|||
} catch (e: any) { |
|||
toast('加载更多失败:' + e.message, 'error') |
|||
} finally { |
|||
contentLoading.value = false |
|||
} |
|||
} |
|||
|
|||
/** 搜索防抖 */ |
|||
const onSearchChange = debounce(() => { |
|||
currentOffset.value = 0 |
|||
loadContent() |
|||
}, 400) |
|||
|
|||
// ==================== 下载 ==================== |
|||
// ==================== 下载 ==================== |
|||
async function downloadFile() { |
|||
if (!selectedFile.value) return |
|||
try { |
|||
const resp = await request.get(`/log/download/${encodeURIComponent(selectedFile.value)}`, { responseType: 'blob' }) |
|||
const blob = resp.data |
|||
const url = URL.createObjectURL(blob) |
|||
const a = document.createElement('a') |
|||
a.href = url |
|||
a.download = selectedFile.value |
|||
document.body.appendChild(a) |
|||
a.click() |
|||
document.body.removeChild(a) |
|||
URL.revokeObjectURL(url) |
|||
} catch (e: any) { |
|||
toast('下载失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 自动刷新 ==================== |
|||
function onAutoRefreshToggle() { |
|||
if (autoRefresh.value) { |
|||
startAutoRefresh() |
|||
} else { |
|||
stopAutoRefresh() |
|||
} |
|||
} |
|||
|
|||
function startAutoRefresh() { |
|||
stopAutoRefresh() |
|||
refreshTimer = setInterval(() => { |
|||
loadContent() |
|||
}, refreshInterval.value * 1000) |
|||
} |
|||
|
|||
function stopAutoRefresh() { |
|||
if (refreshTimer) { |
|||
clearInterval(refreshTimer) |
|||
refreshTimer = null |
|||
} |
|||
} |
|||
|
|||
function restartAutoRefresh() { |
|||
if (autoRefresh.value) { |
|||
startAutoRefresh() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
/* ========== 整体布局 ========== */ |
|||
.log-layout { |
|||
display: flex; |
|||
gap: 16px; |
|||
height: calc(100vh - 200px); |
|||
min-height: 500px; |
|||
} |
|||
|
|||
/* ========== 左侧文件面板 ========== */ |
|||
.file-panel { |
|||
width: 240px; |
|||
flex-shrink: 0; |
|||
border: 1px solid var(--td-border-level-1-color); |
|||
border-radius: var(--td-radius-default); |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.file-panel-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 10px 12px; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
font-weight: 600; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.file-list { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.file-item { |
|||
padding: 10px 12px; |
|||
cursor: pointer; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
transition: background .15s; |
|||
} |
|||
|
|||
.file-item:hover { |
|||
background: var(--td-bg-color-container-hover); |
|||
} |
|||
|
|||
.file-item.active { |
|||
background: var(--td-brand-color-light); |
|||
border-left: 3px solid var(--td-brand-color); |
|||
padding-left: 9px; |
|||
} |
|||
|
|||
.file-name { |
|||
font-family: 'Courier New', monospace; |
|||
font-size: 13px; |
|||
word-break: break-all; |
|||
} |
|||
|
|||
.file-meta { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
margin-top: 4px; |
|||
} |
|||
|
|||
.file-date { |
|||
font-size: 11px; |
|||
color: var(--td-text-color-placeholder); |
|||
} |
|||
|
|||
.file-empty { |
|||
padding: 24px 12px; |
|||
text-align: center; |
|||
} |
|||
|
|||
/* ========== 右侧内容面板 ========== */ |
|||
.content-panel { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-width: 0; |
|||
border: 1px solid var(--td-border-level-1-color); |
|||
border-radius: var(--td-radius-default); |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.content-toolbar { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 8px 12px; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
flex-wrap: wrap; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.content-area { |
|||
flex: 1; |
|||
overflow: auto; |
|||
background: #1e1e1e; |
|||
color: #d4d4d4; |
|||
position: relative; |
|||
} |
|||
|
|||
.content-empty, |
|||
.content-loading { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
height: 100%; |
|||
min-height: 200px; |
|||
} |
|||
|
|||
.log-content { |
|||
margin: 0; |
|||
padding: 12px 16px; |
|||
font-family: 'Consolas', 'Courier New', monospace; |
|||
font-size: 13px; |
|||
line-height: 1.65; |
|||
white-space: pre-wrap; |
|||
word-break: break-all; |
|||
min-height: 200px; |
|||
} |
|||
|
|||
/* 搜索高亮标记 */ |
|||
.log-content :deep(mark) { |
|||
background: #fff3cd; |
|||
color: #000; |
|||
padding: 0 2px; |
|||
border-radius: 2px; |
|||
} |
|||
|
|||
/* ========== 底部信息栏 ========== */ |
|||
.content-footer { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 6px 12px; |
|||
border-top: 1px solid var(--td-border-level-1-color); |
|||
font-size: 12px; |
|||
color: var(--td-text-color-placeholder); |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.log-layout { |
|||
flex-direction: column; |
|||
height: auto; |
|||
} |
|||
.file-panel { |
|||
width: 100%; |
|||
max-height: 200px; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,110 @@ |
|||
package com.wok.supportbot.controller; |
|||
|
|||
import com.wok.supportbot.service.LogViewerService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.core.io.FileSystemResource; |
|||
import org.springframework.core.io.Resource; |
|||
import org.springframework.http.HttpHeaders; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.io.File; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 系统日志查看接口 |
|||
* <p> |
|||
* 提供日志文件列表、尾部内容查看、关键词搜索及文件下载功能。 |
|||
* 仅 admin 角色可访问,所有文件操作限制在日志目录内,防止路径遍历攻击。 |
|||
*/ |
|||
@Slf4j |
|||
@RestController |
|||
@RequestMapping("/log") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public class LogController { |
|||
|
|||
@Autowired |
|||
private LogViewerService logViewerService; |
|||
|
|||
/** |
|||
* 列出所有日志文件(名称、大小、修改时间等) |
|||
*/ |
|||
@GetMapping("/files") |
|||
public ResponseEntity<Map<String, Object>> listFiles() { |
|||
try { |
|||
List<Map<String, Object>> files = logViewerService.listFiles(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", files |
|||
)); |
|||
} catch (Exception e) { |
|||
log.error("获取日志文件列表失败", e); |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "获取日志文件列表失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 查看日志文件尾部内容 |
|||
* |
|||
* @param fileName 日志文件名 |
|||
* @param lines 读取最后 N 行(默认 200,上限 5000) |
|||
* @param keyword 可选搜索关键词 |
|||
*/ |
|||
@GetMapping("/view/{fileName}") |
|||
public ResponseEntity<Map<String, Object>> viewLog( |
|||
@PathVariable String fileName, |
|||
@RequestParam(defaultValue = "200") int lines, |
|||
@RequestParam(required = false) String keyword) { |
|||
try { |
|||
Map<String, Object> result = logViewerService.readLogTail(fileName, lines, keyword); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", result |
|||
)); |
|||
} catch (IllegalArgumentException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
log.error("查看日志失败: {}", fileName, e); |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查看日志失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 下载日志文件(原始文件流) |
|||
* |
|||
* @param fileName 日志文件名 |
|||
*/ |
|||
@GetMapping("/download/{fileName}") |
|||
public ResponseEntity<Resource> downloadLog(@PathVariable String fileName) { |
|||
try { |
|||
java.nio.file.Path filePath = logViewerService.resolveSafe(fileName); |
|||
File file = filePath.toFile(); |
|||
FileSystemResource resource = new FileSystemResource(file); |
|||
|
|||
return ResponseEntity.ok() |
|||
.header(HttpHeaders.CONTENT_DISPOSITION, |
|||
"attachment; filename=\"" + file.getName() + "\"") |
|||
.contentType(MediaType.TEXT_PLAIN) |
|||
.contentLength(file.length()) |
|||
.body(resource); |
|||
} catch (IllegalArgumentException e) { |
|||
return ResponseEntity.badRequest().build(); |
|||
} catch (Exception e) { |
|||
log.error("下载日志失败: {}", fileName, e); |
|||
return ResponseEntity.status(500).build(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,237 @@ |
|||
package com.wok.supportbot.service; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.nio.file.Paths; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.time.format.DateTimeFormatter; |
|||
import java.util.*; |
|||
import java.util.stream.Collectors; |
|||
import java.util.stream.Stream; |
|||
|
|||
/** |
|||
* 日志文件查看服务 |
|||
* <p> |
|||
* 提供日志文件列表、尾部内容读取、关键词搜索等功能。 |
|||
* 仅用于生产运维排查问题,所有操作均限制在 logDir 目录内,防止路径遍历攻击。 |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
public class LogViewerService { |
|||
|
|||
/** 日志文件根目录,默认 ./logs */ |
|||
@Value("${logging.file.path:./logs}") |
|||
private String logDir; |
|||
|
|||
/** 日志文件扩展名白名单 */ |
|||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".log"); |
|||
/** 文件名允许的字符集(防止路径遍历) */ |
|||
private static final String FILE_NAME_REGEX = "^[a-zA-Z0-9._-]+$"; |
|||
/** 默认读取行数 */ |
|||
private static final int DEFAULT_LINES = 200; |
|||
/** 单次最大读取行数上限 */ |
|||
private static final int MAX_LINES = 5000; |
|||
|
|||
// ==================== 文件列表 ==================== |
|||
|
|||
/** |
|||
* 列出日志目录下所有 .log 文件(按修改时间倒序) |
|||
*/ |
|||
public List<Map<String, Object>> listFiles() { |
|||
Path logPath = getLogDir(); |
|||
if (!Files.exists(logPath)) { |
|||
return Collections.emptyList(); |
|||
} |
|||
|
|||
try (Stream<Path> stream = Files.list(logPath)) { |
|||
return stream |
|||
.filter(Files::isRegularFile) |
|||
.filter(p -> { |
|||
String name = p.getFileName().toString().toLowerCase(); |
|||
return ALLOWED_EXTENSIONS.stream().anyMatch(name::endsWith); |
|||
}) |
|||
.sorted((a, b) -> { |
|||
try { |
|||
return Files.getLastModifiedTime(b).compareTo(Files.getLastModifiedTime(a)); |
|||
} catch (IOException e) { |
|||
return 0; |
|||
} |
|||
}) |
|||
.map(p -> { |
|||
Map<String, Object> info = new LinkedHashMap<>(); |
|||
info.put("name", p.getFileName().toString()); |
|||
try { |
|||
info.put("size", Files.size(p)); |
|||
info.put("sizeFormatted", formatBytes(Files.size(p))); |
|||
info.put("lastModified", formatTime(Files.getLastModifiedTime(p).toInstant())); |
|||
} catch (IOException e) { |
|||
info.put("size", 0L); |
|||
info.put("sizeFormatted", "0 B"); |
|||
info.put("lastModified", ""); |
|||
} |
|||
return info; |
|||
}) |
|||
.collect(Collectors.toList()); |
|||
} catch (IOException e) { |
|||
log.error("列出日志文件失败", e); |
|||
return Collections.emptyList(); |
|||
} |
|||
} |
|||
|
|||
// ==================== 内容读取 ==================== |
|||
|
|||
/** |
|||
* 读取日志文件尾部指定行数 |
|||
* |
|||
* @param fileName 日志文件名(不含路径) |
|||
* @param lines 读取行数(上限 {@link #MAX_LINES}) |
|||
* @param keyword 搜索关键词(null 表示不过滤) |
|||
* @return content + totalLines + hasMore + fileSize |
|||
*/ |
|||
public Map<String, Object> readLogTail(String fileName, int lines, String keyword) { |
|||
Path filePath = resolveSafe(fileName); |
|||
|
|||
int actualLines = Math.min(Math.max(lines, 1), MAX_LINES); |
|||
|
|||
try { |
|||
List<String> allLines = tailFile(filePath, actualLines); |
|||
int totalMatched = allLines.size(); |
|||
|
|||
// 关键词过滤 |
|||
if (keyword != null && !keyword.isEmpty()) { |
|||
String kw = keyword; |
|||
allLines = allLines.stream() |
|||
.filter(l -> l.contains(kw)) |
|||
.collect(Collectors.toList()); |
|||
} |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("content", String.join("\n", allLines)); |
|||
result.put("fileSize", Files.size(filePath)); |
|||
result.put("fileSizeFormatted", formatBytes(Files.size(filePath))); |
|||
result.put("linesReturned", allLines.size()); |
|||
result.put("totalLinesInFile", totalMatched); |
|||
result.put("hasMore", totalMatched >= actualLines); |
|||
return result; |
|||
} catch (IOException e) { |
|||
log.error("读取日志文件失败: {}", fileName, e); |
|||
throw new RuntimeException("读取日志文件失败: " + e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
// ==================== 安全方法 ==================== |
|||
|
|||
/** |
|||
* 安全解析文件名,防止路径遍历攻击 |
|||
*/ |
|||
public Path resolveSafe(String fileName) { |
|||
// 1. 白名单校验文件名格式 |
|||
if (fileName == null || fileName.isEmpty()) { |
|||
throw new IllegalArgumentException("文件名不能为空"); |
|||
} |
|||
if (!fileName.matches(FILE_NAME_REGEX)) { |
|||
throw new IllegalArgumentException("文件名包含非法字符: " + fileName); |
|||
} |
|||
// 2. 扩展名白名单 |
|||
String lower = fileName.toLowerCase(); |
|||
if (ALLOWED_EXTENSIONS.stream().noneMatch(lower::endsWith)) { |
|||
throw new IllegalArgumentException("不支持的文件类型,仅允许 .log 文件"); |
|||
} |
|||
// 3. 解析并校验路径仍在 logDir 内 |
|||
Path base = getLogDir(); |
|||
Path resolved = base.resolve(fileName).toAbsolutePath().normalize(); |
|||
if (!resolved.startsWith(base)) { |
|||
throw new IllegalArgumentException("非法的文件路径"); |
|||
} |
|||
// 4. 必须存在 |
|||
if (!Files.exists(resolved)) { |
|||
throw new IllegalArgumentException("日志文件不存在: " + fileName); |
|||
} |
|||
return resolved; |
|||
} |
|||
|
|||
/** |
|||
* 获取日志根目录(确保目录存在) |
|||
*/ |
|||
public Path getLogDir() { |
|||
Path path = Paths.get(logDir).toAbsolutePath().normalize(); |
|||
// 目录不存在时返回但不创建(dev 环境 logs/ 可能不存在) |
|||
return path; |
|||
} |
|||
|
|||
// ==================== Tail 算法 ==================== |
|||
|
|||
/** |
|||
* 从文件尾部读取最后 N 行(类似 tail -n) |
|||
* <p> |
|||
* 使用 RandomAccessFile 从文件末尾逐字节回退定位换行符, |
|||
* 然后将定位到的字节块用 UTF-8 解码,正确处理中文。 |
|||
*/ |
|||
private List<String> tailFile(Path filePath, int maxLines) throws IOException { |
|||
byte[] fileBytes = Files.readAllBytes(filePath); |
|||
int len = fileBytes.length; |
|||
if (len == 0) { |
|||
return Collections.emptyList(); |
|||
} |
|||
|
|||
// 从尾部向前计数换行符,找到起始位置 |
|||
int end = len; |
|||
if (end > 0 && fileBytes[end - 1] == '\n') { |
|||
end--; // 跳过末尾换行符 |
|||
} |
|||
|
|||
int lineCount = 0; |
|||
int start = end; |
|||
while (start > 0 && lineCount < maxLines) { |
|||
start--; |
|||
if (fileBytes[start] == '\n') { |
|||
lineCount++; |
|||
if (lineCount >= maxLines) { |
|||
start++; // 跳过这个换行符,从下一行开始 |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 将 [start, end) 字节块用 UTF-8 解码 |
|||
byte[] block = Arrays.copyOfRange(fileBytes, start, end); |
|||
String text = new String(block, StandardCharsets.UTF_8); |
|||
|
|||
if (text.isEmpty()) { |
|||
return Collections.emptyList(); |
|||
} |
|||
|
|||
// 拆分为行 |
|||
String[] rawLines = text.split("\r?\n"); |
|||
List<String> lines = new ArrayList<>(Arrays.asList(rawLines)); |
|||
|
|||
// 只保留最后 maxLines 行 |
|||
if (lines.size() > maxLines) { |
|||
lines = lines.subList(lines.size() - maxLines, lines.size()); |
|||
} |
|||
|
|||
return lines; |
|||
} |
|||
|
|||
// ==================== 工具方法 ==================== |
|||
|
|||
private String formatBytes(long bytes) { |
|||
if (bytes < 1024) return bytes + " B"; |
|||
int exp = (int) (Math.log(bytes) / Math.log(1024)); |
|||
String unit = "KMGTPE".charAt(exp - 1) + "B"; |
|||
return String.format("%.1f %s", bytes / Math.pow(1024, exp), unit); |
|||
} |
|||
|
|||
private String formatTime(Instant instant) { |
|||
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) |
|||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<configuration> |
|||
<!-- 读取 application.yml 中的日志路径和格式 --> |
|||
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/> |
|||
<springProperty scope="context" name="CONSOLE_PATTERN" source="logging.pattern.console" |
|||
defaultValue="%d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n"/> |
|||
|
|||
<!-- ========== 控制台 appender(所有环境生效)========== --> |
|||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> |
|||
<encoder> |
|||
<pattern>${CONSOLE_PATTERN}</pattern> |
|||
<charset>UTF-8</charset> |
|||
</encoder> |
|||
</appender> |
|||
|
|||
<!-- ========== 文件 appender(仅 prod 环境生效)========== --> |
|||
<springProfile name="prod"> |
|||
<!-- 全量日志,按天滚动,保留 30 天 --> |
|||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> |
|||
<file>${LOG_PATH}/support-bot.log</file> |
|||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> |
|||
<fileNamePattern>${LOG_PATH}/support-bot.%d{yyyy-MM-dd}.log</fileNamePattern> |
|||
<maxHistory>30</maxHistory> |
|||
</rollingPolicy> |
|||
<encoder> |
|||
<pattern>${CONSOLE_PATTERN}</pattern> |
|||
<charset>UTF-8</charset> |
|||
</encoder> |
|||
</appender> |
|||
|
|||
<!-- ERROR 级别单独输出 --> |
|||
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender"> |
|||
<file>${LOG_PATH}/support-bot-error.log</file> |
|||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> |
|||
<level>ERROR</level> |
|||
</filter> |
|||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> |
|||
<fileNamePattern>${LOG_PATH}/support-bot-error.%d{yyyy-MM-dd}.log</fileNamePattern> |
|||
<maxHistory>30</maxHistory> |
|||
</rollingPolicy> |
|||
<encoder> |
|||
<pattern>${CONSOLE_PATTERN}</pattern> |
|||
<charset>UTF-8</charset> |
|||
</encoder> |
|||
</appender> |
|||
</springProfile> |
|||
|
|||
<!-- ========== root 级别配置 ========== --> |
|||
<root level="INFO"> |
|||
<appender-ref ref="CONSOLE"/> |
|||
<!-- FILE 和 FILE_ERROR 由 springProfile 控制,只有 prod 才生效 --> |
|||
<springProfile name="prod"> |
|||
<appender-ref ref="FILE"/> |
|||
<appender-ref ref="FILE_ERROR"/> |
|||
</springProfile> |
|||
</root> |
|||
</configuration> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue