feat(日志): 新增日志文件下载功能

添加日志文件下载相关功能,包括:
1. 新增LogFileDTO用于传输日志文件信息
2. 实现LogFileService接口及其实现类处理日志文件操作
3. 添加日志下载应用服务LogDownloadApplicationService
4. 创建管理后台和客户端API的日志下载控制器
5. 在SecurityConfig中添加日志下载路径权限
6. 新增相关错误码定义
This commit is contained in:
dzq 2026-01-06 15:00:44 +08:00
parent 078c18fec9
commit 7cc0674b90
9 changed files with 434 additions and 5 deletions

View File

@ -0,0 +1,79 @@
package com.agileboot.admin.controller.system;
import cn.hutool.core.util.URLUtil;
import com.agileboot.common.core.base.BaseController;
import com.agileboot.common.core.dto.ResponseDTO;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode.Business;
import com.agileboot.common.utils.file.FileUploadUtils;
import com.agileboot.domain.system.log.LogDownloadApplicationService;
import com.agileboot.domain.system.log.dto.LogFileDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 日志下载控制器 (管理后台使用)
*
* @author agileboot
*/
@Tag(name = "日志下载API", description = "服务器日志下载相关接口")
@RestController
@RequestMapping("/system/log")
@RequiredArgsConstructor
@Slf4j
public class LogDownloadController extends BaseController {
private final LogDownloadApplicationService logDownloadService;
/**
* 获取日志文件列表
*/
@Operation(summary = "获取日志文件列表")
@GetMapping("/files")
public ResponseDTO<List<LogFileDTO>> getLogFileList() {
List<LogFileDTO> fileList = logDownloadService.getLogFileList();
return ResponseDTO.ok(fileList);
}
/**
* 下载日志文件
*
* @param logType 日志类型: info, error, debug
*/
@Operation(summary = "下载日志文件")
@GetMapping("/download/{logType}")
public ResponseEntity<byte[]> downloadLog(@PathVariable String logType) {
try {
// 1. 获取日志文件内容
byte[] fileContent = logDownloadService.getLogFileContent(logType);
// 2. 获取文件名
String fileName = logDownloadService.getLogFileName(logType);
// 3. 构建下载响应头
HttpHeaders headers = FileUploadUtils.getDownloadHeader(fileName);
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
return new ResponseEntity<>(fileContent, headers, org.springframework.http.HttpStatus.OK);
} catch (ApiException e) {
log.error("下载日志文件失败: logType={}, error={}", logType, e.getMessage());
throw e;
} catch (Exception e) {
log.error("下载日志文件失败: logType={}", logType, e);
throw new ApiException(Business.LOG_FILE_READ_FAILED, e.getMessage());
}
}
}

View File

@ -137,7 +137,8 @@ public class SecurityConfig {
.antMatchers("/login", "/register", "/captchaImage", "/api/**", "/file/**").anonymous() .antMatchers("/login", "/register", "/captchaImage", "/api/**", "/file/**").anonymous()
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js",
"/profile/**").permitAll() "/profile/**").permitAll()
.antMatchers("/manual/**", "/qywx/**", "/test/**", "/monitor/**", "/getQyUserinfo", "/getConfig").permitAll() .antMatchers("/manual/**", "/qywx/**", "/test/**", "/monitor/**",
"/system/log/**", "/getQyUserinfo", "/getConfig").permitAll()
// TODO this is danger. // TODO this is danger.
.antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous() .antMatchers("/swagger-resources/**").anonymous()

View File

@ -0,0 +1,76 @@
package com.agileboot.api.controller;
import com.agileboot.common.core.dto.ResponseDTO;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode.Business;
import com.agileboot.common.utils.file.FileUploadUtils;
import com.agileboot.domain.system.log.LogDownloadApplicationService;
import com.agileboot.domain.system.log.dto.LogFileDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 日志下载控制器 (客户端API使用)
*
* @author agileboot
*/
@Tag(name = "日志下载API", description = "服务器日志下载相关接口")
@RestController
@RequestMapping("/api/log")
@RequiredArgsConstructor
@Slf4j
public class LogDownloadController {
private final LogDownloadApplicationService logDownloadService;
/**
* 获取日志文件列表
*/
@Operation(summary = "获取日志文件列表")
@GetMapping("/files")
public ResponseDTO<List<LogFileDTO>> getLogFileList() {
List<LogFileDTO> fileList = logDownloadService.getLogFileList();
return ResponseDTO.ok(fileList);
}
/**
* 下载日志文件
*
* @param logType 日志类型: info, error, debug
*/
@Operation(summary = "下载日志文件")
@GetMapping("/download/{logType}")
public ResponseEntity<byte[]> downloadLog(@PathVariable String logType) {
try {
// 1. 获取日志文件内容
byte[] fileContent = logDownloadService.getLogFileContent(logType);
// 2. 获取文件名
String fileName = logDownloadService.getLogFileName(logType);
// 3. 构建下载响应头
HttpHeaders headers = FileUploadUtils.getDownloadHeader(fileName);
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
return new ResponseEntity<>(fileContent, headers, org.springframework.http.HttpStatus.OK);
} catch (ApiException e) {
log.error("下载日志文件失败: logType={}, error={}", logType, e.getMessage());
throw e;
} catch (Exception e) {
log.error("下载日志文件失败: logType={}", logType, e);
throw new ApiException(Business.LOG_FILE_READ_FAILED, e.getMessage());
}
}
}

View File

@ -24,6 +24,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -51,13 +53,13 @@ public class AdminLoginController {
SysUserEntity userEntity = userService.getById(userId); SysUserEntity userEntity = userService.getById(userId);
if (userEntity == null) { if (userEntity == null) {
log.warn("用户不存在, userId: {}", userId); log.warn("用户不存在, userId: {}", userId);
return ResponseDTO.ok(List.of()); // 返回空列表而不是错误 return ResponseDTO.ok(new ArrayList<>()); // 返回空列表而不是错误
} }
// 2. 检查用户状态 (可选根据需求决定) // 2. 检查用户状态 (可选根据需求决定)
if (userEntity.getStatus() != 1) { // 1表示正常状态 if (userEntity.getStatus() != 1) { // 1表示正常状态
log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus()); log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus());
return ResponseDTO.ok(List.of()); return ResponseDTO.ok(new ArrayList<>());
} }
// 4. 构造 SystemLoginUser (isAdmin设置为false) // 4. 构造 SystemLoginUser (isAdmin设置为false)
@ -87,12 +89,12 @@ public class AdminLoginController {
SysUserEntity userEntity = userService.getById(userId); SysUserEntity userEntity = userService.getById(userId);
if (userEntity == null) { if (userEntity == null) {
log.warn("用户不存在, userId: {}", userId); log.warn("用户不存在, userId: {}", userId);
return ResponseDTO.ok(List.of()); return ResponseDTO.ok(new ArrayList<>());
} }
if (userEntity.getStatus() != 1) { if (userEntity.getStatus() != 1) {
log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus()); log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus());
return ResponseDTO.ok(List.of()); return ResponseDTO.ok(new ArrayList<>());
} }
SystemLoginUser loginUser = new SystemLoginUser( SystemLoginUser loginUser = new SystemLoginUser(

View File

@ -86,6 +86,14 @@ public enum ErrorCode implements ErrorCodeInterface {
PERMISSION_NOT_ALLOWED_TO_OPERATE(10202, "没有权限进行此操作,请联系管理员", "Business.NO_PERMISSION_TO_OPERATE"), PERMISSION_NOT_ALLOWED_TO_OPERATE(10202, "没有权限进行此操作,请联系管理员", "Business.NO_PERMISSION_TO_OPERATE"),
// ----------------------------- LOG -------------------------------------------
LOG_TYPE_NOT_FOUND(10301, "日志类型 {} 不存在", "Business.LOG_TYPE_NOT_FOUND"),
LOG_PATH_NOT_CONFIGURED(10302, "日志路径未配置", "Business.LOG_PATH_NOT_CONFIGURED"),
LOG_FILE_READ_FAILED(10303, "读取日志文件失败: {}", "Business.LOG_FILE_READ_FAILED"),
// ----------------------------- LOGIN ----------------------------------------- // ----------------------------- LOGIN -----------------------------------------
LOGIN_WRONG_USER_PASSWORD(10201, "用户密码错误,请重输", "Business.LOGIN_WRONG_USER_PASSWORD"), LOGIN_WRONG_USER_PASSWORD(10201, "用户密码错误,请重输", "Business.LOGIN_WRONG_USER_PASSWORD"),

View File

@ -0,0 +1,44 @@
package com.agileboot.domain.system.log;
import com.agileboot.domain.system.log.dto.LogFileDTO;
import com.agileboot.domain.system.log.service.LogFileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 日志下载应用服务
*
* @author agileboot
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LogDownloadApplicationService {
private final LogFileService logFileService;
/**
* 获取日志文件列表
*/
public List<LogFileDTO> getLogFileList() {
return logFileService.getLogFileList();
}
/**
* 获取日志文件内容
*/
public byte[] getLogFileContent(String logType) {
return logFileService.getLogFileContent(logType);
}
/**
* 获取日志文件名
*/
public String getLogFileName(String logType) {
return logFileService.getLogFileName(logType);
}
}

View File

@ -0,0 +1,44 @@
package com.agileboot.domain.system.log.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 日志文件信息DTO
*
* @author agileboot
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LogFileDTO {
/**
* 日志类型: info, error, debug
*/
private String logType;
/**
* 文件名
*/
private String fileName;
/**
* 文件是否存在
*/
private Boolean exists;
/**
* 最后修改时间
*/
private Long lastModified;
/**
* 文件大小(字节)
*/
private Long size;
}

View File

@ -0,0 +1,36 @@
package com.agileboot.domain.system.log.service;
import java.util.List;
import com.agileboot.domain.system.log.dto.LogFileDTO;
/**
* 日志文件服务接口
*
* @author agileboot
*/
public interface LogFileService {
/**
* 获取可下载的日志文件列表
*
* @return 日志文件信息列表
*/
List<LogFileDTO> getLogFileList();
/**
* 根据日志类型获取日志文件字节内容
*
* @param logType 日志类型 (info, error, debug)
* @return 文件字节数组
*/
byte[] getLogFileContent(String logType);
/**
* 获取日志文件名
*
* @param logType 日志类型
* @return 文件名
*/
String getLogFileName(String logType);
}

View File

@ -0,0 +1,139 @@
package com.agileboot.domain.system.log.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode.Business;
import com.agileboot.domain.system.log.dto.LogFileDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 日志文件服务实现
*
* @author agileboot
*/
@Service
@Slf4j
public class LogFileServiceImpl implements LogFileService {
/**
* 日志路径
*/
@Value("${logging.file.path:}")
private String logPath;
/**
* 允许的日志类型
*/
private static final List<String> ALLOWED_LOG_TYPES = Arrays.asList("info", "error", "debug");
/**
* 日志类型到文件名前缀的映射
*/
private static final String LOG_FILE_PREFIX_INFO = "sys-info";
private static final String LOG_FILE_PREFIX_ERROR = "sys-error";
private static final String LOG_FILE_PREFIX_DEBUG = "sys-debug";
@Override
public List<LogFileDTO> getLogFileList() {
if (StrUtil.isEmpty(logPath)) {
return new ArrayList<>();
}
return ALLOWED_LOG_TYPES.stream()
.map(this::buildLogFileDTO)
.collect(Collectors.toList());
}
private LogFileDTO buildLogFileDTO(String logType) {
String fileName = getLogFileName(logType);
String filePath = logPath + File.separator + fileName;
File file = new File(filePath);
LogFileDTO dto = new LogFileDTO();
dto.setLogType(logType);
dto.setFileName(fileName);
dto.setExists(file.exists());
dto.setLastModified(file.exists() ? file.lastModified() : null);
dto.setSize(file.exists() ? file.length() : null);
return dto;
}
@Override
public byte[] getLogFileContent(String logType) {
// 1. 参数校验
if (StrUtil.isEmpty(logType)) {
throw new ApiException(Business.COMMON_BAD_REQUEST, "日志类型不能为空");
}
// 2. 日志类型校验 - 防止目录遍历攻击
String normalizedType = logType.toLowerCase().trim();
if (!ALLOWED_LOG_TYPES.contains(normalizedType)) {
throw new ApiException(Business.COMMON_OBJECT_NOT_FOUND,
"日志类型", normalizedType);
}
// 3. 校验日志路径配置
if (StrUtil.isEmpty(logPath)) {
throw new ApiException(Business.COMMON_OBJECT_NOT_FOUND,
"日志配置", "日志路径未配置");
}
// 4. 构建文件路径
String fileName = getLogFileName(normalizedType);
String filePath = logPath + File.separator + fileName;
// 5. 验证文件路径安全 - 确保路径在日志目录下
File logDir = new File(logPath);
File targetFile = new File(filePath);
try {
String canonicalDirPath = logDir.getCanonicalPath();
String canonicalFilePath = targetFile.getCanonicalPath();
if (!canonicalFilePath.startsWith(canonicalDirPath + File.separator)) {
throw new ApiException(Business.COMMON_FILE_NOT_ALLOWED_TO_DOWNLOAD, fileName);
}
} catch (Exception e) {
log.error("验证文件路径失败: {}", filePath, e);
throw new ApiException(Business.COMMON_FILE_NOT_ALLOWED_TO_DOWNLOAD, fileName);
}
// 6. 检查文件是否存在
if (!targetFile.exists() || !targetFile.isFile()) {
throw new ApiException(Business.COMMON_OBJECT_NOT_FOUND, "日志文件", fileName);
}
// 7. 读取文件内容
try {
return FileUtil.readBytes(targetFile);
} catch (Exception e) {
log.error("读取日志文件失败: {}", filePath, e);
throw new ApiException(Business.UPLOAD_FILE_FAILED, "读取日志文件失败: " + e.getMessage());
}
}
@Override
public String getLogFileName(String logType) {
String lowerType = logType.toLowerCase();
if ("info".equals(lowerType)) {
return LOG_FILE_PREFIX_INFO + ".log";
} else if ("error".equals(lowerType)) {
return LOG_FILE_PREFIX_ERROR + ".log";
} else if ("debug".equals(lowerType)) {
return LOG_FILE_PREFIX_DEBUG + ".log";
} else {
throw new ApiException(Business.COMMON_OBJECT_NOT_FOUND, "日志类型", logType);
}
}
}