From 7cc0674b901ac9908dabe65403a6ddec3b973ea4 Mon Sep 17 00:00:00 2001 From: dzq Date: Tue, 6 Jan 2026 15:00:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=97=A5=E5=BF=97):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加日志文件下载相关功能,包括: 1. 新增LogFileDTO用于传输日志文件信息 2. 实现LogFileService接口及其实现类处理日志文件操作 3. 添加日志下载应用服务LogDownloadApplicationService 4. 创建管理后台和客户端API的日志下载控制器 5. 在SecurityConfig中添加日志下载路径权限 6. 新增相关错误码定义 --- .../system/LogDownloadController.java | 79 ++++++++++ .../customize/config/SecurityConfig.java | 3 +- .../api/controller/LogDownloadController.java | 76 ++++++++++ .../manage/AdminLoginController.java | 10 +- .../common/exception/error/ErrorCode.java | 8 + .../log/LogDownloadApplicationService.java | 44 ++++++ .../domain/system/log/dto/LogFileDTO.java | 44 ++++++ .../system/log/service/LogFileService.java | 36 +++++ .../log/service/LogFileServiceImpl.java | 139 ++++++++++++++++++ 9 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 agileboot-admin/src/main/java/com/agileboot/admin/controller/system/LogDownloadController.java create mode 100644 agileboot-api/src/main/java/com/agileboot/api/controller/LogDownloadController.java create mode 100644 agileboot-domain/src/main/java/com/agileboot/domain/system/log/LogDownloadApplicationService.java create mode 100644 agileboot-domain/src/main/java/com/agileboot/domain/system/log/dto/LogFileDTO.java create mode 100644 agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileService.java create mode 100644 agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileServiceImpl.java diff --git a/agileboot-admin/src/main/java/com/agileboot/admin/controller/system/LogDownloadController.java b/agileboot-admin/src/main/java/com/agileboot/admin/controller/system/LogDownloadController.java new file mode 100644 index 0000000..c9b1882 --- /dev/null +++ b/agileboot-admin/src/main/java/com/agileboot/admin/controller/system/LogDownloadController.java @@ -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> getLogFileList() { + List fileList = logDownloadService.getLogFileList(); + return ResponseDTO.ok(fileList); + } + + /** + * 下载日志文件 + * + * @param logType 日志类型: info, error, debug + */ + @Operation(summary = "下载日志文件") + @GetMapping("/download/{logType}") + public ResponseEntity 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()); + } + } + +} diff --git a/agileboot-admin/src/main/java/com/agileboot/admin/customize/config/SecurityConfig.java b/agileboot-admin/src/main/java/com/agileboot/admin/customize/config/SecurityConfig.java index c724546..651557f 100644 --- a/agileboot-admin/src/main/java/com/agileboot/admin/customize/config/SecurityConfig.java +++ b/agileboot-admin/src/main/java/com/agileboot/admin/customize/config/SecurityConfig.java @@ -137,7 +137,8 @@ public class SecurityConfig { .antMatchers("/login", "/register", "/captchaImage", "/api/**", "/file/**").anonymous() .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll() - .antMatchers("/manual/**", "/qywx/**", "/test/**", "/monitor/**", "/getQyUserinfo", "/getConfig").permitAll() + .antMatchers("/manual/**", "/qywx/**", "/test/**", "/monitor/**", + "/system/log/**", "/getQyUserinfo", "/getConfig").permitAll() // TODO this is danger. .antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-resources/**").anonymous() diff --git a/agileboot-api/src/main/java/com/agileboot/api/controller/LogDownloadController.java b/agileboot-api/src/main/java/com/agileboot/api/controller/LogDownloadController.java new file mode 100644 index 0000000..7e6efd7 --- /dev/null +++ b/agileboot-api/src/main/java/com/agileboot/api/controller/LogDownloadController.java @@ -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> getLogFileList() { + List fileList = logDownloadService.getLogFileList(); + return ResponseDTO.ok(fileList); + } + + /** + * 下载日志文件 + * + * @param logType 日志类型: info, error, debug + */ + @Operation(summary = "下载日志文件") + @GetMapping("/download/{logType}") + public ResponseEntity 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()); + } + } + +} diff --git a/agileboot-api/src/main/java/com/agileboot/api/controller/manage/AdminLoginController.java b/agileboot-api/src/main/java/com/agileboot/api/controller/manage/AdminLoginController.java index 7fade55..da17c61 100644 --- a/agileboot-api/src/main/java/com/agileboot/api/controller/manage/AdminLoginController.java +++ b/agileboot-api/src/main/java/com/agileboot/api/controller/manage/AdminLoginController.java @@ -24,6 +24,8 @@ 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 java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -51,13 +53,13 @@ public class AdminLoginController { SysUserEntity userEntity = userService.getById(userId); if (userEntity == null) { log.warn("用户不存在, userId: {}", userId); - return ResponseDTO.ok(List.of()); // 返回空列表而不是错误 + return ResponseDTO.ok(new ArrayList<>()); // 返回空列表而不是错误 } // 2. 检查用户状态 (可选,根据需求决定) if (userEntity.getStatus() != 1) { // 1表示正常状态 log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus()); - return ResponseDTO.ok(List.of()); + return ResponseDTO.ok(new ArrayList<>()); } // 4. 构造 SystemLoginUser (isAdmin设置为false) @@ -87,12 +89,12 @@ public class AdminLoginController { SysUserEntity userEntity = userService.getById(userId); if (userEntity == null) { log.warn("用户不存在, userId: {}", userId); - return ResponseDTO.ok(List.of()); + return ResponseDTO.ok(new ArrayList<>()); } if (userEntity.getStatus() != 1) { log.warn("用户状态异常, userId: {}, status: {}", userId, userEntity.getStatus()); - return ResponseDTO.ok(List.of()); + return ResponseDTO.ok(new ArrayList<>()); } SystemLoginUser loginUser = new SystemLoginUser( diff --git a/agileboot-common/src/main/java/com/agileboot/common/exception/error/ErrorCode.java b/agileboot-common/src/main/java/com/agileboot/common/exception/error/ErrorCode.java index 0400ada..8b62eb8 100644 --- a/agileboot-common/src/main/java/com/agileboot/common/exception/error/ErrorCode.java +++ b/agileboot-common/src/main/java/com/agileboot/common/exception/error/ErrorCode.java @@ -86,6 +86,14 @@ public enum ErrorCode implements ErrorCodeInterface { 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_WRONG_USER_PASSWORD(10201, "用户密码错误,请重输", "Business.LOGIN_WRONG_USER_PASSWORD"), diff --git a/agileboot-domain/src/main/java/com/agileboot/domain/system/log/LogDownloadApplicationService.java b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/LogDownloadApplicationService.java new file mode 100644 index 0000000..3bf6614 --- /dev/null +++ b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/LogDownloadApplicationService.java @@ -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 getLogFileList() { + return logFileService.getLogFileList(); + } + + /** + * 获取日志文件内容 + */ + public byte[] getLogFileContent(String logType) { + return logFileService.getLogFileContent(logType); + } + + /** + * 获取日志文件名 + */ + public String getLogFileName(String logType) { + return logFileService.getLogFileName(logType); + } + +} diff --git a/agileboot-domain/src/main/java/com/agileboot/domain/system/log/dto/LogFileDTO.java b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/dto/LogFileDTO.java new file mode 100644 index 0000000..149a4c7 --- /dev/null +++ b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/dto/LogFileDTO.java @@ -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; + +} diff --git a/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileService.java b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileService.java new file mode 100644 index 0000000..41dd5e1 --- /dev/null +++ b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileService.java @@ -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 getLogFileList(); + + /** + * 根据日志类型获取日志文件字节内容 + * + * @param logType 日志类型 (info, error, debug) + * @return 文件字节数组 + */ + byte[] getLogFileContent(String logType); + + /** + * 获取日志文件名 + * + * @param logType 日志类型 + * @return 文件名 + */ + String getLogFileName(String logType); + +} diff --git a/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileServiceImpl.java b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileServiceImpl.java new file mode 100644 index 0000000..6fd8fc0 --- /dev/null +++ b/agileboot-domain/src/main/java/com/agileboot/domain/system/log/service/LogFileServiceImpl.java @@ -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 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 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); + } + } + +}