Browse Source

新增日志查看功能

TDesign-Vue-Next-1.20.6
wanghanlin 3 weeks ago
parent
commit
149dc3270c
  1. 1
      .gitignore
  2. 43
      frontend/src/api/log.ts
  3. 1
      frontend/src/router/index.ts
  4. 1
      frontend/src/stores/navigation.ts
  5. 460
      frontend/src/views/LogViewer.vue
  6. 110
      src/main/java/com/wok/supportbot/controller/LogController.java
  7. 237
      src/main/java/com/wok/supportbot/service/LogViewerService.java
  8. 2
      src/main/resources/application-prod.yml
  9. 57
      src/main/resources/logback-spring.xml

1
.gitignore

@ -43,3 +43,4 @@ build/
/.claude/
/src/main/resources/static/assets/
/src/main/resources/static/index.html
/logs/

43
frontend/src/api/log.ts

@ -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)
}

1
frontend/src/router/index.ts

@ -33,6 +33,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/settings/mcp-server', name: 'McpServer', component: () => import('@/views/McpServerManager.vue') },
{ path: '/settings/pipeline-flow', name: 'PipelineFlow', component: () => import('@/views/PipelineFlow.vue') },
{ path: '/settings/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.vue') },
{ path: '/settings/log-viewer', name: 'LogViewer', component: () => import('@/views/LogViewer.vue') },
// ==================== 兜底 ====================
{ path: '/:pathMatch(.*)*', redirect: '/chat' },
]

1
frontend/src/stores/navigation.ts

@ -30,6 +30,7 @@ export const MENU_ITEMS = [
{ id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', path: '/settings/mcp-server', roles: ['admin'] },
{ id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' },
{ id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] },
{ id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] },
],
},
]

460
frontend/src/views/LogViewer.vue

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
// 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>

110
src/main/java/com/wok/supportbot/controller/LogController.java

@ -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();
}
}
}

237
src/main/java/com/wok/supportbot/service/LogViewerService.java

@ -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"));
}
}

2
src/main/resources/application-prod.yml

@ -40,6 +40,8 @@ knowledge:
# ==================== 日志级别 ====================
# 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO
logging:
file:
path: ./logs
level:
root: INFO
com.wok.supportbot: INFO

57
src/main/resources/logback-spring.xml

@ -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>
Loading…
Cancel
Save