云
16 小时以前 7db504cbc6d08e50666c86a3dbd6c7da5147b630
fix(web): 修复客户端断开连接时的异常处理

- 在 GlobalExceptionHandler 中添加 AsyncRequestNotUsableException 异常处理
- 注入 HttpServletResponse 参数用于处理异步请求不可用情况
- 添加对客户端断开连接的响应处理逻辑
- 在 MesProQrExportTaskServiceImpl 中捕获 AsyncRequestNotUsableException
- 记录客户端中断下载的日志信息
- 添加相关单元测试验证异常处理逻辑
- 更新静态页面中的公司网址链接
已添加1个文件
已修改3个文件
65 ■■■■■ 文件已修改
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandlerClientAbortTest.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/static/trace.html 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java
@@ -17,6 +17,7 @@
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import com.google.common.util.concurrent.UncheckedExecutionException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.ValidationException;
@@ -34,6 +35,7 @@
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
@@ -67,6 +69,14 @@
    private final ApiErrorLogCommonApi apiErrorLogApi;
    /** å®¢æˆ·ç«¯å·²æ–­å¼€æ—¶å“åº”不可再写;注入 HttpServletResponse ä½¿ Spring MVC ç›´æŽ¥ç»“束处理。 */
    @ExceptionHandler(AsyncRequestNotUsableException.class)
    public void asyncRequestNotUsableExceptionHandler(HttpServletRequest request,
                                                      HttpServletResponse response,
                                                      AsyncRequestNotUsableException ex) {
        log.debug("[asyncRequestNotUsableExceptionHandler][客户端已断开 uri={}]", request.getRequestURI());
    }
    /**
     * å¤„理所有异常,主要是提供给 Filter ä½¿ç”¨
     * å› ä¸º Filter ä¸èµ° SpringMVC çš„æµç¨‹ï¼Œä½†æ˜¯æˆ‘们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。
yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandlerClientAbortTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.framework.web.core.handler;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.io.IOException;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class GlobalExceptionHandlerClientAbortTest {
    @Test
    void doesNotSerializeJsonAfterZipResponseBecomesUnusable() throws Exception {
        MockMvcBuilders.standaloneSetup(new ZipController())
                .setControllerAdvice(new GlobalExceptionHandler("test", null))
                .build()
                .perform(get("/zip"))
                .andExpect(status().isOk())
                .andExpect(content().bytes(new byte[0]));
    }
    @RestController
    static class ZipController {
        @GetMapping("/zip")
        public void download(HttpServletResponse response) throws IOException {
            response.setContentType("application/zip");
            response.flushBuffer();
            throw new AsyncRequestNotUsableException("client disconnected");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java
@@ -15,6 +15,7 @@
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.io.BufferedInputStream;
import java.io.File;
@@ -193,6 +194,7 @@
                response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
            }
            response.setContentLengthLong(length);
            long sent = 0;
            try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
                in.skipNBytes(start);
                OutputStream out = response.getOutputStream();
@@ -205,8 +207,19 @@
                    }
                    out.write(buffer, 0, read);
                    remaining -= read;
                    sent += read;
                }
                out.flush();
            } catch (AsyncRequestNotUsableException e) {
                // æµè§ˆå™¨å–消、网络断开或代理关闭连接时,响应已经不可写,不能再返回 JSON é”™è¯¯ã€‚
                // ZIP ä¿ç•™åˆ°æ¸…理时间,前端可重试当前分段;记录范围与进度便于排查频繁断开。
                log.warn("[download][客户端中断 taskId={}, range={}, sent={}/{} bytes: {}]",
                        id, range, sent, length, e.getMessage());
            } catch (IOException e) {
                // ZIP å“åº”头已经写入,不能交给全局异常处理器序列化 CommonResult。
                // Content-Length ä¸åŒ¹é…ä¼šè®©å®¢æˆ·ç«¯åˆ¤å®šæœ¬æ®µå¤±è´¥å¹¶é‡è¯•。
                log.error("[download][传输失败 taskId={}, range={}, sent={}/{} bytes]",
                        id, range, sent, length, e);
            }
        } finally {
            activeDownloads.computeIfPresent(id, (key, count) -> count > 1 ? count - 1 : null);
yudao-server/src/main/resources/static/trace.html
@@ -327,7 +327,7 @@
        </div>
        <div class="contact-item">
          <span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V20a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9.5 22v-6h5v6"/></svg></span>
          <span class="txt"><span class="lbl">公司网址</span><div class="val"><a href="https://www.talimu.net" target="_blank" rel="noopener">www.talimu.net</a></div></span>
          <span class="txt"><span class="lbl">公司网址</span><div class="val"><a href="https://www.tahezy.com" target="_blank" rel="noopener">www.tahezy.com</a></div></span>
        </div>
        <div class="contact-item">
          <span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/></svg></span>