From f54e28cffcee06c917b8fe0d7199bad4b0ade29d Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 02:12:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=A4=B1=E8=B4=A5=E6=80=81=E5=9C=A8?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E4=BD=93=E9=87=8C=E5=B8=A6=20status=3Dfailed?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E6=89=8D=E8=83=BD=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=96=87=E6=A1=88=E4=B8=8E=E6=AD=A3=E6=96=87?= =?UTF-8?q?=EF=BC=88issue=2010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游工作流 failed 时,后端把 reply 置为失败文案("生成失败,请稍后重试") 并照常走成功路径返回(HTTP 200 / code=0),而返回体里**没有 status**: data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"} 两端前端的 isFailedPayload(读 payload.status / payload.data.status)因此 永远落空,只能把失败文案当正文渲染(实测三个 admin 页面都会这样)。 修 issue 09 时上游每次都 failed,这条是必现的。 改动: - buildManuscriptResult 增加 failed 参数(保留三参重载,等价 failed=false, 以免破坏 issue 08 既有测试的调用形态),失败时 put("status","failed")。 - 新增 buildActivityOutlineResult(同构),activityOutlineGen 收尾改用之。 - 两个方法都在外层作用域记录 workflowFailed:失败分支里有 continue, 不能用分支内的局部布尔。 刻意不改的东西: - 失败仍走 HTTP 200 / code=0。改成 code!=0 会与既有约定冲突、影响所有调用方 的 catch 分支,本条只加一个字段即可让两端现有判定生效。 - answer 键在失败态下依然存在(issue 08 契约),只是现在有了 status 可区分。 测试:新增 AiWorkflowFailureHttpStatusTest 8/8,含反向用例—— 成功态**不得**带 status(否则 isFailedPayload 会误伤正常结果), 以及「成功态正文恰好是失败文案」时唯一判据是 status。 全量 210 跑 0 失败(2 个 ServerContainer not available 为预存问题)。 验证:用桩上游(probe/stub-dify-failure.py,回 workflow_finished{status:failed}) 起第二个后端实例,实测两条链路的返回体都带 status='failed' 且 answer 仍为可读文案,前端 isFailedPayload 判定为 true —— 全链路闭合。 --- .../gxwebsoft/ai/controller/AiController.java | 70 ++++++-- .../AiWorkflowFailureHttpStatusTest.java | 160 ++++++++++++++++++ 2 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureHttpStatusTest.java diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index ae2259c..6b4bf8b 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -360,6 +360,10 @@ public class AiController extends BaseController { String workflowRunId = null; String taskId = null; boolean endSent = false; + // 上游以 failed 收场时置真,用于给 HTTP 返回体带上 status(见 issue 10)。 + // 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了, + // 必须在外层作用域里记录。 + boolean workflowFailed = false; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -423,6 +427,7 @@ public class AiController extends BaseController { if (isWorkflowFailed(workflowStatus(data))) { logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}", taskId, workflowRunId); + workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); HashMap failureData = new HashMap<>(); @@ -490,8 +495,9 @@ public class AiController extends BaseController { // 必须把累积的正文放进 data 一起回:只回一句状态文案时, // 前端(uni-app 的 apiRequest 会回落 body.message)会把 // "Stream processing completed" 当成正文显示(见 issue 08)。 + // 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。 return success("Stream processing completed", - buildManuscriptResult(reply, taskId, workflowRunId)); + buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed)); } catch (Exception e) { System.out.println(e.getMessage()); for (StackTraceElement stackTraceElement : e.getStackTrace()) { @@ -517,8 +523,30 @@ public class AiController extends BaseController { * 缺失时会回落 {@code body.message}(见 issue 08)。 */ private Map buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) { + return buildManuscriptResult(reply, taskId, workflowRunId, false); + } + + /** + * 构造稿件生成的 HTTP 返回体。 + * + *

{@code answer} 键始终存在(空正文给空串):{@code ApiResult} 带 + * {@code @JsonInclude(NON_NULL)},若 {@code data} 为 null 会被整个省略, + * 前端就会回落到 {@code message}("Stream processing completed")——见 issue 08。 + * + *

失败时必须带 {@code status=failed}:否则前端拿到的是 + * {@code answer="生成失败,请稍后重试"},会把失败文案当正文渲染 + * (issue 10 实测:三个 admin 页面都会这样),且无从据此判定失败。 + * uniapp 的 {@code isFailedPayload} 读的就是这个字段。 + * + * @param failed 上游工作流是否以 failed 收场 + */ + private Map buildManuscriptResult(StringBuilder reply, String taskId, + String workflowRunId, boolean failed) { Map result = new LinkedHashMap<>(); result.put("answer", reply == null ? "" : reply.toString()); + if (failed) { + result.put("status", "failed"); + } if (StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } @@ -782,6 +810,8 @@ public class AiController extends BaseController { String conversationId = null; String messageId = null; boolean endSent = false; + // 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。 + boolean workflowFailed = false; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -829,6 +859,7 @@ public class AiController extends BaseController { if (isWorkflowFailed(workflowStatus(data))) { logger.warn("activity outline workflow failed, conversationId={}, messageId={}", conversationId, messageId); + workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); // reset=true:丢弃失败前可能已推给端的半截内容, @@ -862,15 +893,8 @@ public class AiController extends BaseController { // 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方 // (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket, // 返回体是它唯一的正文来源(见 issue 08)。 - Map outlineResult = new LinkedHashMap<>(); - outlineResult.put("answer", reply.toString()); - if (StrUtil.isNotBlank(conversationId)) { - outlineResult.put("conversationId", conversationId); - } - if (StrUtil.isNotBlank(messageId)) { - outlineResult.put("messageId", messageId); - } - return success("Stream processing completed", outlineResult); + return success("Stream processing completed", + buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed)); } catch (Exception e) { logger.error("activity outline request failed", e); sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false); @@ -879,6 +903,32 @@ public class AiController extends BaseController { } } + /** + * 构造活动策划大纲的 HTTP 返回体。 + * + *

与 {@link #buildManuscriptResult(StringBuilder, String, String, boolean)} 同构: + * {@code answer} 键始终存在(空正文给空串,避免 {@code data} 被 + * {@code @JsonInclude(NON_NULL)} 省略而让前端回落到 {@code message},见 issue 08), + * 失败时带 {@code status=failed} 以免失败文案被当正文渲染(见 issue 10)。 + * + * @param failed 上游工作流是否以 failed 收场 + */ + private Map buildActivityOutlineResult(StringBuilder reply, String conversationId, + String messageId, boolean failed) { + Map result = new LinkedHashMap<>(); + result.put("answer", reply == null ? "" : reply.toString()); + if (failed) { + result.put("status", "failed"); + } + if (StrUtil.isNotBlank(conversationId)) { + result.put("conversationId", conversationId); + } + if (StrUtil.isNotBlank(messageId)) { + result.put("messageId", messageId); + } + return result; + } + private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) { sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false); } diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureHttpStatusTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureHttpStatusTest.java new file mode 100644 index 0000000..85eb991 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureHttpStatusTest.java @@ -0,0 +1,160 @@ +package com.gxwebsoft.ai.controller; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue 10 的回归:失败态必须靠 {@code data.status} 表达,而不是靠 {@code answer} 的内容。 + * + *

缺陷回顾:上游工作流 failed 时,后端把 {@code reply} 置为失败文案 + * ({@code "生成失败,请稍后重试"})并照常走成功路径返回 + * (HTTP 200 / {@code code=0})。于是: + *

+ *   data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}
+ * 
+ * 返回体里没有 status,前端的 {@code isFailedPayload}(读 + * {@code payload.status} / {@code payload.data.status})永远落空, + * 只能把失败文案当正文渲染。 + * + *

实测(node 复刻 admin 的解析函数):三个页面都会把 + * 「生成失败,请稍后重试」渲染成正文字段。 + * + *

本测试锁定:失败时 {@code status=failed};成功时不带 {@code status} + * —— 后者同样重要,否则 {@code isFailedPayload} 会把正常结果误判为失败 + * (uniapp 的 {@code isFailedPayload} 只在明确 {@code 'failed'} 时为真)。 + */ +class AiWorkflowFailureHttpStatusTest { + + @SuppressWarnings("unchecked") + private Map buildManuscriptResult(StringBuilder reply, String taskId, + String workflowRunId, boolean failed) throws Exception { + Method method = AiController.class.getDeclaredMethod("buildManuscriptResult", + StringBuilder.class, String.class, String.class, boolean.class); + method.setAccessible(true); + try { + return (Map) method.invoke(new AiController(), reply, taskId, workflowRunId, failed); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + @SuppressWarnings("unchecked") + private Map buildOutlineResult(StringBuilder reply, String conversationId, + String messageId, boolean failed) throws Exception { + Method method = AiController.class.getDeclaredMethod("buildActivityOutlineResult", + StringBuilder.class, String.class, String.class, boolean.class); + method.setAccessible(true); + try { + return (Map) method.invoke(new AiController(), reply, conversationId, messageId, failed); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + // ------------------------------------------------- 缺陷本体:失败必须可判定 + + @Test + void 稿件失败态_返回体带status_failed() throws Exception { + Map result = buildManuscriptResult( + new StringBuilder("生成失败,请稍后重试"), "task-1", "run-1", true); + + assertEquals("failed", result.get("status"), + "失败态必须带 status,否则前端 isFailedPayload 无法判定"); + // 失败文案仍在 answer 里:前端据此显示可读提示是允许的, + // 但它必须能被 status 识别出来,不能只靠文案内容猜。 + assertEquals("生成失败,请稍后重试", result.get("answer")); + } + + @Test + void 大纲失败态_返回体带status_failed() throws Exception { + Map result = buildOutlineResult( + new StringBuilder("生成失败,请稍后重试"), "conv-1", "msg-1", true); + + assertEquals("failed", result.get("status")); + assertEquals("生成失败,请稍后重试", result.get("answer")); + } + + // ------------------------------------------------- 反向:成功态不得误报失败 + + @Test + void 稿件成功态_不得带status() throws Exception { + // 关键反向用例:若成功也带 status(哪怕是 "succeeded" 之外的值), + // 前端失败判定就会误伤正常结果。 + Map result = buildManuscriptResult( + new StringBuilder("# 标题\n\n正文"), "task-1", "run-1", false); + + assertFalse(result.containsKey("status"), + "成功态不应带 status —— 前端只认 'failed',多带字段徒增误判面"); + assertEquals("# 标题\n\n正文", result.get("answer")); + } + + @Test + void 大纲成功态_不得带status() throws Exception { + Map result = buildOutlineResult( + new StringBuilder("正文"), "conv-1", "msg-1", false); + + assertFalse(result.containsKey("status")); + assertEquals("正文", result.get("answer")); + } + + @Test + void 成功态正文恰好是失败文案时_仍以status为准() throws Exception { + // 极端情形:模型真的生成了「生成失败,请稍后重试」这几个字作为正文。 + // 没有 status 时前端无法区分,这正是要靠 status 表达失败的原因。 + Map success = buildManuscriptResult( + new StringBuilder("生成失败,请稍后重试"), null, null, false); + Map failure = buildManuscriptResult( + new StringBuilder("生成失败,请稍后重试"), null, null, true); + + // 两者 answer 完全相同,唯一的判据就是 status + assertEquals(success.get("answer"), failure.get("answer")); + assertFalse(success.containsKey("status"), "没有上游失败信号时不得臆断为 failed"); + assertEquals("failed", failure.get("status"), "有失败信号时必须以 status 表达"); + } + + // ------------------------------------------------- 与 issue 08 的契约并存 + + @Test + void 失败态_answer键仍必须存在() throws Exception { + // issue 08 的契约不能被本条破坏:answer 始终存在,空正文给空串, + // 否则 data 会被 @JsonInclude(NON_NULL) 省略、前端回落到 message。 + Map result = buildManuscriptResult(new StringBuilder(), null, null, true); + + assertTrue(result.containsKey("answer"), "answer 键必须存在(issue 08)"); + assertEquals("", result.get("answer")); + assertEquals("failed", result.get("status")); + } + + @Test + void 空reply为null_不抛异常() throws Exception { + Map result = buildManuscriptResult(null, null, null, true); + + assertNotNull(result); + assertEquals("", result.get("answer")); + assertEquals("failed", result.get("status")); + } + + @Test + void 三参重载_等价于未失败() throws Exception { + // 三参重载目前只被既有测试使用;保留它是为了不破坏 AiManuscriptHttpResultTest + // 的调用形态,这里锁定它等价于 failed=false。 + Method method = AiController.class.getDeclaredMethod("buildManuscriptResult", + StringBuilder.class, String.class, String.class); + method.setAccessible(true); + Object value = method.invoke(new AiController(), new StringBuilder("正文"), "t", "r"); + + @SuppressWarnings("unchecked") + Map result = (Map) value; + assertFalse(result.containsKey("status")); + assertEquals("正文", result.get("answer")); + assertEquals("t", result.get("taskId")); + assertEquals("r", result.get("workflowRunId")); + } +}