修复流式审核点击后无输出(静默失败)

原因:旧实现在返回 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:37:18 +08:00
parent 7a2603e1f9
commit a4f479454f
3 changed files with 55 additions and 26 deletions

View File

@@ -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") @PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8")
public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file, public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType, @RequestParam("reviewType") String reviewType,

View File

@@ -58,7 +58,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
@Override @Override
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { 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 String result = p.images != null
? miMoClient.chatMultimodal(p.system, p.userText, p.images) ? miMoClient.chatMultimodal(p.system, p.userText, p.images)
: miMoClient.chatText(p.system, p.userText); : miMoClient.chatText(p.system, p.userText);
@@ -70,14 +71,21 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
@Override @Override
public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) { public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) {
// 同步校验 + 解析(出错可立即以普通异常返回),耗时的大模型调用放到异步线程 // 只在同步阶段做轻量校验 + 读取字节multipart 必须在请求线程内消费);
Prepared p = prepare(file, reviewType, position); // 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回,
// 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON前端按流读取会“静默失败”
Prepared p = prepareSync(file, reviewType, position);
String username = currentUsername(); String username = currentUsername();
long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L; long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L;
SseEmitter emitter = new SseEmitter(timeoutMs); SseEmitter emitter = new SseEmitter(timeoutMs);
Thread worker = new Thread(() -> { Thread worker = new Thread(() -> {
try { try {
// 立即推送一个 start 事件,确认通道已打开(前端据此显示“已连接”)
emitter.send(event("start", null));
// 解析文档 + 构建提示词(可能抛异常,此处会转成 SSE error
buildPrompt(p);
String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> { String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> {
try { try {
Map<String, Object> ev = new HashMap<>(); Map<String, Object> ev = new HashMap<>();
@@ -101,11 +109,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
} catch (Exception e) { } catch (Exception e) {
log.error("流式审核失败", e); log.error("流式审核失败", e);
try { try {
Map<String, Object> err = new HashMap<>();
err.put("type", "error");
Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e; Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e;
err.put("msg", cause.getMessage()); emitter.send(event("error", cause.getMessage()));
emitter.send(err);
} catch (Exception ignored) {} } catch (Exception ignored) {}
emitter.complete(); emitter.complete();
} }
@@ -116,8 +121,15 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
return emitter; return emitter;
} }
/** 校验 + 读取 + 解析 + 构建提示词 */ private Map<String, Object> event(String type, String msg) {
private Prepared prepare(MultipartFile file, String reviewType, String position) { Map<String, Object> 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()) { if (file == null || file.isEmpty()) {
throw new ServiceException("请上传文件"); throw new ServiceException("请上传文件");
} }
@@ -138,31 +150,33 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
} catch (Exception e) { } catch (Exception e) {
throw new ServiceException("读取文件失败"); throw new ServiceException("读取文件失败");
} }
Prepared p = new Prepared(); Prepared p = new Prepared();
p.reviewType = reviewType; p.reviewType = reviewType;
p.position = position; p.position = position;
p.fileName = fileName; p.fileName = fileName;
p.bytes = bytes; 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) { if (StringUtils.length(text) >= MIN_TEXT_LEN) {
p.userText = "contract".equals(reviewType) p.userText = "contract".equals(p.reviewType)
? "以下是待审核的合同全文:\n\n" + text ? "以下是待审核的合同全文:\n\n" + text
: "以下是待评估的简历内容:" : "以下是待评估的简历内容:"
+ (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + "" : "") + "\n\n" + text; + (StringUtils.isNotBlank(p.position) ? "(目标岗位:" + p.position + "" : "") + "\n\n" + text;
p.images = null; p.images = null;
} else if (DocumentParseUtil.isPdf(fileName)) { } else if (DocumentParseUtil.isPdf(p.fileName)) {
p.images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); p.images = DocumentParseUtil.renderPdfImages(p.bytes, miMoProps.getMaxImagePages());
p.userText = "contract".equals(reviewType) p.userText = "contract".equals(p.reviewType)
? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。"
: "请评估以下图片中的简历(扫描件)。" : "请评估以下图片中的简历(扫描件)。"
+ (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "" : ""); + (StringUtils.isNotBlank(p.position) ? "目标岗位:" + p.position + "" : "");
} else { } else {
throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF");
} }
return p;
} }
/** 落库(结论解析、摘要、创建人) */ /** 落库(结论解析、摘要、创建人) */

View File

@@ -115,6 +115,7 @@ export default {
savedId: null, savedId: null,
matchScore: null, matchScore: null,
riskLevel: null, riskLevel: null,
eventCount: 0,
previewUrl: '', previewUrl: '',
isPdf: false isPdf: false
@@ -170,19 +171,27 @@ export default {
try { try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', { const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
method: 'POST', method: 'POST',
headers: { Authorization: 'Bearer ' + getToken() }, headers: { Authorization: 'Bearer ' + getToken(), Accept: 'text/event-stream' },
body: fd body: fd
}) })
if (!resp.ok || !resp.body) { // 非流式响应(鉴权失败 / 后端异常被包成 JSON / HTML→ 读出来报错,避免静默
let msg = '审核失败(' + resp.status + '' const ct = resp.headers.get('content-type') || ''
try { const j = await resp.json(); if (j && j.msg) msg = j.msg } catch (e) {} 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.$modal.msgError(msg)
this.eventCount = 0
this.streaming = false this.streaming = false
return return
} }
const reader = resp.body.getReader() const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
let buf = '' let buf = ''
this.eventCount = 0
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
@@ -195,6 +204,9 @@ export default {
this.handleFrame(frame) this.handleFrame(frame)
} }
} }
if (this.eventCount === 0) {
this.$modal.msgError('未收到任何流式数据,请检查后端是否可访问 AI 服务')
}
} catch (e) { } catch (e) {
this.$modal.msgError('连接中断:' + (e.message || e)) this.$modal.msgError('连接中断:' + (e.message || e))
} finally { } finally {
@@ -210,7 +222,10 @@ export default {
if (!data) return if (!data) return
let ev let ev
try { ev = JSON.parse(data) } catch (e) { return } 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.reasoning += ev.c
this.scrollBottom() this.scrollBottom()
} else if (ev.type === 'content') { } else if (ev.type === 'content') {