From 24b1967ba91f6f63ffd5c6979cf38fe121967427 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Thu, 29 Jan 2026 19:36:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/base/controller/LogController.java | 419 ++++++++++++++++++ .../base/service/NacosInstanceService.java | 137 ++++++ .../org/jeecg/config/RestTemplateConfig.java | 7 + .../src/main/resources/logback-spring.xml | 46 ++ .../modules/api/controller/LogController.java | 256 ----------- 5 files changed, 609 insertions(+), 256 deletions(-) create mode 100644 jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/controller/LogController.java create mode 100644 jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/service/NacosInstanceService.java create mode 100644 jeecg-boot-base-core/src/main/resources/logback-spring.xml delete mode 100644 jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/LogController.java diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/controller/LogController.java b/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/controller/LogController.java new file mode 100644 index 0000000..9448ac5 --- /dev/null +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/controller/LogController.java @@ -0,0 +1,419 @@ +package org.jeecg.common.system.base.controller; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.RequiresRoles; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.base.service.NacosInstanceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.*; +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 org.springframework.web.client.RestTemplate; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.net.InetAddress; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * @author Shunzhi Jiang + * @since 2023/10/13 + */ +@RestController +@RequestMapping("/service/log") +@Slf4j +@Tag(name = "system/日志") +public class LogController { + + @Value("${logging.file.path:}") + private String loggingFilePath; + + @Value("${spring.application.name:}") + private String serviceName; + + + @Autowired + private NacosInstanceService nacosInstanceService; + + @Autowired + @Qualifier("pureRestTemplate") // 指定使用 named bean + private RestTemplate pureRestTemplate; + + @Autowired + private ApplicationContext applicationContext; + + + /** + * 内部接口:仅查询当前实例的本地日志文件(不聚合) + */ + @GetMapping("/local") + public Result>> getLocalLogFiles() { + if (!checkPathExist()) { + return Result.ok(Collections.emptyList()); + } + File logDir = getLogPath(serviceName).normalize().toFile(); // 注意:这里 service 不用于路径拼接 + File[] files = logDir.listFiles(); + if (ArrayUtil.isEmpty(files)) { + return Result.ok(Collections.emptyList()); + } + + List> fileList = Arrays.stream(files) + .filter(File::isFile) + .sorted(Comparator.comparingLong(File::lastModified).reversed()) // ✅ 优化:避免装箱 + .map(file -> { + Map item = new LinkedHashMap<>(); + item.put("fileName", file.getName()); + item.put("lastModified", DateUtil.formatDateTime(new Date(file.lastModified()))); + item.put("fileSize", formatFileSize(file.length())); + item.put("instance", getLocalInstanceInfo()); + return item; + }) + .collect(Collectors.toList()); // ✅ JDK 1.8 标准写法 + + return Result.ok(fileList); + } + + // 获取当前实例标识(可选) + private String getLocalInstanceInfo() { + String ip = "unknown"; + int port = -1; + try { + InetAddress addr = InetAddress.getLocalHost(); + ip = addr.getHostAddress(); + // 如果你知道端口(如从 server.port 获取) + port = applicationContext.getEnvironment().getProperty("server.port", Integer.class, -1); + } catch (Exception ignored) {} + return port > 0 ? ip + ":" + port : ip; + } + + @Operation(description = "日志文件列表") + @GetMapping("/files") + @RequiresRoles("admin") + public Result>> pageFiles( + @RequestParam String service, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "10") Integer pageSize) { + + // 1. 从 Nacos 获取 service 的所有健康实例 + List instances = nacosInstanceService.getHealthyInstances(service); + if (instances.isEmpty()) { + return Result.ok(Page.of(pageNo, pageSize)); // 无实例 + } + + //获取Token + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + ServletRequestAttributes srat = (ServletRequestAttributes) requestAttributes; + HttpServletRequest formRequest = srat.getRequest(); + String token = formRequest.getHeader("X-Access-Token"); //获取header中的token + + + // 构造带认证头的请求实体(可复用) + HttpHeaders forwardHeaders = new HttpHeaders(); + forwardHeaders.set("X-Access-Token", token); + HttpEntity forwardEntity = new HttpEntity<>(forwardHeaders); + + // 2. 并发调用每个实例的 /log/files/local 接口 + List> allFiles = Collections.synchronizedList(new ArrayList<>()); + + CompletableFuture[] futures = instances.stream() + .map(instance -> CompletableFuture.runAsync(() -> { + try { + String url = "http://" + instance + "/service/log/local"; // 注意路径匹配 + ResponseEntity>>> response = + pureRestTemplate.exchange(url, HttpMethod.GET, forwardEntity, + new ParameterizedTypeReference>>>() {}); + + Result>> result = response.getBody(); + if (result != null && result.isSuccess() && result.getResult() != null) { + // 给每条记录打上 instance 标签(已在 local 接口加了,可选) + allFiles.addAll(result.getResult()); + } + } catch (Exception e) { + // 记录日志:某个实例不可达 + log.warn("Failed to fetch logs from instance: {}", instance, e); + } + })) + .toArray(CompletableFuture[]::new); + + // 等待所有调用完成 + CompletableFuture.allOf(futures).join(); + + // 3. 全局排序(按 lastModified 降序) + allFiles.sort((a, b) -> { + String timeA = a.get("lastModified"); + String timeB = b.get("lastModified"); + try { + return DateUtil.parse(timeB).compareTo(DateUtil.parse(timeA)); // 降序 + } catch (Exception e) { + return 0; + } + }); + + // 4. 手动分页 + int total = allFiles.size(); + int start = (pageNo - 1) * pageSize; + int end = Math.min(start + pageSize, total); + List> pageRecords = start < total + ? allFiles.subList(start, end) + : Collections.emptyList(); + + // 5. 构造分页对象 + Page> page = new Page<>(pageNo, pageSize); + page.setTotal(total); + page.setRecords(pageRecords); + + return Result.ok(page); + } + + private int getStartIndex(Integer pageNo, Integer pageSize, Integer listSize) { + return Math.min(Math.max(0, pageNo - 1) * pageSize, Math.max(listSize, 0)); + } + + private int getEndIndex(Integer pageNo, Integer pageSize, Integer listSize) { + return Math.min(Math.max(1, pageNo) * pageSize, listSize); + } + + private boolean checkPathExist() { + return StrUtil.isNotBlank(loggingFilePath); + } + + private Path getLogPath(String service) { + String validService = validFilter(service); + return Paths.get(loggingFilePath).getParent().resolve(validService); + } + + private String validFilter(String str) { + return StrUtil.strip(StrUtil.strip(StrUtil.replace(str, "..", ""), "/"), "\\"); + } + + private String formatFileSize(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.2f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.2f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + } + + + /** + * 本地日志文件下载(内部接口,仅限服务间调用) + */ + @Operation(description = "本地日志下载(内部)") + @GetMapping("/local/down") + @RequiresRoles("admin") // 保留权限校验,调用时需透传Token + public ResponseEntity localFileDown(@RequestParam String filename) { + log.info("【本地下载】filename: {}", filename); + try { + // 使用当前实例的 serviceName 作为日志目录标识(与 getLocalLogFiles 逻辑一致) + Path logBasePath = getLogPath(serviceName).normalize(); + Path targetPath = logBasePath.resolve(filename).normalize(); + + // 🔒 严格路径穿越防护 + if (!targetPath.startsWith(logBasePath)) { + log.warn("非法路径访问: {}", targetPath); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + File file = targetPath.toFile(); + if (!file.exists() || !file.isFile()) { + log.warn("本地文件不存在: {}", targetPath); + return ResponseEntity.notFound().build(); + } + + FileSystemResource resource = new FileSystemResource(file); + HttpHeaders headers = new HttpHeaders(); + // 兼容中文文件名(RFC 5987 标准) + String encodedName = URLEncoder.encode(file.getName(), String.valueOf(StandardCharsets.UTF_8)) + .replace("+", "%20"); // 空格转 %20 避免部分浏览器解析问题 + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentDispositionFormData("attachment", encodedName); // ✅ 更兼容的写法 + + log.info("【本地下载成功】文件: {}", filename); + return ResponseEntity.ok() + .headers(headers) + .contentLength(file.length()) + .body(resource); + } catch (Exception e) { + log.error("【本地下载异常】filename: {}", filename, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * 本地日志文件删除(内部接口) + */ + @Operation(description = "本地日志删除(内部)") + @GetMapping("/local/delete") // 沿用原设计(建议后续改用 DELETE) + @RequiresRoles("admin") + public Result localFileDelete(@RequestParam String filename) { + try { + Path logBasePath = getLogPath(serviceName).normalize(); + Path targetPath = logBasePath.resolve(filename).normalize(); + + if (!targetPath.startsWith(logBasePath)) { + return Result.error("非法路径访问"); + } + + boolean deleted = Files.deleteIfExists(targetPath); + log.info("【本地删除】{} -> {}", filename, deleted ? "成功" : "文件不存在"); + return Result.ok(deleted); + } catch (Exception e) { + log.error("【本地删除异常】filename: {}", filename, e); + return Result.error("删除失败: " + e.getMessage()); + } + } + + @Operation(description = "日志文件下载(代理到指定实例)") + @GetMapping("/file/down") + @RequiresRoles("admin") + public ResponseEntity fileDown( + @RequestParam String service, + @RequestParam String filename, + @RequestParam String instance) throws UnsupportedEncodingException { // ✅ 新增 instance 参数 + + // 1️⃣ 验证实例合法性 + List healthyInstances = nacosInstanceService.getHealthyInstances(service); + if (!healthyInstances.contains(instance)) { + log.warn("【下载拒绝】实例 {} 不在服务 {} 的健康列表中 | 健康实例: {}", + instance, service, healthyInstances); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body("Invalid instance".getBytes(StandardCharsets.UTF_8)); + } + + // 2️⃣ 透传认证 Token + ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (sra == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + String token = sra.getRequest().getHeader("X-Access-Token"); + if (StrUtil.isBlank(token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // 3️⃣ 构造目标实例请求 + String remoteUrl = String.format( + "http://%s/service/log/local/down?filename=%s", + instance, + URLEncoder.encode(filename, String.valueOf(StandardCharsets.UTF_8)) + ); + log.info("【转发下载】目标实例: {} | 文件: {}", instance, filename); + + try { + // 4️⃣ 调用目标实例(关键:接收完整 ResponseEntity) + HttpHeaders forwardHeaders = new HttpHeaders(); + forwardHeaders.set("X-Access-Token", token); + HttpEntity requestEntity = new HttpEntity<>(forwardHeaders); + + ResponseEntity remoteResponse = pureRestTemplate.exchange( + remoteUrl, + HttpMethod.GET, + requestEntity, + byte[].class + ); + + // 5️⃣ 透传关键响应头(确保浏览器触发下载) + HttpHeaders responseHeaders = new HttpHeaders(); + // 仅透传必要头,避免安全风险 + Arrays.asList("Content-Disposition", "Content-Type", "Content-Length") + .forEach(header -> { + List values = remoteResponse.getHeaders().get(header); + if (values != null) responseHeaders.put(header, values); + }); + + log.info("【下载成功】实例: {} | 文件: {} | 大小: {} bytes", + instance, filename, remoteResponse.getBody() != null ? remoteResponse.getBody().length : 0); + + return ResponseEntity + .status(remoteResponse.getStatusCode()) + .headers(responseHeaders) + .body(remoteResponse.getBody()); + + } catch (Exception e) { + log.error("【下载转发失败】实例: {} | 文件: {}", instance, filename, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(("Download failed: " + e.getMessage()).getBytes(StandardCharsets.UTF_8)); + } + } + + @Operation(description = "日志文件删除(代理到指定实例)") + @GetMapping("/file/delete") + @RequiresRoles("admin") + public Result fileDelete( + @RequestParam String service, + @RequestParam String filename, + @RequestParam String instance) throws UnsupportedEncodingException { + + // 1️⃣ 验证实例 + List healthyInstances = nacosInstanceService.getHealthyInstances(service); + if (!healthyInstances.contains(instance)) { + return Result.error("实例不在健康列表中"); + } + + // 2️⃣ 透传 Token + ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (sra == null) return Result.error("请求上下文异常"); + String token = sra.getRequest().getHeader("X-Access-Token"); + if (StrUtil.isBlank(token)) return Result.error("认证失败"); + + // 3️⃣ 转发删除请求 + String remoteUrl = String.format( + "http://%s/service/log/local/delete?filename=%s", + instance, + URLEncoder.encode(filename, String.valueOf(StandardCharsets.UTF_8)) + ); + log.info("【转发删除】目标实例: {} | 文件: {}", instance, filename); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Access-Token", token); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity> remoteResp = pureRestTemplate.exchange( + remoteUrl, + HttpMethod.GET, + entity, + new ParameterizedTypeReference>() {} + ); + + Result result = remoteResp.getBody(); + log.info("【删除结果】实例: {} | 文件: {} | 结果: {}", + instance, filename, result != null ? result.getMessage() : "unknown"); + return result != null ? result : Result.error("远程响应为空"); + + } catch (Exception e) { + log.error("【删除转发失败】实例: {} | 文件: {}", instance, filename, e); + return Result.error("操作失败: " + e.getMessage()); + } + } + + +} diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/service/NacosInstanceService.java b/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/service/NacosInstanceService.java new file mode 100644 index 0000000..dfa54c4 --- /dev/null +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/base/service/NacosInstanceService.java @@ -0,0 +1,137 @@ +package org.jeecg.common.system.base.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +@Component +public class NacosInstanceService { + + + @Value("${spring.cloud.nacos.server-addr}") + private String nacosUrl; + @Value("${spring.cloud.nacos.username}") + private String username; + @Value("${spring.cloud.nacos.password}") + private String password; + @Value("${spring.cloud.nacos.discovery.namespace}") + private String namespace; + @Value("${spring.cloud.nacos.discovery.group}") + private String group; + + // ✅ 新增:注入纯净版 + @Autowired + @Qualifier("pureRestTemplate") // 指定使用 named bean + private RestTemplate pureRestTemplate; + + private volatile String accessToken; + private volatile long tokenExpireAt; // 过期时间戳(毫秒) + private final ReentrantLock lock = new ReentrantLock(); + + + /** + * 登录 Nacos 获取 accessToken + */ + private void login() { + String loginUrl = "http://" + nacosUrl + "/nacos/v1/auth/users/login"; + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("username", username); + params.add("password", password); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = pureRestTemplate.postForEntity(loginUrl, request, String.class); + if (response.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException("Nacos login failed: " + response.getStatusCode()); + } + + // 使用 Fastjson2 解析 + JSONObject json = JSON.parseObject(response.getBody()); + this.accessToken = json.getString("accessToken"); + Long ttlSeconds = json.getLong("tokenTtl"); // 单位:秒 + this.tokenExpireAt = System.currentTimeMillis() + (ttlSeconds - 60) * 1000; // 提前1分钟过期 + } + + /** + * 确保 accessToken 有效 + */ + private void ensureValidToken() { + if (accessToken == null || System.currentTimeMillis() >= tokenExpireAt) { + lock.lock(); + try { + if (accessToken == null || System.currentTimeMillis() >= tokenExpireAt) { + login(); + } + } finally { + lock.unlock(); + } + } + } + + /** + * 获取服务的所有健康实例(IP:Port) + * + * @param serviceName 微服务名,如 "user-service" + * @return List like ["192.168.1.10:8080", "192.168.1.11:8081"] + */ + public List getHealthyInstances(String serviceName) { + ensureValidToken(); + String namespaceId = namespace; + // 构造 URL + StringBuilder url = new StringBuilder("http://" + nacosUrl) + .append("/nacos/v1/ns/instance/list") + .append("?serviceName=").append(serviceName); + if (namespaceId != null) { + url.append("&namespaceId=").append(namespaceId); // public 时传 "" + } + if(group!=null){ + url.append("&groupName=").append(group); // public 时传 "" + } + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.set("accessToken", accessToken); + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = pureRestTemplate.exchange(url.toString(), HttpMethod.GET, request, String.class); + if (response.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException("Failed to fetch instances from Nacos: " + response.getStatusCode()); + } + + // 解析响应 + JSONObject root = JSON.parseObject(response.getBody()); + JSONArray hosts = root.getJSONArray("hosts"); + if (hosts == null || hosts.isEmpty()) { + return Collections.emptyList(); + } + + List instances = new ArrayList<>(); + for (int i = 0; i < hosts.size(); i++) { + JSONObject host = hosts.getJSONObject(i); + boolean healthy = host.getBooleanValue("healthy"); + boolean enabled = host.getBooleanValue("enabled"); + if (healthy && enabled) { + String ip = host.getString("ip"); + int port = host.getIntValue("port"); + instances.add(ip + ":" + port); + } + } + return instances; + } +} \ No newline at end of file diff --git a/jeecg-boot-base-core/src/main/java/org/jeecg/config/RestTemplateConfig.java b/jeecg-boot-base-core/src/main/java/org/jeecg/config/RestTemplateConfig.java index 50fccd8..0b18d97 100644 --- a/jeecg-boot-base-core/src/main/java/org/jeecg/config/RestTemplateConfig.java +++ b/jeecg-boot-base-core/src/main/java/org/jeecg/config/RestTemplateConfig.java @@ -42,4 +42,11 @@ public class RestTemplateConfig { factory.setConnectTimeout(15000); return factory; } + + // 新增方法 👇 + @Bean("pureRestTemplate") // 指定 bean 名称,避免冲突 + public RestTemplate pureRestTemplate(ClientHttpRequestFactory factory) { + // 创建一个全新的 RestTemplate,不加任何拦截器 + return new RestTemplate(factory); + } } diff --git a/jeecg-boot-base-core/src/main/resources/logback-spring.xml b/jeecg-boot-base-core/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..d6d6553 --- /dev/null +++ b/jeecg-boot-base-core/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}:%L) - %msg%n + UTF-8 + + + + + + ${LOG_HOME}/${appName}.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}:%L - %msg%n + UTF-8 + + + + ${LOG_HOME}/${appName}.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + + true + + + + + + + + + \ No newline at end of file diff --git a/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/LogController.java b/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/LogController.java deleted file mode 100644 index d206cf6..0000000 --- a/jeecg-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/LogController.java +++ /dev/null @@ -1,256 +0,0 @@ -package org.jeecg.modules.api.controller; - -import cn.hutool.core.util.ArrayUtil; -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.authz.annotation.RequiresRoles; -import org.elasticsearch.index.query.BoolQueryBuilder; -import org.elasticsearch.index.query.QueryBuilder; -import org.elasticsearch.index.query.QueryBuilders; -import org.jeecg.common.api.vo.Result; -import org.jeecg.common.log.ApiLog; -import org.jeecg.common.log.ErrorLog; -import org.jeecg.common.log.ErrorLogRepository; -import org.jeecg.common.log.TaskLog; -import org.jeecg.modules.api.bean.LogFilter; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.FileSystemResource; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.elasticsearch.core.ElasticsearchOperations; -import org.springframework.data.elasticsearch.core.SearchHit; -import org.springframework.data.elasticsearch.core.SearchHits; -import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; -import org.springframework.data.elasticsearch.core.query.Query; -import org.springframework.http.*; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; - -import java.io.File; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * @author Shunzhi Jiang - * @since 2023/10/13 - */ -@RestController -@RequestMapping("/service/log") -@Slf4j -@Tag(name = "system/日志") -public class LogController { - - @Autowired - private ElasticsearchOperations elasticsearchOperations; - - @Autowired - private ErrorLogRepository errorLogRepository; - - @Value("${logging.file.path:}") - private String loggingFilePath; - - @Operation(description = "接口日志列表") - @PostMapping("/list") - @RequiresRoles("admin") - public Result> pageList(@RequestBody LogFilter logFilter, - @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, - @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize) { - Pageable pageable = PageRequest.of(pageNo - 1, pageSize); - Sort sort = Sort.by(Sort.Direction.DESC, "createTime"); - NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); - BoolQueryBuilder mustQueryBuilder = QueryBuilders.boolQuery(); - List must = mustQueryBuilder.must(); - if (StringUtils.hasText(logFilter.getService())) { - must.add(QueryBuilders.termQuery("service.keyword", logFilter.getService())); - } - if (Objects.nonNull(logFilter.getIsEx())) { - must.add(QueryBuilders.termQuery("isEx", logFilter.getIsEx())); - } - if (Objects.nonNull(logFilter.getStart()) && Objects.nonNull(logFilter.getEnd())) { - must.add(QueryBuilders.rangeQuery("createTime").gte(logFilter.getStart()).lte(logFilter.getEnd())); - } - if (Objects.nonNull(logFilter.getSpend())) { - must.add(QueryBuilders.rangeQuery("spend").gte(logFilter.getSpend())); - } - if (StringUtils.hasText(logFilter.getKeyword())) { - BoolQueryBuilder shouldQueryBuilder = QueryBuilders.boolQuery(); - List should = shouldQueryBuilder.should(); - should.add(QueryBuilders.matchQuery("method", logFilter.getKeyword())); - should.add(QueryBuilders.matchQuery("requestUrl", logFilter.getKeyword())); - must.add(shouldQueryBuilder); - } - Query query = queryBuilder.withQuery(mustQueryBuilder) - .withPageable(pageable) - .withSort(sort) - .withTrackTotalHits(Boolean.TRUE) - .build(); - SearchHits search = elasticsearchOperations.search(query, ApiLog.class); - List apiLogs = search.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList()); - IPage pageApiLog = new Page<>(pageNo, pageSize); - pageApiLog.setRecords(apiLogs); - pageApiLog.setTotal(search.getTotalHits()); - return Result.ok(pageApiLog); - } - - @Operation(description = "接口日志列表") - @PostMapping("/taskList") - @RequiresRoles("admin") - public Result> taskList(@RequestBody LogFilter logFilter, - @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, - @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize) { - Pageable pageable = PageRequest.of(pageNo - 1, pageSize); - Sort sort = Sort.by(Sort.Direction.DESC, "createTime"); - NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); - BoolQueryBuilder mustQueryBuilder = QueryBuilders.boolQuery(); - List must = mustQueryBuilder.must(); - if (StringUtils.hasText(logFilter.getService())) { - must.add(QueryBuilders.termQuery("service", logFilter.getService())); - } - if (StringUtils.hasText(logFilter.getTaskType())) { - must.add(QueryBuilders.termQuery("taskType", logFilter.getTaskType())); - } - if (StringUtils.hasText(logFilter.getTrigger())) { - must.add(QueryBuilders.termQuery("trigger", logFilter.getTrigger())); - } - if (Objects.nonNull(logFilter.getIsEx())) { - must.add(QueryBuilders.termQuery("isEx", logFilter.getIsEx())); - } - if (Objects.nonNull(logFilter.getStart()) && Objects.nonNull(logFilter.getEnd())) { - must.add(QueryBuilders.rangeQuery("createTime").gte(logFilter.getStart()).lte(logFilter.getEnd())); - } - if (Objects.nonNull(logFilter.getSpend())) { - must.add(QueryBuilders.rangeQuery("spend").gte(logFilter.getSpend())); - } - if (StringUtils.hasText(logFilter.getKeyword())) { - BoolQueryBuilder shouldQueryBuilder = QueryBuilders.boolQuery(); - List should = shouldQueryBuilder.should(); - should.add(QueryBuilders.matchQuery("taskName", logFilter.getKeyword())); - should.add(QueryBuilders.matchQuery("method", logFilter.getKeyword())); - must.add(shouldQueryBuilder); - } - Query query = queryBuilder.withQuery(mustQueryBuilder) - .withPageable(pageable) - .withSort(sort) - .withTrackTotalHits(Boolean.TRUE) - .build(); - SearchHits search = elasticsearchOperations.search(query, TaskLog.class); - List taskLogs = search.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList()); - IPage pageTaskLog = new Page<>(pageNo, pageSize); - pageTaskLog.setRecords(taskLogs); - pageTaskLog.setTotal(search.getTotalHits()); - return Result.ok(pageTaskLog); - } - - @Operation(description = "错误日志ID查询") - @GetMapping("/error/{id}") - @RequiresRoles("admin") - public Result getErrorLogById(@PathVariable String id) { - return Result.ok(errorLogRepository.findById(id).orElseThrow(() -> new RuntimeException("未找到错误日志"))); - } - - @Operation(description = "日志文件列表") - @GetMapping("/files") - @RequiresRoles("admin") - public Result> pageFiles(String service, - @RequestParam(defaultValue = "1") Integer pageNo, - @RequestParam(defaultValue = "10") Integer pageSize) { - Page resultPage = Page.of(pageNo, pageSize); - if (!checkPathExist()) { - return Result.ok(resultPage); - } - File file = getLogPath(service).normalize().toFile(); - File[] files = file.listFiles(); - if (ArrayUtil.isEmpty(files)) { - return Result.ok(resultPage); - } - List fileNames = Arrays.stream(files) - .filter(File::isFile) - .sorted(Comparator.comparing(this::getLastModifiedTime).reversed()) - .map(File::getName) - .collect(Collectors.toList()); - List results = fileNames.subList( - getStartIndex(pageNo, pageSize, fileNames.size()), - getEndIndex(pageNo, pageSize, fileNames.size()) - ); - resultPage.setTotal(fileNames.size()); - resultPage.setRecords(results); - return Result.ok(resultPage); - } - - private int getStartIndex(Integer pageNo, Integer pageSize, Integer listSize) { - return Math.min(Math.max(0, pageNo - 1) * pageSize, Math.max(listSize, 0)); - } - - private int getEndIndex(Integer pageNo, Integer pageSize, Integer listSize) { - return Math.min(Math.max(1, pageNo) * pageSize, listSize); - } - - private Long getLastModifiedTime(File file) { - try { - Path path = file.toPath(); - BasicFileAttributes basicFileAttributes = Files.readAttributes(path, BasicFileAttributes.class); - return basicFileAttributes.lastModifiedTime().toMillis(); - } catch (IOException e) { - return 0L; - } - } - - private boolean checkPathExist() { - return StrUtil.isNotBlank(loggingFilePath); - } - - private Path getLogPath(String service) { - String validService = validFilter(service); - return Paths.get(loggingFilePath).getParent().resolve(validService); - } - - private String validFilter(String str) { - return StrUtil.strip(StrUtil.strip(StrUtil.replace(str, "..", ""), "/"), "\\"); - } - - @Operation(description = "日志文件下载") - @GetMapping("/file/down") - @RequiresRoles("admin") - public ResponseEntity fileDown(String service, String filename) { - log.info("文件下载service:{}\tfilename:{}", service, filename); - try { - File file = getLogPath(service).resolve(filename).normalize().toFile(); - if (file.exists()) { - log.info("下载日志【{}】", filename); - FileSystemResource resource = new FileSystemResource(file); - HttpHeaders headers = new HttpHeaders(); - String name = URLEncoder.encode(file.getName(), "UTF-8"); - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); - headers.setContentDisposition(ContentDisposition.attachment().filename(name).build() - ); - return new ResponseEntity<>(resource, headers, HttpStatus.OK); - } - } catch (Exception e) { - log.error("下载文件出错service:{}\tfilename:{}", service, filename, e); - } - return null; - } - - @Operation(description = "日志文件删除") - @GetMapping("/file/delete") - @RequiresRoles("admin") - public Result fileDelete(String service, String filename) throws IOException { - return Result.ok(Files.deleteIfExists(getLogPath(service).resolve(filename).normalize())); - } - -}