feat: 支持多系统独立认证与日志下载
- 删除 BaseConfig,认证信息并入 LogFileConfig.SystemItem
- 每个系统独立配置用户名/密码/RSA公钥/网关地址/登录路径
- 支持 subPath 自定义每个系统的存储子目录
- 文件路径隔离:{path}/{subPath}/{serviceName}
- 单个系统登录/下载失败不影响其他系统
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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` 作为外挂配置覆盖,该文件不在仓库内
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,54 @@
|
|||||||
package com.renkang.rkutils.config;
|
package com.renkang.rkutils.config;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.noear.solon.annotation.Configuration;
|
import org.noear.solon.annotation.Configuration;
|
||||||
import org.noear.solon.annotation.Inject;
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志文件下载配置,支持多系统独立认证与下载。
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Inject("${file}")
|
@Inject("${file}")
|
||||||
@Data
|
@Data
|
||||||
public class LogFileConfig {
|
public class LogFileConfig {
|
||||||
|
/** 本地日志存储根路径 */
|
||||||
private String path;
|
private String path;
|
||||||
private List<ServiceItem> services;
|
/** 多系统配置列表 */
|
||||||
|
private List<SystemItem> systems;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个系统配置,包含认证信息、登录接口路径及该系统的微服务列表。
|
||||||
|
*/
|
||||||
@Data
|
@Data
|
||||||
@AllArgsConstructor
|
public static class SystemItem {
|
||||||
@NoArgsConstructor
|
/** 系统标识名称 */
|
||||||
public static class ServiceItem {
|
|
||||||
private String name;
|
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<ServiceItem> services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微服务配置项。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class ServiceItem {
|
||||||
|
/** 微服务名称(对应网关路由) */
|
||||||
|
private String name;
|
||||||
|
/** 该微服务的实例数量 */
|
||||||
private int insCount;
|
private int insCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.renkang.rkutils.service.impl;
|
|||||||
|
|
||||||
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.renkang.rkutils.config.BaseConfig;
|
|
||||||
import com.renkang.rkutils.config.LogFileConfig;
|
import com.renkang.rkutils.config.LogFileConfig;
|
||||||
import com.renkang.rkutils.service.LogFileService;
|
import com.renkang.rkutils.service.LogFileService;
|
||||||
import com.renkang.rkutils.vo.FileInfo;
|
import com.renkang.rkutils.vo.FileInfo;
|
||||||
@@ -15,7 +14,6 @@ import org.noear.solon.net.http.HttpUtils;
|
|||||||
import util.RSAUtil;
|
import util.RSAUtil;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -25,227 +23,294 @@ import java.util.HashSet;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志文件下载服务实现,支持多系统独立认证与下载。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LogFileServiceImpl implements LogFileService {
|
public class LogFileServiceImpl implements LogFileService {
|
||||||
@Inject
|
@Inject
|
||||||
private LogFileConfig logFileConfig;
|
private LogFileConfig logFileConfig;
|
||||||
@Inject
|
|
||||||
private BaseConfig baseConfig;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void downloadLogFile() {
|
public void downloadLogFile() {
|
||||||
String path = logFileConfig.getPath();
|
String path = logFileConfig.getPath();
|
||||||
log.info("开始下载日志文件: {}", path);
|
log.info("开始下载日志文件,存储路径: {}", path);
|
||||||
|
|
||||||
String username = baseConfig.getUsername();
|
if (logFileConfig.getSystems() == null || logFileConfig.getSystems().isEmpty()) {
|
||||||
String password = baseConfig.getPassword();
|
log.warn("未配置任何系统,跳过下载");
|
||||||
String gatewayUrl = baseConfig.getGatewayUrl();
|
return;
|
||||||
String publicKey = baseConfig.getPublicKey();
|
}
|
||||||
try {
|
|
||||||
// 使用RSA公钥加密密码
|
|
||||||
String encryptedPassword = RSAUtil.encryptByPublicKey(password, publicKey);
|
|
||||||
|
|
||||||
log.info("密码已加密");
|
// 外层循环:遍历每个系统,独立登录和下载
|
||||||
|
for (LogFileConfig.SystemItem system : logFileConfig.getSystems()) {
|
||||||
JSONObject jsonObject = new JSONObject();
|
String systemName = system.getName();
|
||||||
jsonObject.put("username", username);
|
log.info("========== 开始处理系统: {} ==========", systemName);
|
||||||
jsonObject.put("password", encryptedPassword);
|
try {
|
||||||
// 发送登录请求,使用加密后的密码
|
downloadSystemLogs(system, path);
|
||||||
String response = HttpUtils.http(gatewayUrl + "/sys/mLogin")
|
} catch (Exception e) {
|
||||||
.timeout(20)
|
log.error("处理系统[{}]时发生错误,继续下一个系统", systemName, e);
|
||||||
.bodyOfJson(jsonObject.toJSONString())
|
// 单个系统失败不影响其他系统
|
||||||
.post();
|
|
||||||
JSONObject body = JSONObject.parseObject(response);
|
|
||||||
|
|
||||||
log.info("登录返回: {}", body);
|
|
||||||
boolean success = body.getBoolean("success");
|
|
||||||
if (!success) {
|
|
||||||
log.info("登录结果: {}", "失败");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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);
|
private void downloadSystemLogs(LogFileConfig.SystemItem system, String rootPath) throws Exception {
|
||||||
String downloadUrl = gatewayUrl + "/" + serviceName + "/service/log/files?service=" + serviceName;
|
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")) {
|
// 使用该系统自己的 RSA 公钥加密密码
|
||||||
int code = resp.code();
|
String encryptedPassword = RSAUtil.encryptByPublicKey(password, publicKey);
|
||||||
if (code != 200) {
|
log.info("[{}] 密码已加密", systemName);
|
||||||
log.info("获取[{}]日志文件列表接口错误码: {}", serviceName, code);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String responseBody = resp.bodyAsString(); // 在流关闭前立即读取
|
// 发送登录请求,使用该系统自己的 loginPath
|
||||||
log.info("返回接口数据: {}", responseBody);
|
JSONObject loginBody = new JSONObject();
|
||||||
JSONObject files = JSONObject.parseObject(responseBody);
|
loginBody.put("username", username);
|
||||||
|
loginBody.put("password", encryptedPassword);
|
||||||
|
|
||||||
// 修复:使用正确的对象检查success状态
|
String loginUrl = gatewayUrl + loginPath;
|
||||||
boolean serSuccess = files.getBoolean("success");
|
log.info("[{}] 登录地址: {}", systemName, loginUrl);
|
||||||
if (!serSuccess) {
|
String response = HttpUtils.http(loginUrl)
|
||||||
log.info("获取日志文件结果: {}", "失败");
|
.timeout(20)
|
||||||
continue;
|
.bodyOfJson(loginBody.toJSONString())
|
||||||
}
|
.post();
|
||||||
|
JSONObject body = JSONObject.parseObject(response);
|
||||||
|
log.info("[{}] 登录返回: {}", systemName, body);
|
||||||
|
|
||||||
JSONArray filesList = files.getJSONObject("result").getJSONArray("records");
|
boolean success = body.getBoolean("success");
|
||||||
log.info("获取到的日志文件为: {}", filesList);
|
if (!success) {
|
||||||
|
log.warn("[{}] 登录失败,跳过该系统", systemName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 开始下载日志文件
|
String token = body.getJSONObject("result").getString("token");
|
||||||
if (filesList != null && !filesList.isEmpty()) {
|
log.info("[{}] 登录成功,获取到 token: {}", systemName, token);
|
||||||
Set<String> 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 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="
|
downloadServiceLogs(gatewayUrl, token, systemName, serviceName, insCount, servicePath);
|
||||||
+ 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取基础文件名(去除序列号部分)
|
* 下载单个微服务的日志文件。
|
||||||
|
*/
|
||||||
|
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<String> 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<String> 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) {
|
private String getBaseFileNameWithoutSequence(String fileName) {
|
||||||
// 检查是否包含序号格式(如 .log.001)
|
|
||||||
if (fileName.contains(".")) {
|
if (fileName.contains(".")) {
|
||||||
String[] parts = fileName.split("\\.");
|
String[] parts = fileName.split("\\.");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
// 检查倒数第二个部分是否为数字
|
|
||||||
try {
|
try {
|
||||||
Integer.parseInt(parts[parts.length - 1]);
|
Integer.parseInt(parts[parts.length - 1]);
|
||||||
// 返回去掉序号的部分
|
|
||||||
StringBuilder baseName = new StringBuilder();
|
StringBuilder baseName = new StringBuilder();
|
||||||
for (int i = 0; i < parts.length - 1; i++) {
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
if (i > 0) baseName.append(".");
|
if (i > 0) baseName.append(".");
|
||||||
@@ -253,7 +318,6 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
}
|
}
|
||||||
return baseName.toString();
|
return baseName.toString();
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
// 不是数字,说明没有序号,返回原始名称
|
|
||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +326,7 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取下一个可用的序号
|
* 获取下一个可用序号。
|
||||||
*/
|
*/
|
||||||
private int getNextAvailableSequence(File directory, String baseFileName) {
|
private int getNextAvailableSequence(File directory, String baseFileName) {
|
||||||
File[] existingFiles = directory.listFiles((dir, name) ->
|
File[] existingFiles = directory.listFiles((dir, name) ->
|
||||||
@@ -270,19 +334,16 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
(name.equals(baseFileName) || name.matches(baseFileName + "\\.\\d{3}")));
|
(name.equals(baseFileName) || name.matches(baseFileName + "\\.\\d{3}")));
|
||||||
|
|
||||||
if (existingFiles == null) {
|
if (existingFiles == null) {
|
||||||
return 1; // 目录不存在或无法访问
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int maxSequence = 0;
|
int maxSequence = 0;
|
||||||
|
|
||||||
for (File file : existingFiles) {
|
for (File file : existingFiles) {
|
||||||
String fileName = file.getName();
|
String fileName = file.getName();
|
||||||
if (fileName.equals(baseFileName)) {
|
if (fileName.equals(baseFileName)) {
|
||||||
// 基础文件存在,从1开始
|
|
||||||
maxSequence = Math.max(maxSequence, 1);
|
maxSequence = Math.max(maxSequence, 1);
|
||||||
} else if (fileName.matches(baseFileName + "\\.\\d{3}")) {
|
} else if (fileName.matches(baseFileName + "\\.\\d{3}")) {
|
||||||
// 提取序号
|
String sequencePart = fileName.substring(baseFileName.length() + 1);
|
||||||
String sequencePart = fileName.substring(baseFileName.length() + 1); // +1 for the dot
|
|
||||||
try {
|
try {
|
||||||
int sequence = Integer.parseInt(sequencePart);
|
int sequence = Integer.parseInt(sequencePart);
|
||||||
maxSequence = Math.max(maxSequence, sequence + 1);
|
maxSequence = Math.max(maxSequence, sequence + 1);
|
||||||
@@ -291,15 +352,14 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxSequence;
|
return maxSequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今天零点的时间戳。
|
||||||
|
*/
|
||||||
private long getTodayStartTime() {
|
private long getTodayStartTime() {
|
||||||
// 获取当前时间
|
|
||||||
long currentTime = System.currentTimeMillis();
|
long currentTime = System.currentTimeMillis();
|
||||||
// 计算今天0点的时间
|
|
||||||
java.util.Calendar calendar = java.util.Calendar.getInstance();
|
java.util.Calendar calendar = java.util.Calendar.getInstance();
|
||||||
calendar.setTimeInMillis(currentTime);
|
calendar.setTimeInMillis(currentTime);
|
||||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, 0);
|
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) {
|
private FileInfo getFileInfo(File directory, String baseFileName) {
|
||||||
// 获取最大序号
|
|
||||||
int maxSequence = getNextAvailableSequence(directory, baseFileName) - 1;
|
int maxSequence = getNextAvailableSequence(directory, baseFileName) - 1;
|
||||||
|
|
||||||
// 构建当前最大序号文件名
|
|
||||||
String currentMaxFileName;
|
String currentMaxFileName;
|
||||||
if (maxSequence <= 0) {
|
if (maxSequence <= 0) {
|
||||||
// 没有序号文件,直接使用基础文件名
|
|
||||||
currentMaxFileName = baseFileName;
|
currentMaxFileName = baseFileName;
|
||||||
} else {
|
} else {
|
||||||
// 存在序号文件,拼接序号
|
|
||||||
currentMaxFileName = baseFileName + "." + String.format("%03d", maxSequence);
|
currentMaxFileName = baseFileName + "." + String.format("%03d", maxSequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建下一个序号文件名
|
|
||||||
int nextSequence = maxSequence + 1;
|
int nextSequence = maxSequence + 1;
|
||||||
String nextFileName = baseFileName + "." + String.format("%03d", nextSequence);
|
String nextFileName = baseFileName + "." + String.format("%03d", nextSequence);
|
||||||
|
|
||||||
// 构造当前最大序号文件对象
|
|
||||||
File maxSequenceFile = new File(directory, currentMaxFileName);
|
File maxSequenceFile = new File(directory, currentMaxFileName);
|
||||||
|
|
||||||
// 检查文件是否存在并获取大小
|
|
||||||
long fileSize = 0;
|
long fileSize = 0;
|
||||||
if (maxSequenceFile.exists()) {
|
if (maxSequenceFile.exists()) {
|
||||||
fileSize = maxSequenceFile.length();
|
fileSize = maxSequenceFile.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回封装好的信息
|
|
||||||
return new FileInfo(currentMaxFileName, nextFileName, fileSize);
|
return new FileInfo(currentMaxFileName, nextFileName, fileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计目录下文件名最后一个 _ 前的不同前缀数量(用于判断实例是否变更)。
|
||||||
|
*/
|
||||||
public static int countUniquePrefixes(String directoryPath) {
|
public static int countUniquePrefixes(String directoryPath) {
|
||||||
File directory = new File(directoryPath);
|
File directory = new File(directoryPath);
|
||||||
if (!directory.exists() || !directory.isDirectory()) {
|
if (!directory.exists() || !directory.isDirectory()) {
|
||||||
@@ -360,7 +410,6 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
if (files != null) {
|
if (files != null) {
|
||||||
for (File file : files) {
|
for (File file : files) {
|
||||||
String fileName = file.getName();
|
String fileName = file.getName();
|
||||||
// 提取最后一个 _ 前面的部分作为前缀
|
|
||||||
int lastUnderscoreIndex = fileName.lastIndexOf('_');
|
int lastUnderscoreIndex = fileName.lastIndexOf('_');
|
||||||
if (lastUnderscoreIndex != -1) {
|
if (lastUnderscoreIndex != -1) {
|
||||||
String prefix = fileName.substring(0, lastUnderscoreIndex);
|
String prefix = fileName.substring(0, lastUnderscoreIndex);
|
||||||
@@ -368,8 +417,6 @@ public class LogFileServiceImpl implements LogFileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// log.info("唯一前缀数量: " + prefixSet.size());
|
|
||||||
return prefixSet.size();
|
return prefixSet.size();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,28 @@ solon.scheduling.job:
|
|||||||
initialDelay: 0
|
initialDelay: 0
|
||||||
enable: true #用任务进行启停控制
|
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:
|
file:
|
||||||
path: D:/xjlogs
|
path: D:/xjlogs
|
||||||
services:
|
systems:
|
||||||
- name: health-system
|
- name: 健康管理系统
|
||||||
insCount: 2
|
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
|
# - health-watch
|
||||||
Reference in New Issue
Block a user