文件下载异常错误友好提示:前端 Vue3 +后端 Spring Boot
目标提示:模板文件不存在
目前提示:文件下载失败
但已经接收到的响应已经是:{"code":1,"message":"模板文件不存在","data":null}

原来的代码:
前端:
src\utils\request.ts
/** * 请求工具,定制 axios 请求实例 */ import router from "@/router"; import { useTokenStore } from "@/stores"; import { readBlobAsText } from "@/utils"; import axios from "axios"; import { ElMessage } from "element-plus"; // 定义变量 baseURL,记录公共的前缀,通过代理,将公共的前缀统一转换为访问路径,如:/api 转换为 http://localhost:8080 // const baseURL = 'http://localhost:8080'; const baseURL = "/api"; // 调用 create(),获取到请求实例 instance const instance = axios.create({ baseURL }); // 添加请求拦截器 instance.interceptors.request.use( // 请求前的回调 (config) => { // 获取token const { token } = useTokenStore(); // 判断有没有token if (token) { // 将 token 添加到请求头 config.headers.Authorization = token; } return config; }, // 请求错误的回调 (error) => { Promise.reject(error); } ); // 添加响应拦截器 instance.interceptors.response.use( async (response) => { // 判断业务状态码,0 if (response.data.code === 0) { // 操作成功,返回响应数据 return response.data; } // 判断业务状态码,非0 if (response.data.code) { // 操作失败 // ElMessage.error(response.data.message ? response.data.message : "业务状态码异常,操作失败!"); const message = response.data.message || "业务状态码异常,操作失败!"; // if (message.includes("\n")) { // ElMessage.error({ // message: message.replace(/\n/g, "<br>"), // dangerouslyUseHTMLString: true // }); // } else { // ElMessage.error(message); // } // 统一将换行符替换为 <br>(没有换行则原样输出) const htmlMessage = message.replace(/\n/g, "<br>"); // 统一使用 HTML 模式,支持换行显示 ElMessage.error({ message: htmlMessage, dangerouslyUseHTMLString: true }); // 异步的状态转换为失败,返回响应数据 return Promise.reject(response.data); } // blob 类型的响应,用于接收二进制数据流,处理文件下载响应 if (response.config.responseType === "blob") { // 小文件,防止后端防抖直接回传错误文件(设定错误文件小于1kB) if (response.data.size < 1024) { try { // 读取 Blob 对象的内容(文件内容) const errorText = await response.data.text(); // const errorText = await readBlobAsText(response.data); if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { // ElMessage.error("文件下载失败"); // 兼容处理下载正常的小文件,直接返回整个 response 对象 return response; } } catch { ElMessage.error("文件下载失败"); } // 异步的状态转换为失败,返回响应数据 return Promise.reject(response.data); } // 大文件,直接返回整个 response 对象 return response; } }, async (error) => { // 401 未授权 if (error.response.status === 401) { // 跳转到登录页面 router.push("/login"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } // 429 防抖错误(请求过于频繁) if (error.response.status === 429) { ElMessage.error("操作过于频繁,请稍后再试!"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } // 处理文件下载的错误文件响应(设定错误文件小于1kB),防止后端没有传回 429 状态码 if ( error.config?.responseType === "blob" && error.response?.data instanceof Blob && error.response?.data.size < 1024 ) { try { // 读取 Blob 对象的内容(文件内容) // const errorText = await error.response.data.text(); const errorText = await readBlobAsText(error.response.data); if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { ElMessage.error("文件下载失败"); } } catch { ElMessage.error("文件下载失败"); } return Promise.reject(error); } // 处理普通错误响应 const errorData = error.response.data || {}; ElMessage.error(errorData.msg || errorData.message || "服务异常!"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } ); /** * 默认导出 axios 请求实例 * * 该实例已预配置: * - 基础URL为"/api" * - 请求拦截器自动添加token到Authorization头部 * - 响应拦截器处理业务状态码、错误提示和文件下载等场景 * * @example * // 使用示例 * import { request } from '@/utils' * * // 发送GET请求 * request.get('/users') * * // 发送POST请求 * request.post('/users', { name: 'John' }) * * @returns {import('axios').AxiosInstance} 配置好的axios实例 */ export default instance; 后端:
src/main/java/com/weiyu/controller/CapitalInfoController.java
/** * 使用模板导出数据 * * @param queryDTO 查询对象 * @return {@link ResponseEntity}<{@link Resource}> */ @PostMapping("/export-data-by-template") @Debounce(keyType = Debounce.KeyType.USER, value = -1) // value=-1 表示使用配置值 public ResponseEntity<Resource> exportDataByTemplate(@RequestBody @Valid CapitalInfoQueryDTO queryDTO) throws JsonProcessingException { String endpoint = "/capital/info/export-data-by-template"; String method = "exportDataByTemplate"; // 对日志输出进行HTML转义(防XSS),使用自定义工具类SecurityUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> // log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, SecurityUtils.safeForLog(queryDTO)); // 对日志输出进行HTML转义(防XSS),使用第三方工具类Apache Commons Text的StringEscapeUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> ObjectMapper mapper = new ObjectMapper(); String jsonDTO = mapper.writeValueAsString(queryDTO); log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, StringEscapeUtils.escapeHtml4(jsonDTO)); try { // 获取导出数据文件路径 String filePath = capitalInfoService.exportFileByTemplate(queryDTO); // 创建文件路径 Path path = Paths.get(filePath); // 创建资源 Resource resource = new UrlResource(path.toUri()); // 资源不存在 if (!resource.exists()) { return ResponseEntity.notFound().build(); } log.info("【资金信息】使用模板导出数据成功,{},{}", endpoint, method); // 返回响应实体 return ResponseEntity // 设置状态 .ok() // 设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流 .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(resource.contentLength()) // 设置响应标头,添加属性 Content-Disposition,Content-Disposition就是当用户想把请求所得的内容存为一个文件的时候提供一个默认的文件名。 // 其属性值必须要加上attachment,如: attachment;filename="name.xlsx",就是文件名称的信息,并且文件名称需要用双引号包裹(不支持中文编码,需要编码转换) // 设置内容处置为附件,并指定文件名,到时前端就可以解析这个响应头拿到这个文件名称进行下载 // .header("Content-Disposition", "attachment;filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) +"\"") // 实际测试发现文件名称不用双引号包裹,也是可以达到需求目标,并且前端通过正则表达式解析出文件名称时还简单一些 // 文件名通常放在双引号内,如果文件名包含空格或特殊字符,使用双引号是必要的 .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(FileUtils.getFileName(filePath), StandardCharsets.UTF_8)) // 设置响应消息体为 resource .body(resource); } catch (BusinessException e) { log.error("【资金信息】使用模板导出数据失败,模板文件不存在,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() } catch (ResourceNotFoundException e) { log.error("【资金信息】使用模板导出数据失败,文件不存在,{},{}", endpoint, method); return ResponseEntity.notFound().build(); } catch (Exception e) { log.error("【资金信息】使用模板导出数据失败,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() } }目前你的 Controller 方法返回类型为 ResponseEntity<Resource>,成功时返回文件流,但失败时无法直接返回 JSON 错误信息(因为 body 类型不匹配)。要向前端返回友好的错误信息,有以下两种推荐方案:
方案一:使用全局异常处理器(推荐)
优点:Controller 代码简洁,统一处理异常,返回结构一致。
- 创建全局异常处理类,捕获业务异常并返回
Result格式的错误响应。
java
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity<Result<Object>> handleBusinessException(BusinessException e) { log.error("业务异常:{}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error(e.getMessage())); // 假设 Result.error 返回 Result<Object> } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<Result<Object>> handleResourceNotFoundException(ResourceNotFoundException e) { log.error("资源不存在:{}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Result.error("文件不存在")); } @ExceptionHandler(Exception.class) public ResponseEntity<Result<Object>> handleException(Exception e) { log.error("系统异常:{}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error("服务器内部错误")); } }
- 简化你的 Controller 方法,移除所有 catch 块,直接抛出异常:
java
@PostMapping("/export-data-by-template") @Debounce(...) public ResponseEntity<Resource> exportDataByTemplate(@RequestBody @Valid CapitalInfoQueryDTO queryDTO) throws IOException { // 日志处理(略) String filePath = capitalInfoService.exportFileByTemplate(queryDTO); Path path = Paths.get(filePath); Resource resource = new UrlResource(path.toUri()); if (!resource.exists()) { throw new ResourceNotFoundException(); // 让全局处理器处理 } // 成功返回文件 return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(FileUtils.getFileName(filePath), StandardCharsets.UTF_8)) .body(resource); }
注意:全局处理器中返回的 ResponseEntity<Result<Object>> 与 Controller 返回的 ResponseEntity<Resource> 不冲突,因为异常由 Spring 在外部处理。
方案二:修改返回类型为 ResponseEntity<?>
如果你不想引入全局处理器,可直接修改 Controller 方法的返回类型,并在 catch 中返回错误信息。
- 修改方法签名:
java
public ResponseEntity<?> exportDataByTemplate(...)
- 在 catch 中返回错误信息:
java
catch (BusinessException e) { log.error("...", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("模板文件不存在")); } catch (ResourceNotFoundException e) { log.error("...", e); return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_JSON) .body(Result.error("文件不存在")); } catch (Exception e) { log.error("...", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) .body(Result.error("服务器内部错误")); }
成功时保持不变,返回 ResponseEntity<Resource> 也是合法的,因为 Resource 是 Object 的子类。
补充说明
- 无论哪种方案,前端接收到非 200 状态码后,应从响应体中解析错误信息(JSON 格式)。
- 确保
Result类有合适的构造方法,例如Result.error(String message)。 - 如果使用方案一,请确保全局异常处理器生效(通常通过 Spring 组件扫描自动注册)。
推荐采用方案一,它使 Controller 关注正常流程,异常处理集中管理,更符合 RESTful 设计。
在文件下载请求中,前端设置了 responseType: 'blob',导致后端返回的 JSON 错误信息被当作 Blob 处理。当前代码仅检查了“操作过于频繁”的文本,其他情况统一提示“文件下载失败”,因此无法显示“模板文件不存在”的真实错误信息。
需要在错误处理逻辑中,读取 Blob 内容并尝试解析为 JSON,提取其中的 message 字段进行展示。以下是修改后的 error 回调部分(仅显示相关代码段):
javascript
// 添加响应拦截器 instance.interceptors.response.use( // 成功回调... async (response) => { // ... 原有成功回调代码保持不变 }, async (error) => { // 401 未授权 if (error.response.status === 401) { router.push("/login"); return Promise.reject(error); } // 429 防抖错误 if (error.response.status === 429) { ElMessage.error("操作过于频繁,请稍后再试!"); return Promise.reject(error); } // 处理文件下载的错误响应(responseType = 'blob') if ( error.config?.responseType === "blob" && error.response?.data instanceof Blob && error.response?.data.size < 1024 // 假定错误响应较小 ) { try { // 读取 Blob 内容为文本 const errorText = await readBlobAsText(error.response.data); // 尝试解析 JSON try { const errorJson = JSON.parse(errorText); // 从 JSON 中提取错误信息 const message = errorJson.message || errorJson.msg || "文件下载失败"; ElMessage.error(message); } catch { // 不是 JSON 格式,检查特定文本 if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { ElMessage.error("文件下载失败"); } } } catch (readError) { ElMessage.error("文件下载失败"); } return Promise.reject(error); } // 处理普通错误响应(非 blob) const errorData = error.response?.data || {}; ElMessage.error(errorData.msg || errorData.message || "服务异常!"); return Promise.reject(error); } );
修改说明:
- 在 blob 错误处理分支中,首先读取 Blob 文本。
- 尝试将文本解析为 JSON,如果成功则优先使用
message字段展示错误信息。 - 如果解析失败(说明返回的不是 JSON),再回退到原有的特定文本判断(如“操作过于频繁”)或通用提示。
这样修改后,当后端返回 {"code":1,"message":"模板文件不存在","data":null} 时,前端将正确弹出“模板文件不存在”的提示。
修改的代码:
前端:
src\utils\request.ts
/** * 请求工具,定制 axios 请求实例 */ import router from "@/router"; import { useTokenStore } from "@/stores"; import { readBlobAsText } from "@/utils"; import axios from "axios"; import { ElMessage } from "element-plus"; // 定义变量 baseURL,记录公共的前缀,通过代理,将公共的前缀统一转换为访问路径,如:/api 转换为 http://localhost:8080 // const baseURL = 'http://localhost:8080'; const baseURL = "/api"; // 调用 create(),获取到请求实例 instance const instance = axios.create({ baseURL }); // 添加请求拦截器 instance.interceptors.request.use( // 请求前的回调 (config) => { // 获取token const { token } = useTokenStore(); // 判断有没有token if (token) { // 将 token 添加到请求头 config.headers.Authorization = token; } return config; }, // 请求错误的回调 (error) => { Promise.reject(error); } ); // 添加响应拦截器 instance.interceptors.response.use( async (response) => { // 判断业务状态码,0 if (response.data.code === 0) { // 操作成功,返回响应数据 return response.data; } // 判断业务状态码,非0 if (response.data.code) { // 操作失败 // ElMessage.error(response.data.message ? response.data.message : "业务状态码异常,操作失败!"); const message = response.data.message || "业务状态码异常,操作失败!"; // if (message.includes("\n")) { // ElMessage.error({ // message: message.replace(/\n/g, "<br>"), // dangerouslyUseHTMLString: true // }); // } else { // ElMessage.error(message); // } // 统一将换行符替换为 <br>(没有换行则原样输出) const htmlMessage = message.replace(/\n/g, "<br>"); // 统一使用 HTML 模式,支持换行显示 ElMessage.error({ message: htmlMessage, dangerouslyUseHTMLString: true }); // 异步的状态转换为失败,返回响应数据 return Promise.reject(response.data); } // blob 类型的响应,用于接收二进制数据流,处理文件下载响应 if (response.config.responseType === "blob") { // 小文件,防止后端防抖直接回传错误文件(设定错误文件小于1kB) if (response.data.size < 1024) { try { // 读取 Blob 对象的内容(文件内容) const errorText = await response.data.text(); // const errorText = await readBlobAsText(response.data); if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { // ElMessage.error("文件下载失败"); // 兼容处理下载正常的小文件,直接返回整个 response 对象 return response; } } catch { ElMessage.error("文件下载失败"); } // 异步的状态转换为失败,返回响应数据 return Promise.reject(response.data); } // 大文件,直接返回整个 response 对象 return response; } }, async (error) => { // 401 未授权 if (error.response.status === 401) { // 跳转到登录页面 router.push("/login"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } // 429 防抖错误(请求过于频繁) if (error.response.status === 429) { ElMessage.error("操作过于频繁,请稍后再试!"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } // 处理文件下载的错误文件响应(设定错误文件小于1kB),防止后端没有传回 429 状态码 if ( error.config?.responseType === "blob" && error.response?.data instanceof Blob && error.response?.data.size < 1024 ) { try { // 读取 Blob 对象的内容(文件内容) // const errorText = await error.response.data.text(); const errorText = await readBlobAsText(error.response.data); // 尝试解析 JSON try { const errorJson = JSON.parse(errorText); // 从 JSON 中提取错误信息 const message = errorJson.message || "文件下载失败"; ElMessage.error(message); } catch { // 不是 JSON 格式,检查特定文本 if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { ElMessage.error("文件下载失败"); } } } catch { ElMessage.error("文件下载失败"); } return Promise.reject(error); } // 处理普通错误响应 const errorData = error.response.data || {}; ElMessage.error(errorData.msg || errorData.message || "服务异常!"); // 异步的状态转化成失败的状态,返回失败的 Promise 对象 return Promise.reject(error); } ); /** * 默认导出 axios 请求实例 * * 该实例已预配置: * - 基础URL为"/api" * - 请求拦截器自动添加token到Authorization头部 * - 响应拦截器处理业务状态码、错误提示和文件下载等场景 * * @example * // 使用示例 * import { request } from '@/utils' * * // 发送GET请求 * request.get('/users') * * // 发送POST请求 * request.post('/users', { name: 'John' }) * * @returns {import('axios').AxiosInstance} 配置好的axios实例 */ export default instance; 后端:修改返回类型为 ResponseEntity<?>
src/main/java/com/weiyu/controller/CapitalInfoController.java
/** * 使用模板导出数据 * * @param queryDTO 查询对象 * @return {@link ResponseEntity}<{@link Resource}> */ @PostMapping("/export-data-by-template") @Debounce(keyType = Debounce.KeyType.USER, value = -1) // value=-1 表示使用配置值 public ResponseEntity<?> exportDataByTemplate(@RequestBody @Valid CapitalInfoQueryDTO queryDTO) throws JsonProcessingException { String endpoint = "/capital/info/export-data-by-template"; String method = "exportDataByTemplate"; // 对日志输出进行HTML转义(防XSS),使用自定义工具类SecurityUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> // log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, SecurityUtils.safeForLog(queryDTO)); // 对日志输出进行HTML转义(防XSS),使用第三方工具类Apache Commons Text的StringEscapeUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> ObjectMapper mapper = new ObjectMapper(); String jsonDTO = mapper.writeValueAsString(queryDTO); log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, StringEscapeUtils.escapeHtml4(jsonDTO)); try { // 获取导出数据文件路径 String filePath = capitalInfoService.exportFileByTemplate(queryDTO); // 创建文件路径 Path path = Paths.get(filePath); // 创建资源 Resource resource = new UrlResource(path.toUri()); // 资源不存在 if (!resource.exists()) { return ResponseEntity.notFound().build(); } log.info("【资金信息】使用模板导出数据成功,{},{}", endpoint, method); // 返回响应实体 return ResponseEntity // 设置状态 .ok() // 设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流 .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(resource.contentLength()) // 设置响应标头,添加属性 Content-Disposition,Content-Disposition就是当用户想把请求所得的内容存为一个文件的时候提供一个默认的文件名。 // 其属性值必须要加上attachment,如: attachment;filename="name.xlsx",就是文件名称的信息,并且文件名称需要用双引号包裹(不支持中文编码,需要编码转换) // 设置内容处置为附件,并指定文件名,到时前端就可以解析这个响应头拿到这个文件名称进行下载 // .header("Content-Disposition", "attachment;filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) +"\"") // 实际测试发现文件名称不用双引号包裹,也是可以达到需求目标,并且前端通过正则表达式解析出文件名称时还简单一些 // 文件名通常放在双引号内,如果文件名包含空格或特殊字符,使用双引号是必要的 .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(FileUtils.getFileName(filePath), StandardCharsets.UTF_8)) // 设置响应消息体为 resource .body(resource); } catch (BusinessException e) { log.error("【资金信息】使用模板导出数据失败,模板文件不存在,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); //return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("模板文件不存在")); } catch (ResourceNotFoundException e) { log.error("【资金信息】使用模板导出数据失败,文件不存在,{},{}", endpoint, method); // return ResponseEntity.notFound().build(); return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("文件不存在")); } catch (Exception e) { log.error("【资金信息】使用模板导出数据失败,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); // return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("异常错误,请稍后重试")); } }应用效果:

使用全局异常处理器
package com.weiyu.exception; import lombok.Data; import lombok.EqualsAndHashCode; /** * 模板文件不存在异常 */ @Data @EqualsAndHashCode(callSuper = true) public class TemplateNotFoundException extends RuntimeException { private String templatePath; // 可选,记录缺失的模板路径(仅用于日志) public TemplateNotFoundException(String message) { super(message); } public TemplateNotFoundException(String message, String templatePath) { super(message); this.templatePath = templatePath; } public String getTemplatePath() { return templatePath; } } package com.weiyu.exception; import com.weiyu.model.Result; // 自定义的统一响应对象 import com.weiyu.util.ErrorFileResponseUtils; // 处理错误文件响应的工具类 import jakarta.servlet.http.HttpServletRequest; // 获取HTTP请求信息 import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; // Lombok日志注解 import org.springframework.http.HttpStatus; // HTTP状态码枚举 import org.springframework.util.StringUtils; // Spring字符串工具类 import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; // 异常处理器注解 import org.springframework.web.bind.annotation.RestControllerAdvice; // 控制器增强注解 import org.springframework.web.method.annotation.HandlerMethodValidationException; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import static java.util.Map.entry; /** * 全局异常处理器 * 作用:集中处理整个应用程序中控制器层抛出的异常 */ @RestControllerAdvice // 组合注解:包含 @ControllerAdvice + @ResponseBody,使返回值自动转为JSON @SuppressWarnings("unused") // 使用这个注解来抑制警告 或 使用 @Component @Slf4j public class GlobalExceptionHandler { private static final Map<String, String> FIELD_NAME_MAP = Map.ofEntries( entry("capitalNo", "资金序号"), entry("capitalName", "资金名称"), entry("capitalType", "资金类别"), entry("capitalTotal", "指标预算总额"), entry("capitalLeaveTotal", "指标剩余额"), entry("capitalValidTotal", "指标可用总额"), entry("capitalIndexType", "指标类别"), entry("capitalAccount", "资金账户"), entry("capitalSource", "资金来源"), entry("capitalIndexSource", "指标来源"), entry("capitalYear", "资金年份"), entry("capitalState", "资金状态"), entry("remark", "备注信息") ); /** * 处理防抖异常(DebounceException),同时支持普通请求和文件下载请求 * 适用场景:当检测到重复/频繁请求时抛出的自定义异常 * * @param e 捕获的防抖异常对象 * @param request HTTP请求对象 * @return 根据请求类型返回不同响应:文件下载请求返回错误文件,普通请求返回JSON错误信息 */ @ExceptionHandler(DebounceException.class) // 指定处理的异常类型 public Object handleDebounceException(DebounceException e, HttpServletRequest request) { // 1. 检查是否为文件下载请求 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 生成包含错误信息的文件响应(如txt) return ErrorFileResponseUtils.createErrorFileResponse(e); } // 2. 普通请求返回统一JSON错误格式 return Result.error(e.getMessage(), e.getCode()); } /** * 处理请求已被取消异常(RequestCancelledException) * * @param e 捕获的请求已被取消异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(RequestCancelledException.class) public Object handleRequestCancelledException(RequestCancelledException e) { log.error("请求已被取消异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 return Result.error(e.getMessage(), e.getCode()); } /** * 处理线程中断异常(InterruptedException) * * @param e 捕获的线程中断异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(InterruptedException.class) public Object handleInterruptedException(InterruptedException e) { log.error("线程已被中断异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 Thread.currentThread().interrupt(); return Result.error(e.getMessage(), 499); } /** * 处理方法参数验证异常(MethodArgumentNotValidException) * * @param e 捕获的方法参数验证异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(MethodArgumentNotValidException.class) public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { //return Result.error(Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage()); // 优先处理全局错误(@AssertTrue的错误) List<ObjectError> globalErrors = e.getBindingResult().getGlobalErrors(); if (!globalErrors.isEmpty()) { String errorMessage = globalErrors.get(0).getDefaultMessage(); return Result.error(errorMessage); } // 处理字段错误 FieldError fieldError = e.getBindingResult().getFieldError(); if (fieldError != null) { // 获取字段名称,如:capitalNo、capitalName、capitalType String fieldName = fieldError.getField(); // 获取默认信息(包括自定义信息) String defaultMessage = fieldError.getDefaultMessage(); // 检查是否为占位符,为后续扩展到配置文件 if (defaultMessage != null && defaultMessage.startsWith("{") && defaultMessage.endsWith("}")) { return Result.error("【" + defaultMessage + "】"); } // 检查是否为常见默认信息 Set<String> defaultMessages = Set.of("不能为空", "不能为null"); String fieldDescription = FIELD_NAME_MAP.get(fieldName); if (StringUtils.hasText(fieldDescription) && (defaultMessages.contains(defaultMessage) || (defaultMessage != null && defaultMessage.startsWith("个数必须在")))) { // 生成友好信息 String friendlyMessage = generateFriendlyMessage(fieldDescription, fieldError); return Result.error(friendlyMessage); } // 默认信息(包括自定义信息) return Result.error(defaultMessage); } return Result.error("参数验证失败:" + e.getBindingResult().getFieldError().getDefaultMessage()); } /** * 处理 Spring 6.1+ 统一方法参数验证异常(HandlerMethodValidationException) * 包括:@RequestParam、@PathVariable、@RequestBody 等直接标注约束的校验失败 * * @param e 捕获的Spring 6.1+ 统一方法参数验证异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(HandlerMethodValidationException.class) public Object handleHandlerMethodValidationException(HandlerMethodValidationException e) { // 提取所有参数验证失败的具体错误信息 List<String> errorDetails = e.getAllValidationResults().stream() .flatMap(validationResult -> { // 获取参数名(如果没有则使用参数位置索引) String paramName = validationResult.getMethodParameter().getParameterName(); if (paramName == null) { paramName = "arg" + validationResult.getMethodParameter().getParameterIndex(); } // 将该参数上的所有错误消息转换为字符串(支持字段级和参数级) String finalParamName = paramName; List<String> paramErrors = validationResult.getResolvableErrors().stream() .map(error -> { if (error instanceof FieldError fieldError) { // 字段错误:参数名.字段路径 : 错误消息 return finalParamName + "." + fieldError.getField() + ": " + fieldError.getDefaultMessage(); } else { // 参数对象整体错误(如 @ScriptAssert、@Valid 嵌套对象的类级别注解) return finalParamName + ": " + error.getDefaultMessage(); } }) .toList(); return paramErrors.stream(); }) .collect(Collectors.toList()); // 如果解析失败(极少情况),至少返回原始异常信息 if (errorDetails.isEmpty()) { errorDetails.add(e.getMessage()); } // 返回统一错误响应 return Result.error("参数校验失败:\n\n" + errorDetails); } /** * 处理约束违例异常(ConstraintViolationException) * 触发场景: * 1. Controller 类标注 @Validated,方法参数直接标注约束(或参数对象内嵌套验证) * 2. Service 类标注 @Validated,方法参数/返回值校验失败 * * @param e 捕获的约束违例异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(ConstraintViolationException.class) public Object handleConstraintViolationException(ConstraintViolationException e) { // 提取所有约束违例的详细错误信息 List<String> errorDetails = e.getConstraintViolations().stream() .map(this::buildConstraintViolationMessage) .collect(Collectors.toList()); // 如果解析失败(极少情况),降级返回原始异常信息 if (errorDetails.isEmpty()) { errorDetails.add(e.getMessage()); } // 返回统一错误响应 return Result.error("参数校验失败:\n\n" + errorDetails); } /** * 处理业务异常(BusinessException) * 适用场景:业务异常 */ @ExceptionHandler(BusinessException.class) public Object handleBusinessException(BusinessException e) { log.error("业务异常: {}", e.getMessage(), e); // 返回统一错误响应 return Result.error("业务异常:" + e.getMessage()); } /** * 处理模板文件不存在异常(TemplateNotFoundException) * 适用场景:模板文件不存在 */ @ExceptionHandler(TemplateNotFoundException.class) public Object handleTemplateNotFoundException(TemplateNotFoundException e) { log.error("模板文件不存在: {},请检查模板文件路径: {}", e.getMessage(), e.getTemplatePath(), e); // 返回统一错误响应 return Result.error("模板文件不存在"); } /** * 处理资源不存在异常(ResourceNotFoundException) * 适用场景:文资源文件不存在 */ @ExceptionHandler(ResourceNotFoundException.class) public Object handleResourceNotFoundException(ResourceNotFoundException e) { log.error("资源文件不存在: {}", e.getMessage(), e); // 返回统一错误响应 return Result.error("资源文件不存在"); } /** * 处理所有其他未明确指定的异常(Exception) * 作用:作为异常处理的兜底方案,确保所有异常都被处理 * * @param e 捕获的约束违例异常对象 * @param request 请求对象 * @return 返回JSON错误信息 */ @ExceptionHandler(Exception.class) // 捕获所有未被处理的异常 public Object handleException(Exception e, HttpServletRequest request) { // ❌ 禁止使用 printStackTrace 在控制台输出异常的详细堆栈跟踪信息 // e.printStackTrace(); // ✅ 规范日志记录:使用日志框架记录完整异常堆栈(参数 e 包含异常的详细堆栈跟踪信息) log.error("异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 // 1. 处理文件下载请求的异常 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 确保错误消息不为空,使用默认消息兜底 String message = StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "下载文件失败"; // 生成包含错误信息的文件响应,使用500状态码 return ErrorFileResponseUtils.createErrorFileResponse(message, HttpStatus.INTERNAL_SERVER_ERROR); } // 2. 普通请求的异常处理 return Result.error( StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "操作失败", // 消息处理 500 // 统一返回500服务器错误状态码 ); } /** * 将单个 ConstraintViolation 转换为可读的错误消息 * 格式:字段路径 : 错误消息 */ private String buildConstraintViolationMessage(ConstraintViolation<?> violation) { // 获取完整的属性路径(如:generateWorkflow.createDTOs[0].deptId) String propertyPath = violation.getPropertyPath().toString(); // 获取错误消息 String message = violation.getMessage(); // 如果路径为空(如类级别约束),只返回消息 if (propertyPath.isBlank()) { return message; } return propertyPath + ": " + message; } /** * 生成友好信息 * * @param fieldName 字段名称 * @param error 字段错误对象 * @return 信息字符串 */ private String generateFriendlyMessage(String fieldName, FieldError error) { return switch (Objects.requireNonNull(error.getCode())) { case "NotBlank", "NotNull" -> fieldName + "不能为空"; case "Size" -> fieldName + error.getDefaultMessage(); case "Pattern" -> fieldName + "格式不正确"; default -> fieldName + "验证失败"; }; } } // 检查是否存在模板文件,如果不存在,则再从项目根目录下的 public 目录获取 if (!Files.exists(appTemplatePath)) { Path publicTemplatePath = Paths.get(System.getProperty("user.dir"), "public", "资金信息导出模板.xlsx"); if (!Files.exists(publicTemplatePath)) { throw new TemplateNotFoundException("模板文件不存在,请检查路径:" + appTemplatePath + " 或 " + publicTemplatePath, appTemplatePath + " 或 " + publicTemplatePath); } templateFilePath = publicTemplatePath.toString(); } /** * 使用模板导出数据 * * @param queryDTO 查询对象 * @return {@link ResponseEntity}<{@link Resource}> */ @PostMapping("/export-data-by-template") @Debounce(keyType = Debounce.KeyType.USER, value = -1) // value=-1 表示使用配置值 public ResponseEntity<Resource> exportDataByTemplate(@RequestBody @Valid CapitalInfoQueryDTO queryDTO) throws IOException { String endpoint = "/capital/info/export-data-by-template"; String method = "exportDataByTemplate"; // 对日志输出进行HTML转义(防XSS),使用自定义工具类SecurityUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> // log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, SecurityUtils.safeForLog(queryDTO)); // 对日志输出进行HTML转义(防XSS),使用第三方工具类Apache Commons Text的StringEscapeUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> ObjectMapper mapper = new ObjectMapper(); String jsonDTO = mapper.writeValueAsString(queryDTO); log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, StringEscapeUtils.escapeHtml4(jsonDTO)); // 获取导出数据文件路径 String filePath = capitalInfoService.exportFileByTemplate(queryDTO); // 创建文件路径 Path path = Paths.get(filePath); // 创建资源 Resource resource = new UrlResource(path.toUri()); // 资源不存在则抛出异常(由全局处理器处理) if (!resource.exists()) { throw new ResourceNotFoundException("资源文件不存在"); } log.info("【资金信息】使用模板导出数据成功,{},{}", endpoint, method); // 返回响应实体 return ResponseEntity // 设置状态 .ok() // 设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流 .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(resource.contentLength()) // 设置响应标头,添加属性 Content-Disposition,Content-Disposition就是当用户想把请求所得的内容存为一个文件的时候提供一个默认的文件名。 // 其属性值必须要加上attachment,如: attachment;filename="name.xlsx",就是文件名称的信息,并且文件名称需要用双引号包裹(不支持中文编码,需要编码转换) // 设置内容处置为附件,并指定文件名,到时前端就可以解析这个响应头拿到这个文件名称进行下载 // .header("Content-Disposition", "attachment;filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) +"\"") // 实际测试发现文件名称不用双引号包裹,也是可以达到需求目标,并且前端通过正则表达式解析出文件名称时还简单一些 // 文件名通常放在双引号内,如果文件名包含空格或特殊字符,使用双引号是必要的 .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(FileUtils.getFileName(filePath), StandardCharsets.UTF_8)) // 设置响应消息体为 resource .body(resource); } 为什么会提示下载:download-file.json问题原因:当模板文件不存在时,全局异常处理器中的 handleTemplateNotFoundException 方法直接返回了 Result.error("模板文件不存在")(JSON 格式),但该请求是文件下载请求(前端设置了 responseType: 'blob'),导致浏览器将 JSON 当作文件下载,从而出现下载 download-file.json 的现象。
解决方案:在异常处理方法中增加对文件下载请求的判断,与 handleDebounceException 和通用 handleException 保持一致。
修改全局异常处理器中的三个方法
java
/** * 处理业务异常(BusinessException) */ @ExceptionHandler(BusinessException.class) public Object handleBusinessException(BusinessException e, HttpServletRequest request) { log.error("业务异常: {}", e.getMessage(), e); if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { return ErrorFileResponseUtils.createErrorFileResponse(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } return Result.error(e.getMessage(), e.getCode()); } /** * 处理模板文件不存在异常(TemplateNotFoundException) */ @ExceptionHandler(TemplateNotFoundException.class) public Object handleTemplateNotFoundException(TemplateNotFoundException e, HttpServletRequest request) { log.error("模板文件不存在: {},请检查模板文件路径: {}", e.getMessage(), e.getTemplatePath(), e); if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { return ErrorFileResponseUtils.createErrorFileResponse("模板文件不存在", HttpStatus.NOT_FOUND); } return Result.error("模板文件不存在"); } /** * 处理资源不存在异常(ResourceNotFoundException) */ @ExceptionHandler(ResourceNotFoundException.class) public Object handleResourceNotFoundException(ResourceNotFoundException e, HttpServletRequest request) { log.error("资源文件不存在: {}", e.getMessage(), e); if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { return ErrorFileResponseUtils.createErrorFileResponse("文件不存在", HttpStatus.NOT_FOUND); } return Result.error("文件不存在"); }
说明
- 所有异常处理方法均接收
HttpServletRequest参数,通过ErrorFileResponseUtils.isFileDownloadRequest(request)判断是否为文件下载请求。 - 文件下载请求时,返回一个错误文件(如
.txt),普通请求则返回 JSON 格式的Result。 - 这样既保持了统一异常处理,又兼容了文件下载场景,避免了
download-file.json问题。
控制器代码保持不变
控制器中无需任何 try-catch,直接抛出异常,由全局处理器负责转换响应格式。
最终修改的代码:
src/main/java/com/weiyu/exception/TemplateNotFoundException.java
package com.weiyu.exception; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; /** * 模板文件不存在异常 */ @Getter @Data @EqualsAndHashCode(callSuper = true) public class TemplateNotFoundException extends RuntimeException { private String templatePath; // 可选,记录缺失的模板路径(仅用于日志) public TemplateNotFoundException(String message) { super(message); } public TemplateNotFoundException(String message, String templatePath) { super(message); this.templatePath = templatePath; } }src/main/java/com/weiyu/exception/GlobalExceptionHandler.java
package com.weiyu.exception; import com.weiyu.model.Result; // 自定义的统一响应对象 import com.weiyu.util.ErrorFileResponseUtils; // 处理错误文件响应的工具类 import jakarta.servlet.http.HttpServletRequest; // 获取HTTP请求信息 import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; // Lombok日志注解 import org.springframework.http.HttpStatus; // HTTP状态码枚举 import org.springframework.util.StringUtils; // Spring字符串工具类 import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; // 异常处理器注解 import org.springframework.web.bind.annotation.RestControllerAdvice; // 控制器增强注解 import org.springframework.web.method.annotation.HandlerMethodValidationException; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import static java.util.Map.entry; /** * 全局异常处理器 * 作用:集中处理整个应用程序中控制器层抛出的异常 */ @RestControllerAdvice // 组合注解:包含 @ControllerAdvice + @ResponseBody,使返回值自动转为JSON @SuppressWarnings("unused") // 使用这个注解来抑制警告 或 使用 @Component @Slf4j public class GlobalExceptionHandler { private static final Map<String, String> FIELD_NAME_MAP = Map.ofEntries( entry("capitalNo", "资金序号"), entry("capitalName", "资金名称"), entry("capitalType", "资金类别"), entry("capitalTotal", "指标预算总额"), entry("capitalLeaveTotal", "指标剩余额"), entry("capitalValidTotal", "指标可用总额"), entry("capitalIndexType", "指标类别"), entry("capitalAccount", "资金账户"), entry("capitalSource", "资金来源"), entry("capitalIndexSource", "指标来源"), entry("capitalYear", "资金年份"), entry("capitalState", "资金状态"), entry("remark", "备注信息") ); /** * 处理防抖异常(DebounceException),同时支持普通请求和文件下载请求 * 适用场景:当检测到重复/频繁请求时抛出的自定义异常 * * @param e 捕获的防抖异常对象 * @param request HTTP请求对象 * @return 根据请求类型返回不同响应:文件下载请求返回错误文件,普通请求返回JSON错误信息 */ @ExceptionHandler(DebounceException.class) // 指定处理的异常类型 public Object handleDebounceException(DebounceException e, HttpServletRequest request) { // 1. 检查是否为文件下载请求 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 生成包含错误信息的文件响应(如txt) return ErrorFileResponseUtils.createErrorFileResponse(e); } // 2. 普通请求返回统一JSON错误格式 return Result.error(e.getMessage(), e.getCode()); } /** * 处理请求已被取消异常(RequestCancelledException) * * @param e 捕获的请求已被取消异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(RequestCancelledException.class) public Object handleRequestCancelledException(RequestCancelledException e) { log.error("请求已被取消异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 return Result.error(e.getMessage(), e.getCode()); } /** * 处理线程中断异常(InterruptedException) * * @param e 捕获的线程中断异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(InterruptedException.class) public Object handleInterruptedException(InterruptedException e) { log.error("线程已被中断异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 Thread.currentThread().interrupt(); return Result.error(e.getMessage(), 499); } /** * 处理方法参数验证异常(MethodArgumentNotValidException) * * @param e 捕获的方法参数验证异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(MethodArgumentNotValidException.class) public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { //return Result.error(Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage()); // 优先处理全局错误(@AssertTrue的错误) List<ObjectError> globalErrors = e.getBindingResult().getGlobalErrors(); if (!globalErrors.isEmpty()) { String errorMessage = globalErrors.get(0).getDefaultMessage(); return Result.error(errorMessage); } // 处理字段错误 FieldError fieldError = e.getBindingResult().getFieldError(); if (fieldError != null) { // 获取字段名称,如:capitalNo、capitalName、capitalType String fieldName = fieldError.getField(); // 获取默认信息(包括自定义信息) String defaultMessage = fieldError.getDefaultMessage(); // 检查是否为占位符,为后续扩展到配置文件 if (defaultMessage != null && defaultMessage.startsWith("{") && defaultMessage.endsWith("}")) { return Result.error("【" + defaultMessage + "】"); } // 检查是否为常见默认信息 Set<String> defaultMessages = Set.of("不能为空", "不能为null"); String fieldDescription = FIELD_NAME_MAP.get(fieldName); if (StringUtils.hasText(fieldDescription) && (defaultMessages.contains(defaultMessage) || (defaultMessage != null && defaultMessage.startsWith("个数必须在")))) { // 生成友好信息 String friendlyMessage = generateFriendlyMessage(fieldDescription, fieldError); return Result.error(friendlyMessage); } // 默认信息(包括自定义信息) return Result.error(defaultMessage); } return Result.error("参数验证失败:" + e.getBindingResult().getFieldError().getDefaultMessage()); } /** * 处理 Spring 6.1+ 统一方法参数验证异常(HandlerMethodValidationException) * 包括:@RequestParam、@PathVariable、@RequestBody 等直接标注约束的校验失败 * * @param e 捕获的Spring 6.1+ 统一方法参数验证异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(HandlerMethodValidationException.class) public Object handleHandlerMethodValidationException(HandlerMethodValidationException e) { // 提取所有参数验证失败的具体错误信息 List<String> errorDetails = e.getAllValidationResults().stream() .flatMap(validationResult -> { // 获取参数名(如果没有则使用参数位置索引) String paramName = validationResult.getMethodParameter().getParameterName(); if (paramName == null) { paramName = "arg" + validationResult.getMethodParameter().getParameterIndex(); } // 将该参数上的所有错误消息转换为字符串(支持字段级和参数级) String finalParamName = paramName; List<String> paramErrors = validationResult.getResolvableErrors().stream() .map(error -> { if (error instanceof FieldError fieldError) { // 字段错误:参数名.字段路径 : 错误消息 return finalParamName + "." + fieldError.getField() + ": " + fieldError.getDefaultMessage(); } else { // 参数对象整体错误(如 @ScriptAssert、@Valid 嵌套对象的类级别注解) return finalParamName + ": " + error.getDefaultMessage(); } }) .toList(); return paramErrors.stream(); }) .collect(Collectors.toList()); // 如果解析失败(极少情况),至少返回原始异常信息 if (errorDetails.isEmpty()) { errorDetails.add(e.getMessage()); } // 返回统一错误响应 return Result.error("参数校验失败:\n\n" + errorDetails); } /** * 处理约束违例异常(ConstraintViolationException) * 触发场景: * 1. Controller 类标注 @Validated,方法参数直接标注约束(或参数对象内嵌套验证) * 2. Service 类标注 @Validated,方法参数/返回值校验失败 * * @param e 捕获的约束违例异常对象 * @return 返回JSON错误信息 */ @ExceptionHandler(ConstraintViolationException.class) public Object handleConstraintViolationException(ConstraintViolationException e) { // 提取所有约束违例的详细错误信息 List<String> errorDetails = e.getConstraintViolations().stream() .map(this::buildConstraintViolationMessage) .collect(Collectors.toList()); // 如果解析失败(极少情况),降级返回原始异常信息 if (errorDetails.isEmpty()) { errorDetails.add(e.getMessage()); } // 返回统一错误响应 return Result.error("参数校验失败:\n\n" + errorDetails); } /** * 处理业务异常(BusinessException) * 适用场景:业务异常 * * @param e 捕获的异常对象 * @param request 请求对象 * @return 返回JSON错误信息 */ @ExceptionHandler(BusinessException.class) public Object handleBusinessException(BusinessException e, HttpServletRequest request) { log.error("异常信息: {}", e.getMessage(), e); // 1. 处理文件下载请求的异常 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 前端接收响应结果内容示例:[...] xxx // return ErrorFileResponseUtils.createErrorFileResponse(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); // 前端接收响应结果内容示例:{"code":1,"message":"xxx","data":null} return ErrorFileResponseUtils.createErrorJsonResponse(e.getMessage()); } // 2. 普通请求的异常处理,返回统一错误响应 return Result.error("业务异常:" + e.getMessage()); } /** * 处理模板文件不存在异常(TemplateNotFoundException) * 适用场景:模板文件不存在 * * @param e 捕获的异常对象 * @param request 请求对象 * @return 返回JSON错误信息 */ @ExceptionHandler(TemplateNotFoundException.class) public Object handleTemplateNotFoundException(TemplateNotFoundException e, HttpServletRequest request) { log.error("异常信息:{},模板文件路径: {}", e.getMessage(), e.getTemplatePath(), e); // 1. 处理文件下载请求的异常 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 前端接收响应结果内容示例:[/capital/info/export-data-by-template] 模板文件不存在 // return ErrorFileResponseUtils.createErrorFileResponse("模板文件不存在", HttpStatus.NOT_FOUND); // 前端接收响应结果内容示例:{"code":1,"message":"模板文件不存在","data":null} return ErrorFileResponseUtils.createErrorJsonResponse("模板文件不存在", HttpStatus.NOT_FOUND); } // 2. 普通请求的异常处理,返回统一错误响应 return Result.error("模板文件不存在"); } /** * 处理资源不存在异常(ResourceNotFoundException) * 适用场景:文资源文件不存在 * * @param e 捕获的异常对象 * @param request 请求对象 * @return 返回JSON错误信息 */ @ExceptionHandler(ResourceNotFoundException.class) public Object handleResourceNotFoundException(ResourceNotFoundException e, HttpServletRequest request) { log.error("异常信息:{}", e.getMessage(), e); // 1. 处理文件下载请求的异常 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { return ErrorFileResponseUtils.createErrorFileResponse("资源文件不存在", HttpStatus.NOT_FOUND); } // 2. 普通请求的异常处理,返回统一错误响应 return Result.error("资源文件不存在"); } /** * 处理所有其他未明确指定的异常(Exception) * 作用:作为异常处理的兜底方案,确保所有异常都被处理 * * @param e 捕获的约束违例异常对象 * @param request 请求对象 * @return 返回JSON错误信息 */ @ExceptionHandler(Exception.class) // 捕获所有未被处理的异常 public Object handleException(Exception e, HttpServletRequest request) { // ❌ 禁止使用 printStackTrace 在控制台输出异常的详细堆栈跟踪信息 // e.printStackTrace(); // ✅ 规范日志记录:使用日志框架记录完整异常堆栈(参数 e 包含异常的详细堆栈跟踪信息) log.error("异常错误: {}", e.getMessage(), e); // 记录错误消息和详细堆栈跟踪信息 // 1. 处理文件下载请求的异常 if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 确保错误消息不为空,使用默认消息兜底 String message = StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "下载文件失败"; // 生成包含错误信息的文件响应,使用500状态码 return ErrorFileResponseUtils.createErrorFileResponse(message, HttpStatus.INTERNAL_SERVER_ERROR); } // 2. 普通请求的异常处理 return Result.error( StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "操作失败", // 消息处理 500 // 统一返回500服务器错误状态码 ); } /** * 将单个 ConstraintViolation 转换为可读的错误消息 * 格式:字段路径 : 错误消息 */ private String buildConstraintViolationMessage(ConstraintViolation<?> violation) { // 获取完整的属性路径(如:generateWorkflow.createDTOs[0].deptId) String propertyPath = violation.getPropertyPath().toString(); // 获取错误消息 String message = violation.getMessage(); // 如果路径为空(如类级别约束),只返回消息 if (propertyPath.isBlank()) { return message; } return propertyPath + ": " + message; } /** * 生成友好信息 * * @param fieldName 字段名称 * @param error 字段错误对象 * @return 信息字符串 */ private String generateFriendlyMessage(String fieldName, FieldError error) { return switch (Objects.requireNonNull(error.getCode())) { case "NotBlank", "NotNull" -> fieldName + "不能为空"; case "Size" -> fieldName + error.getDefaultMessage(); case "Pattern" -> fieldName + "格式不正确"; default -> fieldName + "验证失败"; }; } } src/main/java/com/weiyu/util/ErrorFileResponseUtils.java
package com.weiyu.util; import com.weiyu.exception.DebounceException; import com.weiyu.model.Result; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.*; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import jakarta.servlet.http.HttpServletRequest; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; /** * 错误文件响应创建器 */ public class ErrorFileResponseUtils { /** * 创建错误文件响应 */ public static ResponseEntity<Resource> createErrorFileResponse(DebounceException e) { return createErrorFileResponse(e.getMessage(), HttpStatus.valueOf(e.getCode())); } /** * 创建通用错误文件响应 */ public static ResponseEntity<Resource> createErrorFileResponse(String message, HttpStatus status) { // RFC 5987 编码文件名(支持中文) String encodedFilename; // 使用当前时间戳使文件名唯一 String timestamp = String.valueOf(System.currentTimeMillis()); String filename = "error-" + timestamp + ".txt"; encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // 获取当前请求路径(用于错误信息) String requestPath = getCurrentRequestPath(); String fullMessage = "[" + requestPath + "] " + message; byte[] fullContent = fullMessage.getBytes(StandardCharsets.UTF_8); // 构建响应实体 return ResponseEntity .status(status) // 设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流 // .contentType(MediaType.APPLICATION_OCTET_STREAM) // 设置内容类型为 MediaType.TEXT_PLAIN,纯文本日志、错误消息、简单 API 响应。 // .contentType(MediaType.TEXT_PLAIN) // 设置内容类型为 MediaType.TEXT_PLAIN,纯文本日志、错误消息、简单 API 响应,并且明确指定 UTF-8 字符集。 .contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)) .contentLength(fullContent.length) // 设置响应标头,添加属性 Content-Disposition,Content-Disposition就是当用户想把请求所得的内容存为一个文件的时候提供一个默认的文件名。 // 其属性值必须要加上attachment,如: attachment;filename="name.xlsx",就是文件名称的信息,并且文件名称需要用双引号包裹(不支持中文编码,需要编码转换) // 设置内容处置为附件,并指定文件名,到时前端就可以解析这个响应头拿到这个文件名称进行下载 // .header("Content-Disposition", "attachment;filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) +"\"") // 实际测试发现文件名称不用双引号包裹,也是可以达到需求目标,并且前端通过正则表达式解析出文件名称时还简单一些 // 文件名通常放在双引号内,如果文件名包含空格或特殊字符,使用双引号是必要的 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"error.txt\"; " + "filename*=UTF-8''" + encodedFilename) // 设置响应消息体 .body(new ByteArrayResource(fullContent)); } /** * 创建 JSON 错误响应 */ public static ResponseEntity<Result<Object>> createErrorJsonResponse(String message) { return createErrorJsonResponse(message, HttpStatus.INTERNAL_SERVER_ERROR); } /** * 创建通用 JSON 错误响应 */ public static ResponseEntity<Result<Object>> createErrorJsonResponse(String message, HttpStatus status) { Result<Object> result = Result.error(message); return ResponseEntity .status(status) .contentType(MediaType.APPLICATION_JSON) .body(result); } /** * 获取当前请求路径 */ private static String getCurrentRequestPath() { try { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); HttpServletRequest request = attributes.getRequest(); return request.getRequestURI(); } catch (IllegalStateException e) { return "unknown-path"; } } /** * 判断是否是文件下载请求 */ public static boolean isFileDownloadRequest(HttpServletRequest request) { // 1. 检查请求路径(根据您的实际路由调整) String path = request.getRequestURI(); if (path.contains("/export") || path.contains("/download") || path.contains("/exportFile")) { return true; } // 2. 检查Accept头 String acceptHeader = request.getHeader(HttpHeaders.ACCEPT); if (acceptHeader != null && acceptHeader.contains(MediaType.APPLICATION_OCTET_STREAM_VALUE)) { return true; } // 3. 检查Content-Type头(响应类型) String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE); if (contentType != null && contentType.contains(MediaType.APPLICATION_OCTET_STREAM_VALUE)) { return true; } // 4. 检查自定义标记(可选) return "true".equals(request.getHeader("X-File-Download")); } } src/main/java/com/weiyu/service/CapitalInfoService.java
// 获取模板文件路径(优先从应用目录获取) Path appTemplatePath = Paths.get(fileSaveUtils.getAppDirectory(), "template", "资金信息导出模板.xlsx"); String templateFilePath = appTemplatePath.toString(); // 检查是否存在模板文件,如果不存在,则再从项目根目录下的 public 目录获取 if (!Files.exists(appTemplatePath)) { Path publicTemplatePath = Paths.get(System.getProperty("user.dir"), "public", "资金信息导出模板.xlsx"); if (!Files.exists(publicTemplatePath)) { throw new TemplateNotFoundException("模板文件不存在,请检查路径:" + appTemplatePath + " 或 " + publicTemplatePath, appTemplatePath + " 或 " + publicTemplatePath); } templateFilePath = publicTemplatePath.toString(); } src/main/java/com/weiyu/controller/CapitalInfoController.java
/** * 使用模板导出数据 * * @param queryDTO 查询对象 * @return {@link ResponseEntity}<{@link Resource}> */ @PostMapping("/export-data-by-template") @Debounce(keyType = Debounce.KeyType.USER, value = -1) // value=-1 表示使用配置值 public ResponseEntity<Resource> exportDataByTemplate(@RequestBody @Valid CapitalInfoQueryDTO queryDTO) throws IOException { String endpoint = "/capital/info/export-data-by-template"; String method = "exportDataByTemplate"; // 对日志输出进行HTML转义(防XSS),使用自定义工具类SecurityUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> // log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, SecurityUtils.safeForLog(queryDTO)); // 对日志输出进行HTML转义(防XSS),使用第三方工具类Apache Commons Text的StringEscapeUtils,如:将<script>alert(1)</script>转换成<script>alert(1)</script> ObjectMapper mapper = new ObjectMapper(); String jsonDTO = mapper.writeValueAsString(queryDTO); log.info("【资金信息】使用模板导出数据,{},{},queryDTO = {}", endpoint, method, StringEscapeUtils.escapeHtml4(jsonDTO)); // 获取导出数据文件路径 String filePath = capitalInfoService.exportFileByTemplate(queryDTO); // 创建文件路径 Path path = Paths.get(filePath); // 创建资源 Resource resource = new UrlResource(path.toUri()); // 资源不存在则抛出异常(由全局处理器处理) if (!resource.exists()) { throw new ResourceNotFoundException("资源文件不存在"); } log.info("【资金信息】使用模板导出数据成功,{},{}", endpoint, method); // 返回响应实体 return ResponseEntity // 设置状态 .ok() // 设置内容类型为 MediaType.APPLICATION_OCTET_STREAM,八位字节的二进制数据流 .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(resource.contentLength()) // 设置响应标头,添加属性 Content-Disposition,Content-Disposition就是当用户想把请求所得的内容存为一个文件的时候提供一个默认的文件名。 // 其属性值必须要加上attachment,如: attachment;filename="name.xlsx",就是文件名称的信息,并且文件名称需要用双引号包裹(不支持中文编码,需要编码转换) // 设置内容处置为附件,并指定文件名,到时前端就可以解析这个响应头拿到这个文件名称进行下载 // .header("Content-Disposition", "attachment;filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) +"\"") // 实际测试发现文件名称不用双引号包裹,也是可以达到需求目标,并且前端通过正则表达式解析出文件名称时还简单一些 // 文件名通常放在双引号内,如果文件名包含空格或特殊字符,使用双引号是必要的 .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(FileUtils.getFileName(filePath), StandardCharsets.UTF_8)) // 设置响应消息体为 resource .body(resource); }应用效果:

为什么浏览器响应结果是这样的乱码:[/capital/info/export-data-by-template] æ¨¡æ¿æ–‡ä»¶ä¸å˜åœ¨乱码问题的根本原因是后端返回的错误文件响应中没有正确指定字符编码,导致浏览器使用默认编码(如 ISO-8859-1)解析 UTF-8 文本。需要修改 ErrorFileResponseUtils 工具类,确保生成的错误响应明确设置 Content-Type: text/plain; charset=UTF-8,并且内容以 UTF-8 编码写入。
修改步骤
1. 定位 ErrorFileResponseUtils 类
找到 com.weiyu.util.ErrorFileResponseUtils 类,通常它包含以下两个重载方法(根据全局异常处理器的调用推测):
public static Object createErrorFileResponse(DebounceException e)public static Object createErrorFileResponse(String message, HttpStatus status)
2. 修改方法实现,确保 UTF-8 编码
以返回 ResponseEntity<Resource> 为例(也可能是直接操作 HttpServletResponse,但根据全局异常处理器返回 Object 推测可能返回 ResponseEntity),修改如下:
java
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import java.nio.charset.StandardCharsets; public class ErrorFileResponseUtils { /** * 为文件下载请求创建错误文件响应(UTF-8编码) */ public static ResponseEntity<Resource> createErrorFileResponse(String message, HttpStatus status) { byte[] content = message.getBytes(StandardCharsets.UTF_8); ByteArrayResource resource = new ByteArrayResource(content) { @Override public String getFilename() { return "error.txt"; // 固定文件名,也可动态生成 } }; return ResponseEntity .status(status) .contentType(MediaType.TEXT_PLAIN) // 基础类型 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=error.txt") .header(HttpHeaders.CONTENT_TYPE, "text/plain; charset=UTF-8") // 关键:明确指定字符集 .contentLength(content.length) .body(resource); } /** * 重载:基于 DebounceException 创建错误文件响应 */ public static ResponseEntity<Resource> createErrorFileResponse(DebounceException e) { // 可以自定义消息,例如 "操作过于频繁: xxx" String message = e.getMessage() != null ? e.getMessage() : "操作过于频繁,请稍后再试"; return createErrorFileResponse(message, HttpStatus.TOO_MANY_REQUESTS); // 429 } }
如果工具类当前返回的是 Object 但实际返回 ResponseEntity<?>,上述修改是兼容的。如果当前是直接写入 HttpServletResponse,则需修改为:
java
public static void createErrorFileResponse(String message, HttpStatus status, HttpServletResponse response) throws IOException { response.setStatus(status.value()); response.setCharacterEncoding("UTF-8"); response.setContentType("text/plain; charset=UTF-8"); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=error.txt"); response.getWriter().write(message); }
3. 全局异常处理器保持不变
修改后,所有调用 ErrorFileResponseUtils.createErrorFileResponse 的地方都会自动生成 UTF-8 编码的错误文件,浏览器下载后将正常显示中文。
验证
- 重新触发模板文件不存在的场景,浏览器应下载
error.txt文件,打开后内容为“模板文件不存在”,无乱码。 - 前端拦截器中读取 blob 并展示消息的逻辑无需改动(因为 blob 内容本身已正确编码)。
补充说明
如果项目中还有其他地方生成错误文件响应(如 DebounceException、BusinessException 等),均会受益于此修改。确保所有重载方法都遵循相同的编码规范。
在 ErrorFileResponseUtils.createErrorFileResponse 方法中,虽然已经使用 UTF-8 编码生成了响应内容,但响应头 Content-Type 仅设置为 MediaType.TEXT_PLAIN,缺少字符集声明(charset=UTF-8),导致浏览器可能使用默认编码(如 ISO-8859-1)解码,从而出现乱码。
修改方法
修改 createErrorFileResponse 方法,明确指定 Content-Type 的字符集为 UTF-8。有两种方式:
方式一:使用 MediaType 构造带字符集的对象
java
.contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8))
方式二:直接设置响应头字符串
java
.header(HttpHeaders.CONTENT_TYPE, "text/plain; charset=UTF-8")
推荐方式一,因为它更语义化且类型安全。
修改后的代码段
java
public static ResponseEntity<Resource> createErrorFileResponse(String message, HttpStatus status) { // ... 其他代码不变(文件名编码、路径获取等) // 构建响应实体 return ResponseEntity .status(status) // 明确指定 UTF-8 字符集 .contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)) .contentLength(fullContent.length) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"error.txt\"; " + "filename*=UTF-8''" + encodedFilename) .body(new ByteArrayResource(fullContent)); }
修改后效果
浏览器收到响应头 Content-Type: text/plain; charset=UTF-8 后,会使用 UTF-8 解码文件内容,中文将正常显示,不再出现 æ¨¡æ¿æ–‡ä»¶ä¸å˜åœ¨ 类乱码。
补充说明
- 全局异常处理器中所有调用
ErrorFileResponseUtils.createErrorFileResponse的地方(如DebounceException、BusinessException、TemplateNotFoundException、ResourceNotFoundException、Exception)都会自动受益于此修改,无需逐个调整。 - 如果还有其他地方直接返回错误文件响应,请确保同样遵循 UTF-8 编码和字符集声明。
为什么响应结果是这个:[/capital/info/export-data-by-template] 模板文件不存在现在返回的 [/capital/info/export-data-by-template] 模板文件不存在 是正常的、可读的中文响应,这说明之前的乱码问题已经解决!
为什么是这个结果?
在 ErrorFileResponseUtils.createErrorFileResponse 方法中,有这样一段代码:
java
String requestPath = getCurrentRequestPath(); String fullMessage = "[" + requestPath + "] " + message;
它将当前请求路径(如 /capital/info/export-data-by-template)与错误消息(如“模板文件不存在”)拼接在一起,形成完整的错误信息。这是工具类设计时故意加入的,目的是让错误文件内容包含请求上下文,方便排查问题。因此你现在看到的内容是符合预期的。
乱码问题解决了吗?
是的,你之前看到的乱码 æ¨¡æ¿æ–‡ä»¶ä¸å˜åœ¨ 是由于浏览器使用错误编码(如 ISO-8859-1)解码 UTF-8 文本导致的。现在返回的内容正常显示中文,说明:
- 后端已经正确设置了
Content-Type: text/plain; charset=UTF-8。 - 错误文件的内容以 UTF-8 编码生成。
- 浏览器收到正确的编码声明后,使用 UTF-8 解码,因此中文显示正常。
如果你想修改错误消息的格式(例如去掉路径),可以这样做:
编辑 ErrorFileResponseUtils.createErrorFileResponse 方法,将
java
String fullMessage = "[" + requestPath + "] " + message;
改为
java
String fullMessage = message; // 只保留错误消息
或者自定义其他格式。
总结
- 当前响应是预期的,乱码问题已修复。
- 如果对消息格式有特殊要求,可自行调整工具类中的拼接逻辑。
一切正常,可以继续使用了!
我是想实现这样的效果: } catch (BusinessException e) { log.error("【资金信息】使用模板导出数据失败,模板文件不存在,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); //return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("模板文件不存在")); } catch (ResourceNotFoundException e) { log.error("【资金信息】使用模板导出数据失败,文件不存在,{},{}", endpoint, method); // return ResponseEntity.notFound().build(); return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("文件不存在")); } catch (Exception e) { log.error("【资金信息】使用模板导出数据失败,{},{},异常信息:{}", endpoint, method, e.getMessage(), e); // return ResponseEntity.internalServerError().build(); // 等同 ResponseEntity.status(500).build() return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .contentType(MediaType.APPLICATION_JSON) // 明确返回 JSON .body(Result.error("异常错误,请稍后重试")); } 前端浏览器提示“模板文件不存在”,现在还是提示“文件下载失败”要实现前端直接提示“模板文件不存在”而不是“文件下载失败”,需要修改前端 axios 拦截器中处理 blob 错误的部分,使其能够从后端返回的纯文本错误文件中提取错误信息。
问题分析
当前前端 blob 错误处理逻辑:
- 读取 blob 为文本(
errorText)。 - 尝试解析为 JSON,若成功则显示
message。 - 若解析失败,则检查文本是否包含“操作过于频繁”,否则显示默认“文件下载失败”。
由于后端返回的文本是类似 [/capital/info/export-data-by-template] 模板文件不存在 的纯文本(不是 JSON),因此进入 else 分支,显示“文件下载失败”。
解决方案
修改前端拦截器,在 JSON 解析失败时,直接使用 errorText 作为错误消息(或提取出核心部分),而不是固定显示“文件下载失败”。
修改后的代码(关键部分)
找到 response 拦截器中处理 blob 错误的代码段(位于 error 回调内),修改如下:
javascript
if ( error.config?.responseType === "blob" && error.response?.data instanceof Blob && error.response?.data.size < 1024 ) { try { const errorText = await readBlobAsText(error.response.data); try { // 尝试解析 JSON const errorJson = JSON.parse(errorText); const message = errorJson.message || errorJson.msg || "文件下载失败"; ElMessage.error(message); } catch { // 不是 JSON,直接处理纯文本 if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { // 尝试提取核心错误信息(去掉开头的 [路径] 部分) let message = errorText; const match = errorText.match(/^\[.*?\]\s*(.*)/); if (match && match[1]) { message = match[1]; // 提取路径后的内容 } ElMessage.error(message || "文件下载失败"); } } } catch { ElMessage.error("文件下载失败"); } return Promise.reject(error); }
说明
- 当后端返回
[/capital/info/export-data-by-template] 模板文件不存在时,正则^\[.*?\]\s*(.*)会匹配并提取出“模板文件不存在”。 - 如果文本格式变化(如没有路径前缀),则直接使用整个文本。
- 如果连文本读取都失败,则回退到“文件下载失败”。
后端确认
后端已正确返回 UTF-8 编码的文本文件,并且响应头包含 Content-Type: text/plain; charset=UTF-8,因此前端读取的文本是正常的。
修改前端后,即可实现期望效果:当模板文件不存在时,浏览器弹出“模板文件不存在”提示,而不是“文件下载失败”。
可以修改后端,返回json吗?可以修改后端返回 JSON 格式的错误信息,但需要注意以下几点:
方案可行性分析
- 控制器方法签名:
public ResponseEntity<Resource> exportDataByTemplate(...)声明的返回类型是Resource(文件流),但全局异常处理器可以返回任意类型,它会完全覆盖控制器的响应。因此,在异常处理器中返回ResponseEntity<Result>是允许的。 - 前端响应类型:由于前端请求设置了
responseType: 'blob',即使是 JSON 响应,也会被包装成 Blob,进入 axios 的错误回调。前端需要读取 Blob 并解析 JSON,这与当前处理逻辑基本一致,只是将纯文本替换为 JSON。 - 状态码:错误时应返回非 2xx 状态码(如 404、500),确保前端进入错误回调。
修改方案
1. 在 ErrorFileResponseUtils 中添加返回 JSON 错误响应的方法
java
public static ResponseEntity<Result<Object>> createJsonErrorResponse(String message, HttpStatus status) { Result<Object> result = Result.error(message); return ResponseEntity .status(status) .contentType(MediaType.APPLICATION_JSON) .body(result); }
2. 修改全局异常处理器中处理文件下载请求的分支
将原来调用 createErrorFileResponse 的地方改为根据情况选择返回文件或 JSON。但为了保持一致性,可以统一返回 JSON(因为前端已能处理),但需要确保前端能正确解析。考虑到前端 responseType: 'blob',即使返回 JSON,也是 Blob,所以前端仍需要读取并解析。实际上,返回 JSON 可以使前端错误处理更简洁(直接解析 JSON 取 message)。
因此,修改异常处理器中的相关方法:
java
@ExceptionHandler(TemplateNotFoundException.class) public Object handleTemplateNotFoundException(TemplateNotFoundException e, HttpServletRequest request) { log.error("模板文件不存在: {},模板文件路径: {}", e.getMessage(), e.getTemplatePath(), e); if (ErrorFileResponseUtils.isFileDownloadRequest(request)) { // 返回 JSON 错误响应 return ErrorFileResponseUtils.createJsonErrorResponse("模板文件不存在", HttpStatus.NOT_FOUND); } return Result.error("模板文件不存在"); }
其他异常(如 BusinessException、ResourceNotFoundException、DebounceException、Exception 等)类似修改。
3. 前端调整(简化)
在 axios 错误拦截器中,对于 blob 错误,直接读取文本并尝试解析 JSON,取 message 字段即可:
javascript
if ( error.config?.responseType === "blob" && error.response?.data instanceof Blob && error.response?.data.size < 1024 ) { try { const errorText = await readBlobAsText(error.response.data); try { const errorJson = JSON.parse(errorText); // 直接使用 errorJson.message ElMessage.error(errorJson.message || "文件下载失败"); } catch { // 如果解析失败,降级处理(保留原有的纯文本判断) if (errorText.includes("操作过于频繁")) { ElMessage.error("操作过于频繁,请稍后再试!"); } else { ElMessage.error(errorText || "文件下载失败"); } } } catch { ElMessage.error("文件下载失败"); } return Promise.reject(error); }
优缺点
- 优点:错误信息格式统一(JSON),前端解析逻辑简单,不需要针对纯文本做正则提取。
- 缺点:需要修改异常处理器和工具类,且返回 JSON 的响应头是
application/json,但前端仍以 blob 方式接收,虽然可行但语义上不够清晰。另外,如果将来有其他文件下载接口,也需要保持一致。
建议
如果希望改动最小且快速解决问题,采用之前的前端正则提取方案即可;如果希望统一错误格式、便于后续维护,则采用后端返回 JSON 的方案。两种方案都能实现用户需求。