From 2b34ba6cdb60bd429e35b469c6502ea006d1aae7 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 30 Jan 2026 19:43:11 +0800 Subject: [PATCH] 1 --- .../jarvis/TencentDocConfigController.java | 7 +- .../jarvis/TencentDocController.java | 94 ++++++++++--- .../controller/monitor/LogfileController.java | 125 ++++++++++++++++++ .../src/main/resources/application-dev.yml | 2 + .../src/main/resources/application-prod.yml | 2 + sql/menu_logfile.sql | 3 + 6 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/LogfileController.java create mode 100644 sql/menu_logfile.sql diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocConfigController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocConfigController.java index 35b1d84..01c1b0f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocConfigController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocConfigController.java @@ -42,6 +42,9 @@ public class TencentDocConfigController extends BaseController { // Redis key前缀(用于存储文档配置) private static final String REDIS_KEY_PREFIX = "tencent:doc:auto:config:"; + + /** Redis key前缀,用于存储上次处理的最大行数(与 TencentDocController 一致) */ + private static final String LAST_PROCESSED_ROW_KEY_PREFIX = "tendoc:last_row:"; /** * 获取当前配置 @@ -98,7 +101,7 @@ public class TencentDocConfigController extends BaseController { // 获取当前同步进度(如果有配置) // 注意:使用与 TencentDocController 相同的 Redis key 前缀 if (fileId != null && !fileId.isEmpty() && sheetId != null && !sheetId.isEmpty()) { - String syncProgressKey = "tendoc:last_row:" + fileId + ":" + sheetId; + String syncProgressKey = LAST_PROCESSED_ROW_KEY_PREFIX + fileId + ":" + sheetId; Integer currentProgress = redisCache.getCacheObject(syncProgressKey); log.debug("读取同步进度 - key: {}, value: {}", syncProgressKey, currentProgress); if (currentProgress != null) { @@ -211,7 +214,7 @@ public class TencentDocConfigController extends BaseController { // 清除该文档的同步进度(配置更新时重置进度,从新的startRow重新开始) // 注意:使用与 TencentDocController 相同的 Redis key 前缀 - String syncProgressKey = "tendoc:last_row:" + fileId.trim() + ":" + sheetId.trim(); + String syncProgressKey = LAST_PROCESSED_ROW_KEY_PREFIX + fileId.trim() + ":" + sheetId.trim(); String configVersionKey = "tencent:doc:sync:config_version:" + fileId.trim() + ":" + sheetId.trim(); redisCache.deleteObject(syncProgressKey); redisCache.deleteObject(configVersionKey); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java index b24a9a9..5b8be6f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TencentDocController.java @@ -1150,30 +1150,74 @@ public class TencentDocController extends BaseController { log.info("列位置识别完成 - 单号: {}, 物流单号: {}, 备注: {}, 是否安排: {}, 标记: {}, 下单电话: {}", orderNoColumn, logisticsLinkColumn, remarkColumn, arrangedColumn, markColumn, phoneColumn); - // 读取数据行 - // 使用 A1 表示法(Excel格式) - String range = String.format("A%d:Z%d", startRow, endRow); // 例如:A3:Z203 - log.info("开始读取数据行 - 行号: {} ~ {}, range: {}", startRow, endRow, range); - + // 读取数据行(空数据行可能导致接口异常,对 endRow 尝试 -1、-10 重试以保证业务执行成功) + int effectiveEndRow = endRow; JSONObject sheetData = null; - try { - sheetData = tencentDocService.readSheetData(accessToken, fileId, sheetId, range); - log.info("数据行读取成功,响应: {}", sheetData != null ? "有数据" : "null"); - } catch (Exception e) { - log.error("读取数据行失败", e); - return AjaxResult.error("读取数据行失败: " + e.getMessage()); + int[] retryDecrements = new int[] { 0, 1, 10 }; + for (int decrement : retryDecrements) { + int tryEndRow = Math.max(startRow, endRow - decrement); + if (tryEndRow < startRow) { + continue; + } + String range = String.format("A%d:Z%d", startRow, tryEndRow); + log.info("开始读取数据行 - 行号: {} ~ {}, range: {} (尝试 decrement={})", startRow, tryEndRow, range, decrement); + try { + sheetData = tencentDocService.readSheetData(accessToken, fileId, sheetId, range); + if (sheetData != null) { + effectiveEndRow = tryEndRow; + log.info("数据行读取成功,响应: 有数据 (使用 endRow={})", effectiveEndRow); + break; + } + } catch (Exception e) { + log.warn("读取数据行失败 (endRow={}, decrement={}),将重试更小范围: {}", tryEndRow, decrement, e.getMessage()); + if (decrement == retryDecrements[retryDecrements.length - 1]) { + log.error("读取数据行多次重试后仍失败", e); + return AjaxResult.error("读取数据行失败: " + e.getMessage()); + } + } } if (sheetData == null) { return AjaxResult.error("读取数据行返回null"); } - JSONArray values = sheetData.getJSONArray("values"); + JSONArray values = null; + try { + values = sheetData.getJSONArray("values"); + } catch (Exception e) { + log.warn("解析 values 异常,尝试将 endRow 缩小后重新读取: {}", e.getMessage()); + for (int decrement : new int[] { 1, 10 }) { + int tryEndRow = Math.max(startRow, effectiveEndRow - decrement); + if (tryEndRow >= startRow) { + String retryRange = String.format("A%d:Z%d", startRow, tryEndRow); + try { + sheetData = tencentDocService.readSheetData(accessToken, fileId, sheetId, retryRange); + if (sheetData != null) { + values = sheetData.getJSONArray("values"); + effectiveEndRow = tryEndRow; + break; + } + } catch (Exception ex) { + log.warn("重新读取 (endRow={}) 仍失败: {}", tryEndRow, ex.getMessage()); + } + } + } + } + if (values == null && sheetData != null) { + try { + values = sheetData.getJSONArray("values"); + } catch (Exception e) { + log.error("解析 values 失败", e); + return AjaxResult.error("解析数据行失败: " + e.getMessage()); + } + } + endRow = effectiveEndRow; // 后续统一使用实际读取成功的 endRow + String range = String.format("A%d:Z%d", startRow, endRow); log.info("解析后的数据行数: {}", values != null ? values.size() : "null"); if (values == null || values.isEmpty()) { log.warn("指定范围内没有数据,可能已处理完毕。range={}, sheetData keys={}", - range, sheetData.keySet()); + range, sheetData != null ? sheetData.keySet() : null); // 打印前10个键值对用于调试 if (sheetData != null && !sheetData.isEmpty()) { @@ -1215,7 +1259,18 @@ public class TencentDocController extends BaseController { JSONArray updates = new JSONArray(); // 存储需要更新的行和值 for (int i = 0; i < values.size(); i++) { - JSONArray row = values.getJSONArray(i); + JSONArray row; + try { + Object rowObj = values.get(i); + if (rowObj == null || !(rowObj instanceof JSONArray)) { + continue; // 非数组(空数据行等)跳过 + } + row = (JSONArray) rowObj; + } catch (Exception e) { + log.warn("解析第 {} 行数据异常,跳过(index={}): {}", startRow + i, i, e.getMessage()); + errorCount++; + continue; + } if (row == null || row.size() <= Math.max(orderNoColumn, logisticsLinkColumn)) { continue; // 跳过空行或列数不足的行 } @@ -1223,8 +1278,15 @@ public class TencentDocController extends BaseController { // 计算实际的行号(Excel行号,从1开始) int excelRow = startRow + i; - // 获取单号 - String orderNo = row.getString(orderNoColumn); + // 获取单号(空数据行可能导致 getString 异常,单行 try 保证整批继续执行) + String orderNo; + try { + orderNo = row.getString(orderNoColumn); + } catch (Exception e) { + log.warn("读取单号列异常,跳过行 {}: {}", excelRow, e.getMessage()); + errorCount++; + continue; + } if (orderNo == null || orderNo.trim().isEmpty()) { skippedCount++; continue; // 跳过空单号的行 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/LogfileController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/LogfileController.java new file mode 100644 index 0000000..8ca00c6 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/LogfileController.java @@ -0,0 +1,125 @@ +package com.ruoyi.web.controller.monitor; + +import com.ruoyi.common.core.domain.AjaxResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +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.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 日志文件查看(HTTPS 轮询读取最新内容,无需 WebSocket) + * + * @author system + */ +@RestController +@RequestMapping("/monitor/logfile") +public class LogfileController { + + private static final Logger log = LoggerFactory.getLogger(LogfileController.class); + + /** 允许读取的日志文件名(不含路径),与 logback 及实际日志目录一致 */ + private static final List ALLOWED_FILES = Collections.unmodifiableList( + Arrays.asList("all.log", "sys-info.log", "sys-error.log", "sys-user.log")); + + /** 默认读取行数 */ + private static final int DEFAULT_LINES = 500; + + /** 最大读取行数 */ + private static final int MAX_LINES = 5000; + + @Value("${ruoyi.logPath:/home/van/project/ruoyi-java/logs}") + private String logPath; + + /** + * 获取可选的日志文件列表 + */ + @PreAuthorize("@ss.hasPermi('monitor:server:list')") + @GetMapping("/list") + public AjaxResult list() { + List files = new ArrayList<>(ALLOWED_FILES); + return AjaxResult.success(files); + } + + /** + * 读取日志文件末尾 N 行(tail 语义),通过 HTTPS 返回最新内容 + * + * @param file 文件名,如 sys-info.log(必须在白名单内) + * @param lines 行数,默认 500,最大 5000 + */ + @PreAuthorize("@ss.hasPermi('monitor:server:list')") + @GetMapping("/tail") + public AjaxResult tail( + @RequestParam(value = "file", required = true) String file, + @RequestParam(value = "lines", required = false) Integer lines) { + if (file == null || file.trim().isEmpty()) { + return AjaxResult.error("参数 file 不能为空"); + } + // 只允许白名单内的文件名,防止路径穿越 + String fileName = file.trim(); + if (!ALLOWED_FILES.contains(fileName)) { + log.warn("拒绝非法日志文件请求: {}", fileName); + return AjaxResult.error("不允许读取该文件"); + } + int lineCount = lines != null && lines > 0 ? Math.min(lines, MAX_LINES) : DEFAULT_LINES; + + Path baseDir = Paths.get(logPath).normalize().toAbsolutePath(); + Path target = baseDir.resolve(fileName).normalize(); + if (!target.startsWith(baseDir)) { + log.warn("路径穿越被拒绝: {}", target); + return AjaxResult.error("非法路径"); + } + if (!Files.isRegularFile(target)) { + return AjaxResult.error("文件不存在或不可读: " + fileName); + } + + try { + List allLines = Files.readAllLines(target, StandardCharsets.UTF_8); + int total = allLines.size(); + int from = Math.max(0, total - lineCount); + String content = allLines.subList(from, total).stream() + .collect(Collectors.joining("\n")); + return AjaxResult.success(new TailResult(fileName, content, total, from + 1, total)); + } catch (IOException e) { + log.error("读取日志文件失败: {}", target, e); + return AjaxResult.error("读取失败: " + e.getMessage()); + } + } + + /** 返回体中的 data 结构 */ + public static class TailResult { + private final String file; + private final String content; + private final int totalLines; + private final int fromLine; + private final int toLine; + + public TailResult(String file, String content, int totalLines, int fromLine, int toLine) { + this.file = file; + this.content = content; + this.totalLines = totalLines; + this.fromLine = fromLine; + this.toLine = toLine; + } + + public String getFile() { return file; } + public String getContent() { return content; } + public int getTotalLines() { return totalLines; } + public int getFromLine() { return fromLine; } + public int getToLine() { return toLine; } + } +} diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 81a8c24..c496f13 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -8,6 +8,8 @@ ruoyi: copyrightYear: 2025 # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) profile: D:/ruoyi-java/uploadPath + # 日志目录(与 logback 中 log.path 一致,用于前端「日志查看」读取文件) + logPath: D:/ruoyi-java/logs # 获取ip地址开关 addressEnabled: false # 验证码类型 math 数字计算 char 字符验证 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 0dd67ae..1647fea 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -8,6 +8,8 @@ ruoyi: copyrightYear: 2025 # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) profile: /home/van/project/ruoyi-java/uploadPath + # 日志目录(与 logback 中 log.path 一致,用于前端「日志查看」读取文件) + logPath: /home/van/project/ruoyi-java/logs # 获取ip地址开关 addressEnabled: false # 验证码类型 math 数字计算 char 字符验证 diff --git a/sql/menu_logfile.sql b/sql/menu_logfile.sql new file mode 100644 index 0000000..bebd728 --- /dev/null +++ b/sql/menu_logfile.sql @@ -0,0 +1,3 @@ +-- 日志文件查看菜单(与「服务监控」同级,使用 monitor:server:list 权限) +-- 执行后可在「系统监控」下看到「日志文件」菜单,通过 HTTPS 轮询查看最新日志,无需 SSH +insert into sys_menu values('118', '日志文件', '2', '7', 'logfile', 'monitor/logfile/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'documentation', 'admin', sysdate(), '', null, '日志文件查看菜单');