修复流式审核点击后无输出(静默失败)
原因:旧实现在返回 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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 落库(结论解析、摘要、创建人) */
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user