diff --git a/.gitignore b/.gitignore index 7c1fbfd..52fa845 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ build/ /.claude/ /src/main/resources/static/assets/ /src/main/resources/static/index.html +/logs/ diff --git a/frontend/src/api/log.ts b/frontend/src/api/log.ts new file mode 100644 index 0000000..882cb55 --- /dev/null +++ b/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> { + 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> { + let path = `/log/view/${encodeURIComponent(fileName)}?lines=${lines}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + return request.get(path).then(r => r.data) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 785613a..00fa3b8 100644 --- a/frontend/src/router/index.ts +++ b/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' }, ] diff --git a/frontend/src/stores/navigation.ts b/frontend/src/stores/navigation.ts index ddfdd78..138dc26 100644 --- a/frontend/src/stores/navigation.ts +++ b/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'] }, ], }, ] diff --git a/frontend/src/views/LogViewer.vue b/frontend/src/views/LogViewer.vue new file mode 100644 index 0000000..abcf7e3 --- /dev/null +++ b/frontend/src/views/LogViewer.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/src/main/java/com/wok/supportbot/controller/LogController.java b/src/main/java/com/wok/supportbot/controller/LogController.java new file mode 100644 index 0000000..9c805f1 --- /dev/null +++ b/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; + +/** + * 系统日志查看接口 + *

+ * 提供日志文件列表、尾部内容查看、关键词搜索及文件下载功能。 + * 仅 admin 角色可访问,所有文件操作限制在日志目录内,防止路径遍历攻击。 + */ +@Slf4j +@RestController +@RequestMapping("/log") +@PreAuthorize("hasRole('admin')") +public class LogController { + + @Autowired + private LogViewerService logViewerService; + + /** + * 列出所有日志文件(名称、大小、修改时间等) + */ + @GetMapping("/files") + public ResponseEntity> listFiles() { + try { + List> 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> viewLog( + @PathVariable String fileName, + @RequestParam(defaultValue = "200") int lines, + @RequestParam(required = false) String keyword) { + try { + Map 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 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(); + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/LogViewerService.java b/src/main/java/com/wok/supportbot/service/LogViewerService.java new file mode 100644 index 0000000..f68c211 --- /dev/null +++ b/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; + +/** + * 日志文件查看服务 + *

+ * 提供日志文件列表、尾部内容读取、关键词搜索等功能。 + * 仅用于生产运维排查问题,所有操作均限制在 logDir 目录内,防止路径遍历攻击。 + */ +@Slf4j +@Service +public class LogViewerService { + + /** 日志文件根目录,默认 ./logs */ + @Value("${logging.file.path:./logs}") + private String logDir; + + /** 日志文件扩展名白名单 */ + private static final Set 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> listFiles() { + Path logPath = getLogDir(); + if (!Files.exists(logPath)) { + return Collections.emptyList(); + } + + try (Stream 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 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 readLogTail(String fileName, int lines, String keyword) { + Path filePath = resolveSafe(fileName); + + int actualLines = Math.min(Math.max(lines, 1), MAX_LINES); + + try { + List 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 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) + *

+ * 使用 RandomAccessFile 从文件末尾逐字节回退定位换行符, + * 然后将定位到的字节块用 UTF-8 解码,正确处理中文。 + */ + private List 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 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")); + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e0dbdaa..b1b9aa8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -40,6 +40,8 @@ knowledge: # ==================== 日志级别 ==================== # 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO logging: + file: + path: ./logs level: root: INFO com.wok.supportbot: INFO diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..5f59204 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,57 @@ + + + + + + + + + + ${CONSOLE_PATTERN} + UTF-8 + + + + + + + + ${LOG_PATH}/support-bot.log + + ${LOG_PATH}/support-bot.%d{yyyy-MM-dd}.log + 30 + + + ${CONSOLE_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/support-bot-error.log + + ERROR + + + ${LOG_PATH}/support-bot-error.%d{yyyy-MM-dd}.log + 30 + + + ${CONSOLE_PATTERN} + UTF-8 + + + + + + + + + + + + + +