fix(ai): 创意生成工作流失败时不再把原始报文吐给用户

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
This commit is contained in:
2026-09-24 03:44:08 +08:00
parent cbbe3b87b4
commit 23a7f001cc
2 changed files with 280 additions and 1 deletions
@@ -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;
/**
* 创意生成链路「工作流失败」时的返回整形。
*
* <p>对应 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 进了聊天气泡。
*
* <p>报文样本取自 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<String, Object> buildResult(String responseBody) throws Exception {
Map<String, Object> 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<String, Object> result = (Map<String, Object>) method.invoke(controller, responseData, responseBody);
return result;
}
// ------------------------------------------------------------ 失败态
@Test
void 失败态_不得把整包报文当作answer下发() throws Exception {
Map<String, Object> 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<String, Object> 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<String, Object> result = buildResult(FAILED_RESPONSE_BODY);
// outputs 为空对象 {} 时前端会原样返回并取不到字段,是整包兜底的触发条件之一;
// 失败路径干脆不带,语义更干净。
assertFalse(result.containsKey("outputs"), "失败路径不应下发 outputs: " + result.keySet());
}
@Test
void 失败态_不得携带raw整包() throws Exception {
Map<String, Object> result = buildResult(FAILED_RESPONSE_BODY);
assertFalse(result.containsKey("raw"),
"失败路径不得下发 raw(issue 01 的硬性验收项),实际 keys: " + result.keySet());
}
@Test
void 失败态_结果需可被识别为失败() throws Exception {
Map<String, Object> result = buildResult(FAILED_RESPONSE_BODY);
// 后端必须给出可判定的失败标识,前端才能区别于「成功但内容为空」。
assertEquals("failed", String.valueOf(result.get("status")),
"结果里应保留 failed 状态,便于上游判定");
}
@Test
void 失败态_序列化后不含内部标识符() throws Exception {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> result = (Map<String, Object>) 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<String, Object> result = buildResult(body);
String serialized = JSONUtil.toJSONString(result);
assertFalse(serialized.contains("req_id"), "泄露了 req_id: " + serialized);
assertFalse(serialized.contains("泄露风险"), "泄露了 error 文本: " + serialized);
}
}