Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-02 16:10:39 +08:00
6 changed files with 612 additions and 259 deletions
@@ -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());
}
}
}
@@ -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>
@@ -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()));
}
}
@@ -53,9 +53,9 @@ public class SmsSendMsgHandle implements ISendMsgHandle {
@Override
public Result<?> sendCqSms(int pageSize,boolean isBatch){
String logSuffix = isBatch?"[群发]":"[单发]";
// String logSuffix = isBatch?"[群发]":"[单发]";
//调用短信接口发送短信
log.info("查询未发送和发送失败的短信"+logSuffix);
// log.info("查询未发送和发送失败的短信"+logSuffix);
//根据单发和群发拼接查询
LambdaQueryWrapper<SysMessage> queryWrapper = new LambdaQueryWrapper<>();
if(!isBatch){
@@ -69,7 +69,7 @@ public class SmsSendMsgHandle implements ISendMsgHandle {
queryWrapper.orderByAsc(SysMessage::getCreateTime).last("limit "+pageSize);
//查询需要发送的数据
List<SysMessage> sysMessages = sysMessageService.getBaseMapper().selectList(queryWrapper);
log.info("开始发送短信("+sysMessages.size()+")"+logSuffix);
// log.info("开始发送短信("+sysMessages.size()+")"+logSuffix);
// 2.执行发送短信任务
for (SysMessage sysMessage : sysMessages) {
ThreadUtil.safeSleep(100);//防止并发,休眠0.1秒