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
@@ -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;
/**
* 创意生成工作流以失败收场时给用户看的文案。
*
* <p>上游的真实失败原因(如 {@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<String, Object> 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<String, Object> buildCreativeAssistantResult(Map<String, Object> responseData, String responseBody) {
Map<String, Object> 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 工作流事件里的执行状态。
*
* <p>{@code workflow_finished} 事件把状态放在 {@code data.status};
* 少数事件会放在顶层,两处都看,取到即返回。
*
* @return 状态字符串;取不到时为 {@code null}
*/
private String workflowStatus(Map<String, Object> 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;
}
/**
* 判断工作流是否以失败收场。
*
* <p>只在拿到明确失败状态时为真;状态缺失视为未失败,避免误伤正常响应。
*/
private boolean isWorkflowFailed(String status) {
return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status));
}
private Map<String, Object> buildChatMessageResult(Map<String, Object> responseData, String responseBody) {
Map<String, Object> result = new LinkedHashMap<>();
if (responseData == null) {
@@ -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);
}
}