From 23a7f001cc8296ecaf99251ef51287a0657f05dd Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 24 Sep 2026 03:44:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=88=9B=E6=84=8F=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E5=8E=9F=E5=A7=8B=E6=8A=A5=E6=96=87=E5=90=90?= =?UTF-8?q?=E7=BB=99=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dify 以 workflow_finished + data.status=failed + outputs={} 收场时,后端只搬运 字段、从不读 status,把一个失败结果包成 success 下发,且把整包报文塞进 raw; 前端 formatCreativeResult 取不到业务字段就落到 text || stringifyUnknown(payload), 整段原始 JSON(含 workflow_id / task_id / req_id 与上游错误栈)进了聊天气泡。 - 新增 workflowStatus / isWorkflowFailed:失败时只回 status + 可读 answer, 不下发 outputs / raw,不携带上游错误文本 - executeCreativeAssistant 的 workflow_finished 分支先判失败,命中则直接返回, 不再用流式片段或 taskId 覆盖失败文案 - 失败报文顶层 answer 是 workflow_run_id(UUID),故失败文案用固定字符串, 不取自 answer - 新增 AiCreativeFailureResultTest:13 个用例,样本为 issue 原文的真实失败报文 Refs: .scratch/ai-assistant/issues/01-creative-assistant-raw-json-bubble.md --- .../gxwebsoft/ai/controller/AiController.java | 65 +++++- .../AiCreativeFailureResultTest.java | 216 ++++++++++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeFailureResultTest.java diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 4727984..e0f9820 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -53,6 +53,14 @@ public class AiController extends BaseController { private static final Logger logger = LoggerFactory.getLogger(AiController.class); private static final long AI_REQUEST_TIMEOUT = 10 * 60 * 1000L; + /** + * 创意生成工作流以失败收场时给用户看的文案。 + * + *

上游的真实失败原因(如 {@code Model disabled})属于 Dify 侧配置问题, + * 既不面向用户也不该泄露;这里统一给一句可读提示。 + */ + private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试"; + @Resource private WebSocketServer webSocketServer; @Resource @@ -847,6 +855,14 @@ public class AiController extends BaseController { } if ("message_end".equals(event) || "workflow_finished".equals(event)) { Map result = buildCreativeAssistantResult(data, json); + if (isWorkflowFailed(stringValue(result.get("status")))) { + // 失败态:buildCreativeAssistantResult 已给好可读文案, + // 这里不得再用流式片段或 taskId 覆盖,也不下发 raw。 + clearActiveAiTask(loginUserId, taskId); + logger.warn("creative assistant workflow failed, workflowRunId={}, taskId={}", + workflowRunId, taskId); + return success(result); + } if (StrUtil.isBlank(stringValue(result.get("answer"))) && reply.length() > 0) { result.put("answer", reply.toString()); } @@ -1301,6 +1317,17 @@ public class AiController extends BaseController { private Map buildCreativeAssistantResult(Map responseData, String responseBody) { Map result = new LinkedHashMap<>(); + + String status = workflowStatus(responseData); + if (isWorkflowFailed(status)) { + // 工作流失败:只回可读文案与状态,不下发 outputs / raw / 上游错误栈。 + // raw 里含 workflow_id / task_id / req_id 与上游异常文本,端上没有任何消费者, + // 一旦下发就会被前端兜底逻辑整段贴进聊天气泡(见 issue 01)。 + result.put("status", status); + result.put("answer", CREATIVE_FAILED_MESSAGE); + return result; + } + if (responseData == null) { result.put("answer", responseBody); return result; @@ -1326,7 +1353,8 @@ public class AiController extends BaseController { Object data = responseData.get("data"); if (data instanceof Map) { - Object status = ((Map) data).get("status"); + // status 已在方法开头经 workflowStatus 统一取出, + // 这里只负责下发(含顶层 status 的兜底情况)。 if (status != null) { result.put("status", status); } @@ -1343,6 +1371,41 @@ public class AiController extends BaseController { return result; } + /** + * 取 Dify 工作流事件里的执行状态。 + * + *

{@code workflow_finished} 事件把状态放在 {@code data.status}; + * 少数事件会放在顶层,两处都看,取到即返回。 + * + * @return 状态字符串;取不到时为 {@code null} + */ + private String workflowStatus(Map responseData) { + if (responseData == null) { + return null; + } + Object data = responseData.get("data"); + if (data instanceof Map) { + Object status = ((Map) data).get("status"); + if (status != null && StrUtil.isNotBlank(status.toString())) { + return status.toString(); + } + } + Object status = responseData.get("status"); + if (status != null && StrUtil.isNotBlank(status.toString())) { + return status.toString(); + } + return null; + } + + /** + * 判断工作流是否以失败收场。 + * + *

只在拿到明确失败状态时为真;状态缺失视为未失败,避免误伤正常响应。 + */ + private boolean isWorkflowFailed(String status) { + return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status)); + } + private Map buildChatMessageResult(Map responseData, String responseBody) { Map result = new LinkedHashMap<>(); if (responseData == null) { diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeFailureResultTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeFailureResultTest.java new file mode 100644 index 0000000..3fb9950 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeFailureResultTest.java @@ -0,0 +1,216 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.common.core.utils.JSONUtil; +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 01:Dify 以 {@code workflow_finished} + {@code data.status = "failed"} + + * {@code data.outputs = {}} 收场时,后端原先只搬运字段、从不读 {@code status}, + * 于是把一个失败结果包成 {@code success} 下发,且把整包报文塞进 {@code raw}。 + * 前端 {@code formatCreativeResult} 取不到业务字段就落到 + * {@code text || stringifyUnknown(payload)},结果整段原始 JSON 进了聊天气泡。 + * + *

报文样本取自 issue 01「实际」一节的真实线上返回。 + */ +class AiCreativeFailureResultTest { + + /** + * issue 01 里贴的那份真实失败报文(字段与取值原样保留)。 + */ + private static final String FAILED_RESPONSE_BODY = "{" + + "\"event\":\"workflow_finished\"," + + "\"workflow_run_id\":\"b2e18332-68bd-49e6-90e2-bd9d16d428ff\"," + + "\"task_id\":\"0b1fdd88-cc6f-45b8-989d-be4f85bcff74\"," + + "\"data\":{" + + "\"id\":\"b2e18332-68bd-49e6-90e2-bd9d16d428ff\"," + + "\"workflow_id\":\"4d64b156-7b9d-4101-98eb-ec22cc8160a1\"," + + "\"status\":\"failed\"," + + "\"outputs\":{}," + + "\"error\":\"req_id: dc688258de PluginInvokeError: [models] Error: API request failed with status code 403\"," + + "\"elapsed_time\":13.045538," + + "\"total_tokens\":1620," + + "\"total_steps\":3," + + "\"exceptions_count\":1," + + "\"files\":[]" + + "}" + + "}"; + + /** + * 正常成功报文:创意链路约定的业务字段。 + */ + private static final String SUCCEEDED_RESPONSE_BODY = "{" + + "\"event\":\"workflow_finished\"," + + "\"workflow_run_id\":\"run-ok-1\"," + + "\"task_id\":\"task-ok-1\"," + + "\"data\":{" + + "\"id\":\"run-ok-1\"," + + "\"status\":\"succeeded\"," + + "\"outputs\":{\"Script_result\":\"这是生成好的脚本正文\"}" + + "}" + + "}"; + + /** + * 复刻 AiController 的调用入口:把报文解析成 Map 后交给私有的 + * {@code buildCreativeAssistantResult(responseData, responseBody)}。 + */ + private Map buildResult(String responseBody) throws Exception { + Map responseData = JSONUtil.parseObject(responseBody, Map.class); + assertNotNull(responseData, "测试样本必须是合法 JSON"); + + AiController controller = new AiController(); + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantResult", Map.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map result = (Map) method.invoke(controller, responseData, responseBody); + return result; + } + + // ------------------------------------------------------------ 失败态 + + @Test + void 失败态_不得把整包报文当作answer下发() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + Object answer = result.get("answer"); + // 原缺陷:answer 被 extractAnswer 填成 workflow_run_id(UUID)。 + // 无论最终给出失败文案还是留空,都绝不能是那份原始报文或其中的内部标识符。 + String answerText = answer == null ? "" : String.valueOf(answer); + assertFalse(answerText.contains("workflow_id"), "answer 泄露了 workflow_id: " + answerText); + assertFalse(answerText.contains("PluginInvokeError"), "answer 泄露了上游错误栈: " + answerText); + assertFalse(FAILED_RESPONSE_BODY.equals(answerText), "answer 是整包报文"); + } + + @Test + void 失败态_answer不得是workflowRunId这样的UUID() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + // 关键:失败报文里顶层 answer 被 Dify 填成了 workflow_run_id 的值, + // 前端若原样展示,气泡里就是一个裸露的 UUID —— 对用户毫无意义。 + String answerText = String.valueOf(result.get("answer")); + assertFalse(answerText.contains("b2e18332-68bd-49e6-90e2-bd9d16d428ff"), + "answer 是 UUID,不是可读文案: " + answerText); + assertEquals("生成失败,请稍后重试", answerText); + } + + @Test + void 失败态_不得下发outputs() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + // outputs 为空对象 {} 时前端会原样返回并取不到字段,是整包兜底的触发条件之一; + // 失败路径干脆不带,语义更干净。 + assertFalse(result.containsKey("outputs"), "失败路径不应下发 outputs: " + result.keySet()); + } + + @Test + void 失败态_不得携带raw整包() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + assertFalse(result.containsKey("raw"), + "失败路径不得下发 raw(issue 01 的硬性验收项),实际 keys: " + result.keySet()); + } + + @Test + void 失败态_结果需可被识别为失败() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + // 后端必须给出可判定的失败标识,前端才能区别于「成功但内容为空」。 + assertEquals("failed", String.valueOf(result.get("status")), + "结果里应保留 failed 状态,便于上游判定"); + } + + @Test + void 失败态_序列化后不含内部标识符() throws Exception { + Map result = buildResult(FAILED_RESPONSE_BODY); + + String serialized = JSONUtil.toJSONString(result); + assertFalse(serialized.contains("4d64b156-7b9d-4101-98eb-ec22cc8160a1"), "泄露了 workflow_id"); + assertFalse(serialized.contains("PluginInvokeError"), "泄露了上游错误栈"); + assertFalse(serialized.contains("req_id"), "泄露了 req_id"); + } + + // ------------------------------------------------------------ 成功态(防回归) + + @Test + void 成功态_仍下发业务内容() throws Exception { + Map result = buildResult(SUCCEEDED_RESPONSE_BODY); + + assertEquals("这是生成好的脚本正文", String.valueOf(result.get("answer"))); + assertEquals("run-ok-1", String.valueOf(result.get("workflowRunId"))); + assertEquals("task-ok-1", String.valueOf(result.get("taskId"))); + assertEquals("succeeded", String.valueOf(result.get("status"))); + assertTrue(result.containsKey("outputs"), "成功态应保留 outputs 供前端渲染"); + } + + @Test + void 成功态_业务内容不被失败判定误伤() throws Exception { + Map result = buildResult(SUCCEEDED_RESPONSE_BODY); + + // status 为 succeeded 时不得走失败分支 + assertFalse("生成失败,请稍后重试".equals(String.valueOf(result.get("answer")))); + } + + // ------------------------------------------------------------ 边界与异常分支 + + @Test + void 状态缺失_不误判为失败() throws Exception { + // 少数事件没有 data.status;不能因此把正常内容吃掉 + String body = "{\"event\":\"workflow_finished\",\"data\":{\"outputs\":{\"Script_result\":\"正文\"}}}"; + Map result = buildResult(body); + + assertEquals("正文", String.valueOf(result.get("answer"))); + assertFalse("生成失败,请稍后重试".equals(String.valueOf(result.get("answer")))); + } + + @Test + void 大小写与空白_状态判定不敏感() throws Exception { + String body = "{\"data\":{\"status\":\" FAILED \",\"outputs\":{}}}"; + Map result = buildResult(body); + + assertEquals("生成失败,请稍后重试", String.valueOf(result.get("answer"))); + assertFalse(result.containsKey("raw"), "失败路径不得下发 raw"); + } + + @Test + void 顶层status_同样能识别失败() throws Exception { + String body = "{\"status\":\"failed\",\"outputs\":{}}"; + Map result = buildResult(body); + + assertEquals("生成失败,请稍后重试", String.valueOf(result.get("answer"))); + assertFalse(result.containsKey("raw"), "失败路径不得下发 raw"); + } + + @Test + void 空报文_不抛异常且不泄露() throws Exception { + // responseData == null 的旧分支:回落到 responseBody 原文。 + // 该路径只在解析失败时走到,此处确认不会 NPE。 + AiController controller = new AiController(); + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantResult", Map.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map result = (Map) method.invoke(controller, null, "非 JSON 文本"); + + assertEquals("非 JSON 文本", String.valueOf(result.get("answer"))); + } + + @Test + void 失败态_携带error字段也不下发() throws Exception { + String body = "{\"data\":{\"status\":\"failed\",\"outputs\":{},\"error\":\"req_id: 泄露风险\"}}"; + Map result = buildResult(body); + + String serialized = JSONUtil.toJSONString(result); + assertFalse(serialized.contains("req_id"), "泄露了 req_id: " + serialized); + assertFalse(serialized.contains("泄露风险"), "泄露了 error 文本: " + serialized); + } +}