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:
@@ -53,6 +53,14 @@ public class AiController extends BaseController {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(AiController.class);
|
private static final Logger logger = LoggerFactory.getLogger(AiController.class);
|
||||||
private static final long AI_REQUEST_TIMEOUT = 10 * 60 * 1000L;
|
private static final long AI_REQUEST_TIMEOUT = 10 * 60 * 1000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创意生成工作流以失败收场时给用户看的文案。
|
||||||
|
*
|
||||||
|
* <p>上游的真实失败原因(如 {@code Model disabled})属于 Dify 侧配置问题,
|
||||||
|
* 既不面向用户也不该泄露;这里统一给一句可读提示。
|
||||||
|
*/
|
||||||
|
private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private WebSocketServer webSocketServer;
|
private WebSocketServer webSocketServer;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -847,6 +855,14 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
if ("message_end".equals(event) || "workflow_finished".equals(event)) {
|
if ("message_end".equals(event) || "workflow_finished".equals(event)) {
|
||||||
Map<String, Object> result = buildCreativeAssistantResult(data, json);
|
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) {
|
if (StrUtil.isBlank(stringValue(result.get("answer"))) && reply.length() > 0) {
|
||||||
result.put("answer", reply.toString());
|
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) {
|
private Map<String, Object> buildCreativeAssistantResult(Map<String, Object> responseData, String responseBody) {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
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) {
|
if (responseData == null) {
|
||||||
result.put("answer", responseBody);
|
result.put("answer", responseBody);
|
||||||
return result;
|
return result;
|
||||||
@@ -1326,7 +1353,8 @@ public class AiController extends BaseController {
|
|||||||
|
|
||||||
Object data = responseData.get("data");
|
Object data = responseData.get("data");
|
||||||
if (data instanceof Map) {
|
if (data instanceof Map) {
|
||||||
Object status = ((Map) data).get("status");
|
// status 已在方法开头经 workflowStatus 统一取出,
|
||||||
|
// 这里只负责下发(含顶层 status 的兜底情况)。
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
result.put("status", status);
|
result.put("status", status);
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1371,41 @@ public class AiController extends BaseController {
|
|||||||
return result;
|
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) {
|
private Map<String, Object> buildChatMessageResult(Map<String, Object> responseData, String responseBody) {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
if (responseData == null) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user