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);
+ }
+}