From 433ea5a99552fdcd8376a81bb68fd9942610f009 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Mon, 15 Jun 2026 16:09:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E7=8B=AC=E7=AB=8B=E8=AE=A4=E8=AF=81=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 BaseConfig,认证信息并入 LogFileConfig.SystemItem - 每个系统独立配置用户名/密码/RSA公钥/网关地址/登录路径 - 支持 subPath 自定义每个系统的存储子目录 - 文件路径隔离:{path}/{subPath}/{serviceName} - 单个系统登录/下载失败不影响其他系统 Co-Authored-By: Claude --- CLAUDE.md | 77 +++ .../renkang/rkutils/config/BaseConfig.java | 16 - .../renkang/rkutils/config/LogFileConfig.java | 41 +- .../service/impl/LogFileServiceImpl.java | 485 ++++++++++-------- src/main/resources/app.yml | 31 +- 5 files changed, 400 insertions(+), 250 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 src/main/java/com/renkang/rkutils/config/BaseConfig.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3ffdfd5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +基于 **Solon 3.9.0** 框架的日志文件定时下载工具。通过远程网关 API 认证后,拉取多个微服务实例的日志文件(`.log` 和 `.gz`)到本地磁盘,并管理本地日志文件的清理与归档。 + +- Java 17,Maven 构建 +- 启动类:`com.renkang.rkutils.App` +- 配置文件:`src/main/resources/app.yml`(外挂 `conf/app-xj.yml`) + +## 构建与运行 + +```bash +# 编译打包 +mvn clean package -DskipTests + +# 直接运行(Solon Maven Plugin) +mvn solon:run +``` + +当前无测试用例。 + +## 架构概览 + +``` +App.java (入口, @SolonMain + @EnableScheduling) + ├── DownFileJob — 定时任务, cron: 0 30 * * * ? (每小时第30分钟) + └── LogFileService — 接口, 定义 downloadLogFile() + └── LogFileServiceImpl — 核心下载逻辑 + ├── BaseConfig — ${base}: username/password/publicKey/gatewayUrl + └── LogFileConfig — ${file}: path/services (name + insCount) +``` + +### 核心流程 (LogFileServiceImpl.downloadLogFile) + +1. **登录认证**:RSA 公钥加密密码 → POST `/sys/mLogin` → 获取 JWT token +2. **获取文件列表**:token 鉴权 → GET `/{serviceName}/service/log/files` → JSON 中取 `result.records` +3. **本地清理**:删除今日之前的 `.log` 文件;将不属于当前实例前缀的历史文件移入 `tmp/` 目录 +4. **下载文件**: + - `.gz` 压缩文件(已归档)→ 下载到 `{path}/{serviceName}/archive/`,已存在则跳过 + - `.log` 文件 → 下载到 `{path}/{serviceName}/`,与本地同名文件比大小:远程更大则覆盖,更小则新建序号文件(`.log.001` 类推) + +### 配置结构 + +```yaml +solon.config.load: # 外挂配置文件 +solon.logging.appender: # 控制台 + 文件日志 +solon.scheduling.job: # downFileJob 定时任务配置 +base: # 网关认证信息(BaseConfig) + username / password / publicKey / gatewayUrl +file: # 日志下载配置(LogFileConfig) + path: D:/xjlogs + services: + - name: jeecg-system + insCount: 2 +``` + +### 关键依赖 + +| 依赖 | 用途 | +|------|------| +| solon-lib | Solon 核心 | +| solon-scheduling-simple | `@Scheduled` 定时任务支持 | +| solon-logging-logback-jakarta | 日志框架(Logback) | +| solon-net-httputils | HTTP 客户端(`HttpUtils.http()`) | +| fastjson2 | JSON 解析 | +| lombok | 注解简化(`@Data`, `@Slf4j`) | +| solon-maven-plugin | Maven 打包/运行插件 | + +### 注意事项 + +- `BaseConfig` 中的密码通过 RSA 公钥加密后传输,`RSAUtil`(`util` 包)只提供公钥加密 +- `LogFileServiceImpl.countUniquePrefixes()` 统计文件名最后一个 `_` 前的部分作为实例前缀,用于判断是否有实例变更 +- 日志文件路径使用硬编码 `/` 分隔符,在 Windows 本地运行时 Java 会正确处理 +- `solon.config.load` 指向 `conf/app-xj.yml` 作为外挂配置覆盖,该文件不在仓库内 diff --git a/src/main/java/com/renkang/rkutils/config/BaseConfig.java b/src/main/java/com/renkang/rkutils/config/BaseConfig.java deleted file mode 100644 index 9eb2ddf..0000000 --- a/src/main/java/com/renkang/rkutils/config/BaseConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.renkang.rkutils.config; - -import lombok.Data; -import org.noear.solon.annotation.Configuration; -import org.noear.solon.annotation.Inject; - -@Configuration -@Inject("${base}") -@Data -public class BaseConfig { - private String username; - private String password; - private String gatewayUrl; - private String publicKey; - -} diff --git a/src/main/java/com/renkang/rkutils/config/LogFileConfig.java b/src/main/java/com/renkang/rkutils/config/LogFileConfig.java index 7207f3b..d9d2f13 100644 --- a/src/main/java/com/renkang/rkutils/config/LogFileConfig.java +++ b/src/main/java/com/renkang/rkutils/config/LogFileConfig.java @@ -1,25 +1,54 @@ package com.renkang.rkutils.config; -import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; import org.noear.solon.annotation.Configuration; import org.noear.solon.annotation.Inject; import java.util.List; +/** + * 日志文件下载配置,支持多系统独立认证与下载。 + */ @Configuration @Inject("${file}") @Data public class LogFileConfig { + /** 本地日志存储根路径 */ private String path; - private List services; + /** 多系统配置列表 */ + private List systems; + /** + * 单个系统配置,包含认证信息、登录接口路径及该系统的微服务列表。 + */ @Data - @AllArgsConstructor - @NoArgsConstructor - public static class ServiceItem { + public static class SystemItem { + /** 系统标识名称 */ private String name; + /** 本地存储子目录(相对于 file.path),不配置则默认使用 name */ + private String subPath; + /** 网关用户名 */ + private String username; + /** 网关密码(明文,传输时通过 RSA 公钥加密) */ + private String password; + /** RSA 公钥(Base64),用于加密密码 */ + private String publicKey; + /** 网关基础地址 */ + private String gatewayUrl; + /** 登录接口路径,如 /sys/mLogin */ + private String loginPath; + /** 该系统下的微服务列表 */ + private List services; + } + + /** + * 微服务配置项。 + */ + @Data + public static class ServiceItem { + /** 微服务名称(对应网关路由) */ + private String name; + /** 该微服务的实例数量 */ private int insCount; } } diff --git a/src/main/java/com/renkang/rkutils/service/impl/LogFileServiceImpl.java b/src/main/java/com/renkang/rkutils/service/impl/LogFileServiceImpl.java index ab112a9..77e552d 100644 --- a/src/main/java/com/renkang/rkutils/service/impl/LogFileServiceImpl.java +++ b/src/main/java/com/renkang/rkutils/service/impl/LogFileServiceImpl.java @@ -2,7 +2,6 @@ package com.renkang.rkutils.service.impl; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; -import com.renkang.rkutils.config.BaseConfig; import com.renkang.rkutils.config.LogFileConfig; import com.renkang.rkutils.service.LogFileService; import com.renkang.rkutils.vo.FileInfo; @@ -15,7 +14,6 @@ import org.noear.solon.net.http.HttpUtils; import util.RSAUtil; import java.io.File; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; @@ -25,227 +23,294 @@ import java.util.HashSet; import java.util.Objects; import java.util.Set; +/** + * 日志文件下载服务实现,支持多系统独立认证与下载。 + */ @Component @Slf4j public class LogFileServiceImpl implements LogFileService { @Inject private LogFileConfig logFileConfig; - @Inject - private BaseConfig baseConfig; @Override public void downloadLogFile() { String path = logFileConfig.getPath(); - log.info("开始下载日志文件: {}", path); + log.info("开始下载日志文件,存储路径: {}", path); - String username = baseConfig.getUsername(); - String password = baseConfig.getPassword(); - String gatewayUrl = baseConfig.getGatewayUrl(); - String publicKey = baseConfig.getPublicKey(); - try { - // 使用RSA公钥加密密码 - String encryptedPassword = RSAUtil.encryptByPublicKey(password, publicKey); + if (logFileConfig.getSystems() == null || logFileConfig.getSystems().isEmpty()) { + log.warn("未配置任何系统,跳过下载"); + return; + } - log.info("密码已加密"); - - JSONObject jsonObject = new JSONObject(); - jsonObject.put("username", username); - jsonObject.put("password", encryptedPassword); - // 发送登录请求,使用加密后的密码 - String response = HttpUtils.http(gatewayUrl + "/sys/mLogin") - .timeout(20) - .bodyOfJson(jsonObject.toJSONString()) - .post(); - JSONObject body = JSONObject.parseObject(response); - - log.info("登录返回: {}", body); - boolean success = body.getBoolean("success"); - if (!success) { - log.info("登录结果: {}", "失败"); - return; + // 外层循环:遍历每个系统,独立登录和下载 + for (LogFileConfig.SystemItem system : logFileConfig.getSystems()) { + String systemName = system.getName(); + log.info("========== 开始处理系统: {} ==========", systemName); + try { + downloadSystemLogs(system, path); + } catch (Exception e) { + log.error("处理系统[{}]时发生错误,继续下一个系统", systemName, e); + // 单个系统失败不影响其他系统 } - String token = body.getJSONObject("result").getString("token"); - log.info("登录成功,获取到的token为: {}", token); + } + log.info("所有系统日志下载任务完成"); + } - for (LogFileConfig.ServiceItem serviceItem : logFileConfig.getServices()) { - String serviceName = serviceItem.getName(); - int insCount = serviceItem.getInsCount(); - log.info("开始下载服务: {}", serviceName); - String downloadUrl = gatewayUrl + "/" + serviceName + "/service/log/files?service=" + serviceName; + /** + * 下载单个系统下所有微服务的日志文件。 + */ + private void downloadSystemLogs(LogFileConfig.SystemItem system, String rootPath) throws Exception { + String systemName = system.getName(); + // 存储子目录:优先用配置的 subPath,未配置则回退为系统名 + String subPath = system.getSubPath() != null && !system.getSubPath().isBlank() + ? system.getSubPath() : systemName; + String username = system.getUsername(); + String password = system.getPassword(); + String gatewayUrl = system.getGatewayUrl(); + String publicKey = system.getPublicKey(); + String loginPath = system.getLoginPath(); - try (HttpResponse resp = HttpUtils.http(downloadUrl).timeout(30).header("x-access-token", token).exec("GET")) { - int code = resp.code(); - if (code != 200) { - log.info("获取[{}]日志文件列表接口错误码: {}", serviceName, code); - continue; - } + // 使用该系统自己的 RSA 公钥加密密码 + String encryptedPassword = RSAUtil.encryptByPublicKey(password, publicKey); + log.info("[{}] 密码已加密", systemName); - String responseBody = resp.bodyAsString(); // 在流关闭前立即读取 - log.info("返回接口数据: {}", responseBody); - JSONObject files = JSONObject.parseObject(responseBody); + // 发送登录请求,使用该系统自己的 loginPath + JSONObject loginBody = new JSONObject(); + loginBody.put("username", username); + loginBody.put("password", encryptedPassword); - // 修复:使用正确的对象检查success状态 - boolean serSuccess = files.getBoolean("success"); - if (!serSuccess) { - log.info("获取日志文件结果: {}", "失败"); - continue; - } + String loginUrl = gatewayUrl + loginPath; + log.info("[{}] 登录地址: {}", systemName, loginUrl); + String response = HttpUtils.http(loginUrl) + .timeout(20) + .bodyOfJson(loginBody.toJSONString()) + .post(); + JSONObject body = JSONObject.parseObject(response); + log.info("[{}] 登录返回: {}", systemName, body); - JSONArray filesList = files.getJSONObject("result").getJSONArray("records"); - log.info("获取到的日志文件为: {}", filesList); + boolean success = body.getBoolean("success"); + if (!success) { + log.warn("[{}] 登录失败,跳过该系统", systemName); + return; + } - // 开始下载日志文件 - if (filesList != null && !filesList.isEmpty()) { - Set instanceSet = new HashSet<>(); - filesList.stream() - .filter(JSONObject.class::isInstance) - .map(JSONObject.class::cast) - .map(file -> file.getString("instance")) - .filter(Objects::nonNull) - .map(instance -> instance.replace(".", "_").replace(":", "_")) // 替换 . 和 : 为 _ - .forEach(instanceSet::add); - for (int i = 0; i < filesList.size(); i++) { - JSONObject file = filesList.getJSONObject(i); - String fileName = file.getString("fileName"); - String instance = file.getString("instance"); - String lastModified = file.getString("lastModified"); + String token = body.getJSONObject("result").getString("token"); + log.info("[{}] 登录成功,获取到 token: {}", systemName, token); - String filePath = path + "/" + serviceName; + // 遍历该系统下的微服务列表 + if (system.getServices() == null || system.getServices().isEmpty()) { + log.warn("[{}] 该系统未配置微服务,跳过", systemName); + return; + } - log.info("开始下载日志文件: {}/{}", filePath, fileName); + for (LogFileConfig.ServiceItem serviceItem : system.getServices()) { + String serviceName = serviceItem.getName(); + int insCount = serviceItem.getInsCount(); + // 文件存储路径:{rootPath}/{subPath}/{serviceName} + String servicePath = rootPath + "/" + subPath + "/" + serviceName; + log.info("[{}] 开始下载服务: {},存储目录: {}", systemName, serviceName, servicePath); - String downloadFileUrl = gatewayUrl + "/" + serviceName + "/service/log/file/down?filename=" - + fileName + "&instance=" + instance + "&service=" + serviceName; - - - String instanceStr = instance.replace(":", "_").replace(".", "_"); - File downFile = new File(filePath + "/" + instanceStr + "_" + fileName); - - - if (fileName.endsWith(".gz")) {//如果是压缩文件,说明已经归档压缩了,不会再改变,先查询一下本地是否存在,如果存在就跳过 - filePath = filePath + "/archive/";//压缩文件存放在archive目录下 - downFile = new File(filePath + "/" + instanceStr + "_" + fileName); - if (downFile.exists()) { - log.info("文件已存在,跳过下载: {}", downFile.getAbsolutePath()); - continue; - } - } else { - //判断一下所有log文件更新日期,将小于今天的全部删除 - File[] logFiles = new File(filePath).listFiles((dir, name) -> - name.endsWith(".log") || name.matches(".*\\.log\\.\\d{3}") - ); - if (logFiles != null) { - // 提前创建 /tmp/ 目录,避免重复创建 - File tmpBaseDir = new File(filePath + "/tmp/"); - if (!tmpBaseDir.exists()) { - tmpBaseDir.mkdirs(); - } - for (File logFile : logFiles) { - try { - long lastModifiedTime = logFile.lastModified(); - String lastDate = DateUtil.format(new Date(lastModifiedTime), "yyyy-MM-dd"); - String haveFileName = logFile.getName(); - // 判断是否需要移动文件(实例变化) - // 判断 haveFileName 是否以 instanceSet 中任意一个实例开头 - boolean isMatched = instanceSet.stream().anyMatch(haveFileName::startsWith); - int prefixesCount = countUniquePrefixes(filePath);//查询文件夹下有多少个不同前缀 - if (!isMatched && prefixesCount > insCount) { - File mvDir = new File(tmpBaseDir, lastDate); - if (!mvDir.exists()) { - mvDir.mkdirs(); - } - Files.move(logFile.toPath(), mvDir.toPath().resolve(haveFileName), StandardCopyOption.REPLACE_EXISTING); - log.info("文件已移动至临时目录: {}", mvDir.toPath().resolve(haveFileName)); - continue; // 已移动的文件无需进一步处理 - } - if (lastModifiedTime < getTodayStartTime()) { - log.info("删除过期文件: {}", logFile.getAbsolutePath()); - logFile.delete(); - } - } catch (IOException e) { - log.error("处理文件时发生错误: {}", logFile.getAbsolutePath(), e); - } - } - } - } - - // 在新的try-with-resources块中处理文件下载 - try (HttpResponse downloadRes = HttpUtils.http(downloadFileUrl) - .header("x-access-token", token) - .header("Content-Type", "application/octet-stream") - .timeout(60).exec("GET")) { - - int downloadCode = downloadRes.code(); - if (downloadCode != 200) { - log.info("下载[{}]日志文件接口错误码: {}", serviceName, downloadCode); - continue; - } - - // 立即读取字节数据,在流关闭前完成 - byte[] bytes = downloadRes.bodyAsBytes(); - log.info("下载文件大小: {}", bytes.length); - - - // 创建目录 - File downFileDir = new File(filePath); - if (!downFileDir.exists()) { - downFileDir.mkdirs(); // 修复:使用mkdirs()创建多级目录 - } - - // 确保目录存在后再创建文件 - if (!downFile.getParentFile().exists()) { - downFile.getParentFile().mkdirs(); - } - //当天的文件.log结尾,如果有存在的文件,文件名加序号 - if (fileName.endsWith(".log")) { - // 获取基础文件名(不包含序号部分) - String baseFileName = getBaseFileNameWithoutSequence(downFile.getName()); - // 查找目标目录中所有匹配的基础文件名 - File parentDir = downFile.getParentFile(); - - FileInfo fileInfo = this.getFileInfo(parentDir, baseFileName); - - //判断文件大小,如果本地文件大于线上文件,需要下载并重命名;如果本地文件小于线上文件,直接下载替换(本地文件以最大序号为准) - long localFileSize = fileInfo.getFileSize(); - if (localFileSize == bytes.length) { - log.info("文件已存在,跳过下载: {}", downFile.getAbsolutePath()); - continue; - } - if (localFileSize > bytes.length) { - downFile = new File(parentDir, fileInfo.getNextFileName()); - } else { - downFile = new File(parentDir, fileInfo.getCurrentMaxFileName()); - } - } - - // 写入文件 - try (FileOutputStream fos = new FileOutputStream(downFile)) { - fos.write(bytes); - log.info("成功下载文件: {}", downFile.getAbsolutePath()); - } - } - } - } - } // 外层try-with-resources结束 - } - } catch (Exception e) { - log.error("下载日志文件过程中发生错误: ", e); - throw new RuntimeException("下载日志文件失败", e); + downloadServiceLogs(gatewayUrl, token, systemName, serviceName, insCount, servicePath); } } /** - * 获取基础文件名(去除序列号部分) + * 下载单个微服务的日志文件。 + */ + private void downloadServiceLogs(String gatewayUrl, String token, String systemName, + String serviceName, int insCount, String filePath) { + String downloadUrl = gatewayUrl + "/" + serviceName + "/service/log/files?service=" + serviceName; + log.info("[{}] 获取文件列表地址: {}", systemName, downloadUrl); + + try (HttpResponse resp = HttpUtils.http(downloadUrl) + .timeout(30) + .header("x-access-token", token) + .exec("GET")) { + + int code = resp.code(); + if (code != 200) { + log.warn("[{}] 获取[{}]日志文件列表接口错误码: {}", systemName, serviceName, code); + return; + } + + String responseBody = resp.bodyAsString(); + log.info("[{}] 返回接口数据: {}", systemName, responseBody); + JSONObject files = JSONObject.parseObject(responseBody); + + boolean serSuccess = files.getBoolean("success"); + if (!serSuccess) { + log.warn("[{}] 获取[{}]日志文件结果失败", systemName, serviceName); + return; + } + + JSONArray filesList = files.getJSONObject("result").getJSONArray("records"); + log.info("[{}] 获取到的日志文件为: {}", systemName, filesList); + + if (filesList == null || filesList.isEmpty()) { + log.info("[{}] [{}] 无日志文件需要下载", systemName, serviceName); + return; + } + + // 收集当前实例集合(用于后续清理判断) + Set instanceSet = new HashSet<>(); + filesList.stream() + .filter(JSONObject.class::isInstance) + .map(JSONObject.class::cast) + .map(file -> file.getString("instance")) + .filter(Objects::nonNull) + .map(instance -> instance.replace(".", "_").replace(":", "_")) + .forEach(instanceSet::add); + + // 本地清理非 .gz 文件 + cleanLocalLogs(filePath, insCount, instanceSet); + + // 逐文件下载 + for (int i = 0; i < filesList.size(); i++) { + JSONObject file = filesList.getJSONObject(i); + String fileName = file.getString("fileName"); + String instance = file.getString("instance"); + + log.info("[{}] 开始下载日志文件: {}/{}", systemName, filePath, fileName); + + String downloadFileUrl = gatewayUrl + "/" + serviceName + + "/service/log/file/down?filename=" + fileName + + "&instance=" + instance + "&service=" + serviceName; + + String instanceStr = instance.replace(":", "_").replace(".", "_"); + File downFile; + + if (fileName.endsWith(".gz")) { + // 压缩归档文件,存放在 archive 目录 + String archivePath = filePath + "/archive/"; + downFile = new File(archivePath + instanceStr + "_" + fileName); + if (downFile.exists()) { + log.info("[{}] 文件已存在,跳过下载: {}", systemName, downFile.getAbsolutePath()); + continue; + } + } else { + downFile = new File(filePath + "/" + instanceStr + "_" + fileName); + } + + // 下载文件 + downloadSingleFile(downloadFileUrl, token, systemName, downFile, fileName); + } + } catch (Exception e) { + log.error("[{}] 处理服务[{}]时发生错误", systemName, serviceName, e); + } + } + + /** + * 清理本地过期/无效日志文件。 + */ + private void cleanLocalLogs(String filePath, int insCount, Set instanceSet) { + File[] logFiles = new File(filePath).listFiles((dir, name) -> + name.endsWith(".log") || name.matches(".*\\.log\\.\\d{3}") + ); + if (logFiles == null) { + return; + } + + // 提前创建 tmp 目录 + File tmpBaseDir = new File(filePath + "/tmp/"); + if (!tmpBaseDir.exists()) { + tmpBaseDir.mkdirs(); + } + + for (File logFile : logFiles) { + try { + long lastModifiedTime = logFile.lastModified(); + String lastDate = DateUtil.format(new Date(lastModifiedTime), "yyyy-MM-dd"); + String haveFileName = logFile.getName(); + + // 判断是否需要将过期实例的文件移入 tmp + boolean isMatched = instanceSet.stream().anyMatch(haveFileName::startsWith); + int prefixesCount = countUniquePrefixes(filePath); + if (!isMatched && prefixesCount > insCount) { + File mvDir = new File(tmpBaseDir, lastDate); + if (!mvDir.exists()) { + mvDir.mkdirs(); + } + Files.move(logFile.toPath(), mvDir.toPath().resolve(haveFileName), + StandardCopyOption.REPLACE_EXISTING); + log.info("文件已移动至临时目录: {}", mvDir.toPath().resolve(haveFileName)); + continue; + } + + // 删除今日之前的文件 + if (lastModifiedTime < getTodayStartTime()) { + log.info("删除过期文件: {}", logFile.getAbsolutePath()); + logFile.delete(); + } + } catch (IOException e) { + log.error("处理文件时发生错误: {}", logFile.getAbsolutePath(), e); + } + } + } + + /** + * 下载并保存单个日志文件,处理本地同名文件的序号逻辑。 + */ + private void downloadSingleFile(String downloadFileUrl, String token, String systemName, + File downFile, String fileName) { + try (HttpResponse downloadRes = HttpUtils.http(downloadFileUrl) + .header("x-access-token", token) + .header("Content-Type", "application/octet-stream") + .timeout(60).exec("GET")) { + + int downloadCode = downloadRes.code(); + if (downloadCode != 200) { + log.warn("[{}] 下载文件接口错误码: {}", systemName, downloadCode); + return; + } + + byte[] bytes = downloadRes.bodyAsBytes(); + log.info("[{}] 下载文件大小: {} bytes", systemName, bytes.length); + + // 确保目录存在 + File downFileDir = downFile.getParentFile(); + if (!downFileDir.exists()) { + downFileDir.mkdirs(); + } + + // .log 文件处理序号逻辑 + if (fileName.endsWith(".log")) { + String baseFileName = getBaseFileNameWithoutSequence(downFile.getName()); + File parentDir = downFile.getParentFile(); + FileInfo fileInfo = this.getFileInfo(parentDir, baseFileName); + + long localFileSize = fileInfo.getFileSize(); + if (localFileSize == bytes.length) { + log.info("[{}] 文件已存在且大小相同,跳过下载: {}", systemName, downFile.getAbsolutePath()); + return; + } + if (localFileSize > bytes.length) { + // 本地更大 → 新文件用下一个序号 + downFile = new File(parentDir, fileInfo.getNextFileName()); + } else { + // 远程更大 → 覆盖当前最大序号文件 + downFile = new File(parentDir, fileInfo.getCurrentMaxFileName()); + } + } + + // 写入文件 + try (FileOutputStream fos = new FileOutputStream(downFile)) { + fos.write(bytes); + log.info("[{}] 成功下载文件: {}", systemName, downFile.getAbsolutePath()); + } + } catch (Exception e) { + log.error("[{}] 下载文件失败: {}", systemName, downloadFileUrl, e); + } + } + + /** + * 获取基础文件名(去除序号部分,如 .log.001 → .log)。 */ private String getBaseFileNameWithoutSequence(String fileName) { - // 检查是否包含序号格式(如 .log.001) if (fileName.contains(".")) { String[] parts = fileName.split("\\."); if (parts.length >= 3) { - // 检查倒数第二个部分是否为数字 try { Integer.parseInt(parts[parts.length - 1]); - // 返回去掉序号的部分 StringBuilder baseName = new StringBuilder(); for (int i = 0; i < parts.length - 1; i++) { if (i > 0) baseName.append("."); @@ -253,7 +318,6 @@ public class LogFileServiceImpl implements LogFileService { } return baseName.toString(); } catch (NumberFormatException e) { - // 不是数字,说明没有序号,返回原始名称 return fileName; } } @@ -262,7 +326,7 @@ public class LogFileServiceImpl implements LogFileService { } /** - * 获取下一个可用的序号 + * 获取下一个可用序号。 */ private int getNextAvailableSequence(File directory, String baseFileName) { File[] existingFiles = directory.listFiles((dir, name) -> @@ -270,19 +334,16 @@ public class LogFileServiceImpl implements LogFileService { (name.equals(baseFileName) || name.matches(baseFileName + "\\.\\d{3}"))); if (existingFiles == null) { - return 1; // 目录不存在或无法访问 + return 1; } int maxSequence = 0; - for (File file : existingFiles) { String fileName = file.getName(); if (fileName.equals(baseFileName)) { - // 基础文件存在,从1开始 maxSequence = Math.max(maxSequence, 1); } else if (fileName.matches(baseFileName + "\\.\\d{3}")) { - // 提取序号 - String sequencePart = fileName.substring(baseFileName.length() + 1); // +1 for the dot + String sequencePart = fileName.substring(baseFileName.length() + 1); try { int sequence = Integer.parseInt(sequencePart); maxSequence = Math.max(maxSequence, sequence + 1); @@ -291,15 +352,14 @@ public class LogFileServiceImpl implements LogFileService { } } } - return maxSequence; } - + /** + * 获取今天零点的时间戳。 + */ private long getTodayStartTime() { - // 获取当前时间 long currentTime = System.currentTimeMillis(); - // 计算今天0点的时间 java.util.Calendar calendar = java.util.Calendar.getInstance(); calendar.setTimeInMillis(currentTime); calendar.set(java.util.Calendar.HOUR_OF_DAY, 0); @@ -310,43 +370,33 @@ public class LogFileServiceImpl implements LogFileService { } /** - * 获取当前最大序号的文件名、下一个序号的文件名以及最大序号文件的大小 - * - * @param directory 目标目录 - * @param baseFileName 基础文件名(不含序号) - * @return FileInfo 包含当前最大序号文件名、下一个序号文件名和文件大小的信息 + * 获取当前最大序号文件的信息。 */ private FileInfo getFileInfo(File directory, String baseFileName) { - // 获取最大序号 int maxSequence = getNextAvailableSequence(directory, baseFileName) - 1; - // 构建当前最大序号文件名 String currentMaxFileName; if (maxSequence <= 0) { - // 没有序号文件,直接使用基础文件名 currentMaxFileName = baseFileName; } else { - // 存在序号文件,拼接序号 currentMaxFileName = baseFileName + "." + String.format("%03d", maxSequence); } - // 构建下一个序号文件名 int nextSequence = maxSequence + 1; String nextFileName = baseFileName + "." + String.format("%03d", nextSequence); - // 构造当前最大序号文件对象 File maxSequenceFile = new File(directory, currentMaxFileName); - - // 检查文件是否存在并获取大小 long fileSize = 0; if (maxSequenceFile.exists()) { fileSize = maxSequenceFile.length(); } - // 返回封装好的信息 return new FileInfo(currentMaxFileName, nextFileName, fileSize); } + /** + * 统计目录下文件名最后一个 _ 前的不同前缀数量(用于判断实例是否变更)。 + */ public static int countUniquePrefixes(String directoryPath) { File directory = new File(directoryPath); if (!directory.exists() || !directory.isDirectory()) { @@ -360,7 +410,6 @@ public class LogFileServiceImpl implements LogFileService { if (files != null) { for (File file : files) { String fileName = file.getName(); - // 提取最后一个 _ 前面的部分作为前缀 int lastUnderscoreIndex = fileName.lastIndexOf('_'); if (lastUnderscoreIndex != -1) { String prefix = fileName.substring(0, lastUnderscoreIndex); @@ -368,8 +417,6 @@ public class LogFileServiceImpl implements LogFileService { } } } - -// log.info("唯一前缀数量: " + prefixSet.size()); return prefixSet.size(); } } diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml index 7fffe94..d7a52d9 100644 --- a/src/main/resources/app.yml +++ b/src/main/resources/app.yml @@ -21,15 +21,28 @@ solon.scheduling.job: initialDelay: 0 enable: true #用任务进行启停控制 -base: - username: admin - password: "#fCjW%x2dv@a" - publicKey: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB" - gatewayUrl: https://api-jkglpt.iosp.ydpt.tech -# gatewayUrl: https://xj-api.mcrm.vip:8888 +# 多系统日志下载配置 file: path: D:/xjlogs - services: - - name: health-system - insCount: 2 + systems: + - name: 健康管理系统 + subPath: health # 自定义存储子目录(相对于 file.path),不配则默认用 name + username: admin + password: "#fCjW%x2dv@a" + publicKey: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB" + gatewayUrl: https://api-jkglpt.iosp.ydpt.tech + loginPath: /sys/mLogin + services: + - name: health-system + insCount: 2 + - name: 健康监测系统 + subPath: datacenter # 自定义存储子目录(相对于 file.path),不配则默认用 name + username: admin + password: Aa135790!123 + publicKey: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbc+/BMkhCHZ5RDj6r4TXsFQJwP7HUd8qq+6wXo1KuvE6B1/9JmM7pNThmnkebSa+yP1ECa/H/QCSSKKHBb1JUeHKwUymvxVd78DPOanshqiAKSf+1bKx4g1OMT/5DPTn5Dg0lH5t4wvnUIpWM/7XH0Y1NWsgF5r8C90I3QcBkOwIDAQAB" + gatewayUrl: https://dcapi-jkglpt.iosp.ydpt.tech + loginPath: /sys/getToken + services: + - name: jeecg-system + insCount: 1 # - health-watch \ No newline at end of file