日志文件下载功能,支持多实例日志
This commit is contained in:
+419
@@ -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<List<Map<String, String>>> 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<Map<String, String>> fileList = Arrays.stream(files)
|
||||
.filter(File::isFile)
|
||||
.sorted(Comparator.comparingLong(File::lastModified).reversed()) // ✅ 优化:避免装箱
|
||||
.map(file -> {
|
||||
Map<String, String> 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<IPage<Map<String, String>>> pageFiles(
|
||||
@RequestParam String service,
|
||||
@RequestParam(defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||
|
||||
// 1. 从 Nacos 获取 service 的所有健康实例
|
||||
List<String> 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<Void> forwardEntity = new HttpEntity<>(forwardHeaders);
|
||||
|
||||
// 2. 并发调用每个实例的 /log/files/local 接口
|
||||
List<Map<String, String>> allFiles = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
CompletableFuture[] futures = instances.stream()
|
||||
.map(instance -> CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
String url = "http://" + instance + "/service/log/local"; // 注意路径匹配
|
||||
ResponseEntity<Result<List<Map<String, String>>>> response =
|
||||
pureRestTemplate.exchange(url, HttpMethod.GET, forwardEntity,
|
||||
new ParameterizedTypeReference<Result<List<Map<String, String>>>>() {});
|
||||
|
||||
Result<List<Map<String, String>>> 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<Map<String, String>> pageRecords = start < total
|
||||
? allFiles.subList(start, end)
|
||||
: Collections.emptyList();
|
||||
|
||||
// 5. 构造分页对象
|
||||
Page<Map<String, String>> 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<FileSystemResource> 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<Boolean> 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<byte[]> fileDown(
|
||||
@RequestParam String service,
|
||||
@RequestParam String filename,
|
||||
@RequestParam String instance) throws UnsupportedEncodingException { // ✅ 新增 instance 参数
|
||||
|
||||
// 1️⃣ 验证实例合法性
|
||||
List<String> 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<Void> requestEntity = new HttpEntity<>(forwardHeaders);
|
||||
|
||||
ResponseEntity<byte[]> 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<String> 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<Boolean> fileDelete(
|
||||
@RequestParam String service,
|
||||
@RequestParam String filename,
|
||||
@RequestParam String instance) throws UnsupportedEncodingException {
|
||||
|
||||
// 1️⃣ 验证实例
|
||||
List<String> 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<Void> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<Result<Boolean>> remoteResp = pureRestTemplate.exchange(
|
||||
remoteUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
new ParameterizedTypeReference<Result<Boolean>>() {}
|
||||
);
|
||||
|
||||
Result<Boolean> 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+137
@@ -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<String, String> params = new LinkedMultiValueMap<>();
|
||||
params.add("username", username);
|
||||
params.add("password", password);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
|
||||
|
||||
ResponseEntity<String> 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<String> like ["192.168.1.10:8080", "192.168.1.11:8081"]
|
||||
*/
|
||||
public List<String> 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<Void> request = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
|
||||
<!-- 获取应用名和日志路径 -->
|
||||
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="app"/>
|
||||
<springProperty scope="context" name="LOG_HOME" source="logging.file.path"
|
||||
defaultValue="${user.dir}/logs/${appName}"/>
|
||||
<springProperty scope="context" name="MAX_HISTORY" source="logging.logback.rollingPolicy.max-history"
|
||||
defaultValue="10"/>
|
||||
<springProperty scope="context" name="MAX_FILE_SIZE" source="logging.logback.rollingPolicy.max-file-size"
|
||||
defaultValue="10MB"/>
|
||||
<springProperty scope="context" name="TOTAL_SIZE_CAP" source="logging.logback.rollingPolicy.total-size-cap"
|
||||
defaultValue="200MB"/>
|
||||
|
||||
<!-- 控制台输出 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}:%L) - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件输出 + 滚动策略 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/${appName}.log</file>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}:%L - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!-- 关键:添加 .gz 后缀以启用自动压缩 -->
|
||||
<fileNamePattern>${LOG_HOME}/${appName}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
|
||||
<maxHistory>${MAX_HISTORY}</maxHistory>
|
||||
<totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap>
|
||||
<!-- 可选:清理旧文件时也删除压缩文件 -->
|
||||
<cleanHistoryOnStart>true</cleanHistoryOnStart>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!-- 根日志级别 -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
-256
@@ -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<IPage<ApiLog>> 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<QueryBuilder> 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<QueryBuilder> 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<ApiLog> search = elasticsearchOperations.search(query, ApiLog.class);
|
||||
List<ApiLog> apiLogs = search.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList());
|
||||
IPage<ApiLog> pageApiLog = new Page<>(pageNo, pageSize);
|
||||
pageApiLog.setRecords(apiLogs);
|
||||
pageApiLog.setTotal(search.getTotalHits());
|
||||
return Result.ok(pageApiLog);
|
||||
}
|
||||
|
||||
@Operation(description = "接口日志列表")
|
||||
@PostMapping("/taskList")
|
||||
@RequiresRoles("admin")
|
||||
public Result<IPage<TaskLog>> 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<QueryBuilder> 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<QueryBuilder> 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<TaskLog> search = elasticsearchOperations.search(query, TaskLog.class);
|
||||
List<TaskLog> taskLogs = search.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList());
|
||||
IPage<TaskLog> 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<ErrorLog> getErrorLogById(@PathVariable String id) {
|
||||
return Result.ok(errorLogRepository.findById(id).orElseThrow(() -> new RuntimeException("未找到错误日志")));
|
||||
}
|
||||
|
||||
@Operation(description = "日志文件列表")
|
||||
@GetMapping("/files")
|
||||
@RequiresRoles("admin")
|
||||
public Result<IPage<String>> pageFiles(String service,
|
||||
@RequestParam(defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||
Page<String> 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<String> fileNames = Arrays.stream(files)
|
||||
.filter(File::isFile)
|
||||
.sorted(Comparator.comparing(this::getLastModifiedTime).reversed())
|
||||
.map(File::getName)
|
||||
.collect(Collectors.toList());
|
||||
List<String> 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<FileSystemResource> 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<Boolean> fileDelete(String service, String filename) throws IOException {
|
||||
return Result.ok(Files.deleteIfExists(getLogPath(service).resolve(filename).normalize()));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user