From a4f479454ff13deaaff9c12aa14a3d1eb84c4fc1 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:37:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E7=82=B9=E5=87=BB=E5=90=8E=E6=97=A0=E8=BE=93=E5=87=BA?= =?UTF-8?q?=EF=BC=88=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因:旧实现在返回 SseEmitter 之前同步做文档解析/渲染,一旦抛异常会被全局 异常处理器包成 JSON(HTTP 200 + {code,msg})返回;前端按 SSE 流读取,找不到 \n\n 分隔帧便静默结束——表现为“闪一下后无输出、也无报错”。 后端: - analyzeStream 拆分 prepareSync(仅校验+读字节,必须在请求线程内)与 buildPrompt(解析/渲染/构建提示词)。buildPrompt 移入异步线程,任何异常都 转为 SSE error 事件返回,不再走 JSON 静默路径 - 线程启动即推送 start 事件,确认通道已打开 - 流式接口去掉 @Log(操作日志切面会尝试序列化 SseEmitter 返回值) 前端 add.vue: - 校验响应 content-type:非 text/event-stream(鉴权失败/异常JSON/HTML)时读出 正文并弹出错误,避免静默 - 统计收到的事件数,全程零事件时提示“未收到流式数据,请检查后端能否访问AI服务” - 处理 start 事件 注:服务端为 Undertow、未开 gzip 压缩,dev 代理默认透传,排除缓冲导致。 Co-Authored-By: Claude Fable 5 --- .../oa/controller/OaAiReviewController.java | 4 +- .../service/impl/OaAiReviewServiceImpl.java | 52 ++++++++++++------- ruoyi-ui/src/views/oa/aiReview/add.vue | 25 +++++++-- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java index eb53e6b..b424c0e 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -42,9 +42,9 @@ public class OaAiReviewController extends BaseController { } /** - * 上传并流式审核(SSE:边生成边推送,结束后落库) + * 上传并流式审核(SSE:边生成边推送,结束后落库)。 + * 不加 @Log:操作日志切面会尝试序列化返回值,SseEmitter 不适合被序列化。 */ - @Log(title = "AI审核", businessType = BusinessType.OTHER) @PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8") public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file, @RequestParam("reviewType") String reviewType, diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java index 808d897..559cec3 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -58,7 +58,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { @Override public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { - Prepared p = prepare(file, reviewType, position); + Prepared p = prepareSync(file, reviewType, position); + buildPrompt(p); String result = p.images != null ? miMoClient.chatMultimodal(p.system, p.userText, p.images) : miMoClient.chatText(p.system, p.userText); @@ -70,14 +71,21 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { @Override public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) { - // 同步校验 + 解析(出错可立即以普通异常返回),耗时的大模型调用放到异步线程 - Prepared p = prepare(file, reviewType, position); + // 只在同步阶段做轻量校验 + 读取字节(multipart 必须在请求线程内消费); + // 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回, + // 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON(前端按流读取会“静默失败”)。 + Prepared p = prepareSync(file, reviewType, position); String username = currentUsername(); long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L; SseEmitter emitter = new SseEmitter(timeoutMs); Thread worker = new Thread(() -> { try { + // 立即推送一个 start 事件,确认通道已打开(前端据此显示“已连接”) + emitter.send(event("start", null)); + // 解析文档 + 构建提示词(可能抛异常,此处会转成 SSE error) + buildPrompt(p); + String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> { try { Map ev = new HashMap<>(); @@ -101,11 +109,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { } catch (Exception e) { log.error("流式审核失败", e); try { - Map err = new HashMap<>(); - err.put("type", "error"); Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e; - err.put("msg", cause.getMessage()); - emitter.send(err); + emitter.send(event("error", cause.getMessage())); } catch (Exception ignored) {} emitter.complete(); } @@ -116,8 +121,15 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { return emitter; } - /** 校验 + 读取 + 解析 + 构建提示词 */ - private Prepared prepare(MultipartFile file, String reviewType, String position) { + private Map event(String type, String msg) { + Map ev = new HashMap<>(); + ev.put("type", type); + if (msg != null) ev.put("msg", msg); + return ev; + } + + /** 同步阶段:校验 + 读取字节(不解析,快) */ + private Prepared prepareSync(MultipartFile file, String reviewType, String position) { if (file == null || file.isEmpty()) { throw new ServiceException("请上传文件"); } @@ -138,31 +150,33 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { } catch (Exception e) { throw new ServiceException("读取文件失败"); } - Prepared p = new Prepared(); p.reviewType = reviewType; p.position = position; p.fileName = fileName; p.bytes = bytes; - p.system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); + return p; + } - String text = truncate(DocumentParseUtil.extractText(fileName, bytes)); + /** 解析文档 + 构建提示词(耗时/可能抛异常的部分) */ + private void buildPrompt(Prepared p) { + p.system = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position); + String text = truncate(DocumentParseUtil.extractText(p.fileName, p.bytes)); if (StringUtils.length(text) >= MIN_TEXT_LEN) { - p.userText = "contract".equals(reviewType) + p.userText = "contract".equals(p.reviewType) ? "以下是待审核的合同全文:\n\n" + text : "以下是待评估的简历内容:" - + (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + ")" : "") + "\n\n" + text; + + (StringUtils.isNotBlank(p.position) ? "(目标岗位:" + p.position + ")" : "") + "\n\n" + text; p.images = null; - } else if (DocumentParseUtil.isPdf(fileName)) { - p.images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); - p.userText = "contract".equals(reviewType) + } else if (DocumentParseUtil.isPdf(p.fileName)) { + p.images = DocumentParseUtil.renderPdfImages(p.bytes, miMoProps.getMaxImagePages()); + p.userText = "contract".equals(p.reviewType) ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" : "请评估以下图片中的简历(扫描件)。" - + (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "。" : ""); + + (StringUtils.isNotBlank(p.position) ? "目标岗位:" + p.position + "。" : ""); } else { throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); } - return p; } /** 落库(结论解析、摘要、创建人) */ diff --git a/ruoyi-ui/src/views/oa/aiReview/add.vue b/ruoyi-ui/src/views/oa/aiReview/add.vue index d1745d4..8455cc6 100644 --- a/ruoyi-ui/src/views/oa/aiReview/add.vue +++ b/ruoyi-ui/src/views/oa/aiReview/add.vue @@ -115,6 +115,7 @@ export default { savedId: null, matchScore: null, riskLevel: null, + eventCount: 0, previewUrl: '', isPdf: false @@ -170,19 +171,27 @@ export default { try { const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', { method: 'POST', - headers: { Authorization: 'Bearer ' + getToken() }, + headers: { Authorization: 'Bearer ' + getToken(), Accept: 'text/event-stream' }, body: fd }) - if (!resp.ok || !resp.body) { - let msg = '审核失败(' + resp.status + ')' - try { const j = await resp.json(); if (j && j.msg) msg = j.msg } catch (e) {} + // 非流式响应(鉴权失败 / 后端异常被包成 JSON / HTML)→ 读出来报错,避免静默 + const ct = resp.headers.get('content-type') || '' + if (!resp.ok || !resp.body || ct.indexOf('text/event-stream') === -1) { + let msg = '审核失败(HTTP ' + resp.status + ')' + try { + const txt = await resp.text() + try { const j = JSON.parse(txt); if (j && (j.msg || j.message)) msg = j.msg || j.message } + catch (e) { if (txt) msg = txt.slice(0, 200) } + } catch (e) {} this.$modal.msgError(msg) + this.eventCount = 0 this.streaming = false return } const reader = resp.body.getReader() const decoder = new TextDecoder('utf-8') let buf = '' + this.eventCount = 0 // eslint-disable-next-line no-constant-condition while (true) { const { value, done } = await reader.read() @@ -195,6 +204,9 @@ export default { this.handleFrame(frame) } } + if (this.eventCount === 0) { + this.$modal.msgError('未收到任何流式数据,请检查后端是否可访问 AI 服务') + } } catch (e) { this.$modal.msgError('连接中断:' + (e.message || e)) } finally { @@ -210,7 +222,10 @@ export default { if (!data) return let ev try { ev = JSON.parse(data) } catch (e) { return } - if (ev.type === 'reasoning') { + this.eventCount++ + if (ev.type === 'start') { + // 通道已打开,等待解析/生成 + } else if (ev.type === 'reasoning') { this.reasoning += ev.c this.scrollBottom() } else if (ev.type === 'content') {