fix(ai): 失败态在返回体里带 status=failed,前端才能区分失败文案与正文(issue 10)

上游工作流 failed 时,后端把 reply 置为失败文案("生成失败,请稍后重试")
并照常走成功路径返回(HTTP 200 / code=0),而返回体里**没有 status**:

    data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}

两端前端的 isFailedPayload(读 payload.status / payload.data.status)因此
永远落空,只能把失败文案当正文渲染(实测三个 admin 页面都会这样)。
修 issue 09 时上游每次都 failed,这条是必现的。

改动:
- buildManuscriptResult 增加 failed 参数(保留三参重载,等价 failed=false,
  以免破坏 issue 08 既有测试的调用形态),失败时 put("status","failed")。
- 新增 buildActivityOutlineResult(同构),activityOutlineGen 收尾改用之。
- 两个方法都在外层作用域记录 workflowFailed:失败分支里有 continue,
  不能用分支内的局部布尔。

刻意不改的东西:
- 失败仍走 HTTP 200 / code=0。改成 code!=0 会与既有约定冲突、影响所有调用方
  的 catch 分支,本条只加一个字段即可让两端现有判定生效。
- answer 键在失败态下依然存在(issue 08 契约),只是现在有了 status 可区分。

测试:新增 AiWorkflowFailureHttpStatusTest 8/8,含反向用例——
成功态**不得**带 status(否则 isFailedPayload 会误伤正常结果),
以及「成功态正文恰好是失败文案」时唯一判据是 status。
全量 210 跑 0 失败(2 个 ServerContainer not available 为预存问题)。

验证:用桩上游(probe/stub-dify-failure.py,回 workflow_finished{status:failed})
起第二个后端实例,实测两条链路的返回体都带 status='failed' 且
answer 仍为可读文案,前端 isFailedPayload 判定为 true —— 全链路闭合。
This commit is contained in:
2026-10-13 02:12:46 +08:00
parent fbc9774e19
commit f54e28cffc
2 changed files with 220 additions and 10 deletions
@@ -360,6 +360,10 @@ public class AiController extends BaseController {
String workflowRunId = null; String workflowRunId = null;
String taskId = null; String taskId = null;
boolean endSent = false; boolean endSent = false;
// 上游以 failed 收场时置真,用于给 HTTP 返回体带上 status(见 issue 10)。
// 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了,
// 必须在外层作用域里记录。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader( try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
@@ -423,6 +427,7 @@ public class AiController extends BaseController {
if (isWorkflowFailed(workflowStatus(data))) { if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}", logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}",
taskId, workflowRunId); taskId, workflowRunId);
workflowFailed = true;
reply.setLength(0); reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE); reply.append(CREATIVE_FAILED_MESSAGE);
HashMap<String, String> failureData = new HashMap<>(); HashMap<String, String> failureData = new HashMap<>();
@@ -490,8 +495,9 @@ public class AiController extends BaseController {
// 必须把累积的正文放进 data 一起回:只回一句状态文案时, // 必须把累积的正文放进 data 一起回:只回一句状态文案时,
// 前端(uni-app 的 apiRequest 会回落 body.message)会把 // 前端(uni-app 的 apiRequest 会回落 body.message)会把
// "Stream processing completed" 当成正文显示(见 issue 08)。 // "Stream processing completed" 当成正文显示(见 issue 08)。
// 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。
return success("Stream processing completed", return success("Stream processing completed",
buildManuscriptResult(reply, taskId, workflowRunId)); buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed));
} catch (Exception e) { } catch (Exception e) {
System.out.println(e.getMessage()); System.out.println(e.getMessage());
for (StackTraceElement stackTraceElement : e.getStackTrace()) { for (StackTraceElement stackTraceElement : e.getStackTrace()) {
@@ -517,8 +523,30 @@ public class AiController extends BaseController {
* 缺失时会回落 {@code body.message}(见 issue 08)。 * 缺失时会回落 {@code body.message}(见 issue 08)。
*/ */
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) { private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) {
return buildManuscriptResult(reply, taskId, workflowRunId, false);
}
/**
* 构造稿件生成的 HTTP 返回体。
*
* <p>{@code answer} 键<b>始终存在</b>(空正文给空串):{@code ApiResult} 带
* {@code @JsonInclude(NON_NULL)},若 {@code data} 为 null 会被整个省略,
* 前端就会回落到 {@code message}("Stream processing completed")——见 issue 08。
*
* <p>失败时必须带 {@code status=failed}:否则前端拿到的是
* {@code answer="生成失败,请稍后重试"},会把失败文案当正文渲染
* (issue 10 实测:三个 admin 页面都会这样),且无从据此判定失败。
* uniapp 的 {@code isFailedPayload} 读的就是这个字段。
*
* @param failed 上游工作流是否以 failed 收场
*/
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId,
String workflowRunId, boolean failed) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply == null ? "" : reply.toString()); result.put("answer", reply == null ? "" : reply.toString());
if (failed) {
result.put("status", "failed");
}
if (StrUtil.isNotBlank(taskId)) { if (StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId); result.put("taskId", taskId);
} }
@@ -782,6 +810,8 @@ public class AiController extends BaseController {
String conversationId = null; String conversationId = null;
String messageId = null; String messageId = null;
boolean endSent = false; boolean endSent = false;
// 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader( try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
@@ -829,6 +859,7 @@ public class AiController extends BaseController {
if (isWorkflowFailed(workflowStatus(data))) { if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("activity outline workflow failed, conversationId={}, messageId={}", logger.warn("activity outline workflow failed, conversationId={}, messageId={}",
conversationId, messageId); conversationId, messageId);
workflowFailed = true;
reply.setLength(0); reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE); reply.append(CREATIVE_FAILED_MESSAGE);
// reset=true:丢弃失败前可能已推给端的半截内容, // reset=true:丢弃失败前可能已推给端的半截内容,
@@ -862,15 +893,8 @@ public class AiController extends BaseController {
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方 // 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket, // (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
// 返回体是它唯一的正文来源(见 issue 08)。 // 返回体是它唯一的正文来源(见 issue 08)。
Map<String, Object> outlineResult = new LinkedHashMap<>(); return success("Stream processing completed",
outlineResult.put("answer", reply.toString()); buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed));
if (StrUtil.isNotBlank(conversationId)) {
outlineResult.put("conversationId", conversationId);
}
if (StrUtil.isNotBlank(messageId)) {
outlineResult.put("messageId", messageId);
}
return success("Stream processing completed", outlineResult);
} catch (Exception e) { } catch (Exception e) {
logger.error("activity outline request failed", e); logger.error("activity outline request failed", e);
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false); sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
@@ -879,6 +903,32 @@ public class AiController extends BaseController {
} }
} }
/**
* 构造活动策划大纲的 HTTP 返回体。
*
* <p>与 {@link #buildManuscriptResult(StringBuilder, String, String, boolean)} 同构:
* {@code answer} 键始终存在(空正文给空串,避免 {@code data} 被
* {@code @JsonInclude(NON_NULL)} 省略而让前端回落到 {@code message},见 issue 08),
* 失败时带 {@code status=failed} 以免失败文案被当正文渲染(见 issue 10)。
*
* @param failed 上游工作流是否以 failed 收场
*/
private Map<String, Object> buildActivityOutlineResult(StringBuilder reply, String conversationId,
String messageId, boolean failed) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply == null ? "" : reply.toString());
if (failed) {
result.put("status", "failed");
}
if (StrUtil.isNotBlank(conversationId)) {
result.put("conversationId", conversationId);
}
if (StrUtil.isNotBlank(messageId)) {
result.put("messageId", messageId);
}
return result;
}
private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) { private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) {
sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false); sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false);
} }
@@ -0,0 +1,160 @@
package com.gxwebsoft.ai.controller;
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 10 的回归:失败态必须靠 {@code data.status} 表达,而不是靠 {@code answer} 的内容。
*
* <p>缺陷回顾:上游工作流 failed 时,后端把 {@code reply} 置为失败文案
* ({@code "生成失败,请稍后重试"})并<b>照常走成功路径</b>返回
* (HTTP 200 / {@code code=0})。于是:
* <pre>
* data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}
* </pre>
* 返回体里<b>没有 status</b>,前端的 {@code isFailedPayload}(读
* {@code payload.status} / {@code payload.data.status})永远落空,
* 只能把失败文案当正文渲染。
*
* <p>实测(node 复刻 admin 的解析函数):三个页面都会把
* 「生成失败,请稍后重试」渲染成正文字段。
*
* <p>本测试锁定:失败时 {@code status=failed};成功时<b>不带</b> {@code status}
* —— 后者同样重要,否则 {@code isFailedPayload} 会把正常结果误判为失败
* (uniapp 的 {@code isFailedPayload} 只在明确 {@code 'failed'} 时为真)。
*/
class AiWorkflowFailureHttpStatusTest {
@SuppressWarnings("unchecked")
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId,
String workflowRunId, boolean failed) throws Exception {
Method method = AiController.class.getDeclaredMethod("buildManuscriptResult",
StringBuilder.class, String.class, String.class, boolean.class);
method.setAccessible(true);
try {
return (Map<String, Object>) method.invoke(new AiController(), reply, taskId, workflowRunId, failed);
} catch (java.lang.reflect.InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> buildOutlineResult(StringBuilder reply, String conversationId,
String messageId, boolean failed) throws Exception {
Method method = AiController.class.getDeclaredMethod("buildActivityOutlineResult",
StringBuilder.class, String.class, String.class, boolean.class);
method.setAccessible(true);
try {
return (Map<String, Object>) method.invoke(new AiController(), reply, conversationId, messageId, failed);
} catch (java.lang.reflect.InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
// ------------------------------------------------- 缺陷本体:失败必须可判定
@Test
void 稿件失败态_返回体带status_failed() throws Exception {
Map<String, Object> result = buildManuscriptResult(
new StringBuilder("生成失败,请稍后重试"), "task-1", "run-1", true);
assertEquals("failed", result.get("status"),
"失败态必须带 status,否则前端 isFailedPayload 无法判定");
// 失败文案仍在 answer 里:前端据此显示可读提示是允许的,
// 但它必须能被 status 识别出来,不能只靠文案内容猜。
assertEquals("生成失败,请稍后重试", result.get("answer"));
}
@Test
void 大纲失败态_返回体带status_failed() throws Exception {
Map<String, Object> result = buildOutlineResult(
new StringBuilder("生成失败,请稍后重试"), "conv-1", "msg-1", true);
assertEquals("failed", result.get("status"));
assertEquals("生成失败,请稍后重试", result.get("answer"));
}
// ------------------------------------------------- 反向:成功态不得误报失败
@Test
void 稿件成功态_不得带status() throws Exception {
// 关键反向用例:若成功也带 status(哪怕是 "succeeded" 之外的值),
// 前端失败判定就会误伤正常结果。
Map<String, Object> result = buildManuscriptResult(
new StringBuilder("# 标题\n\n正文"), "task-1", "run-1", false);
assertFalse(result.containsKey("status"),
"成功态不应带 status —— 前端只认 'failed',多带字段徒增误判面");
assertEquals("# 标题\n\n正文", result.get("answer"));
}
@Test
void 大纲成功态_不得带status() throws Exception {
Map<String, Object> result = buildOutlineResult(
new StringBuilder("正文"), "conv-1", "msg-1", false);
assertFalse(result.containsKey("status"));
assertEquals("正文", result.get("answer"));
}
@Test
void 成功态正文恰好是失败文案时_仍以status为准() throws Exception {
// 极端情形:模型真的生成了「生成失败,请稍后重试」这几个字作为正文。
// 没有 status 时前端无法区分,这正是要靠 status 表达失败的原因。
Map<String, Object> success = buildManuscriptResult(
new StringBuilder("生成失败,请稍后重试"), null, null, false);
Map<String, Object> failure = buildManuscriptResult(
new StringBuilder("生成失败,请稍后重试"), null, null, true);
// 两者 answer 完全相同,唯一的判据就是 status
assertEquals(success.get("answer"), failure.get("answer"));
assertFalse(success.containsKey("status"), "没有上游失败信号时不得臆断为 failed");
assertEquals("failed", failure.get("status"), "有失败信号时必须以 status 表达");
}
// ------------------------------------------------- 与 issue 08 的契约并存
@Test
void 失败态_answer键仍必须存在() throws Exception {
// issue 08 的契约不能被本条破坏:answer 始终存在,空正文给空串,
// 否则 data 会被 @JsonInclude(NON_NULL) 省略、前端回落到 message。
Map<String, Object> result = buildManuscriptResult(new StringBuilder(), null, null, true);
assertTrue(result.containsKey("answer"), "answer 键必须存在(issue 08)");
assertEquals("", result.get("answer"));
assertEquals("failed", result.get("status"));
}
@Test
void 空reply为null_不抛异常() throws Exception {
Map<String, Object> result = buildManuscriptResult(null, null, null, true);
assertNotNull(result);
assertEquals("", result.get("answer"));
assertEquals("failed", result.get("status"));
}
@Test
void 三参重载_等价于未失败() throws Exception {
// 三参重载目前只被既有测试使用;保留它是为了不破坏 AiManuscriptHttpResultTest
// 的调用形态,这里锁定它等价于 failed=false。
Method method = AiController.class.getDeclaredMethod("buildManuscriptResult",
StringBuilder.class, String.class, String.class);
method.setAccessible(true);
Object value = method.invoke(new AiController(), new StringBuilder("正文"), "t", "r");
@SuppressWarnings("unchecked")
Map<String, Object> result = (Map<String, Object>) value;
assertFalse(result.containsKey("status"));
assertEquals("正文", result.get("answer"));
assertEquals("t", result.get("taskId"));
assertEquals("r", result.get("workflowRunId"));
}
}