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

原因:旧实现在返回 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")
public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,

View File

@@ -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<String, Object> ev = new HashMap<>();
@@ -101,11 +109,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
} catch (Exception e) {
log.error("流式审核失败", e);
try {
Map<String, Object> 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<String, Object> event(String type, String msg) {
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()) {
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;
}
/** 落库(结论解析、摘要、创建人) */

View File

@@ -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') {