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 taskId = null;
boolean endSent = false;
// 上游以 failed 收场时置真,用于给 HTTP 返回体带上 status(见 issue 10)。
// 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了,
// 必须在外层作用域里记录。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
@@ -423,6 +427,7 @@ public class AiController extends BaseController {
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}",
taskId, workflowRunId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
HashMap<String, String> failureData = new HashMap<>();
@@ -490,8 +495,9 @@ public class AiController extends BaseController {
// 必须把累积的正文放进 data 一起回:只回一句状态文案时,
// 前端(uni-app 的 apiRequest 会回落 body.message)会把
// "Stream processing completed" 当成正文显示(见 issue 08)。
// 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。
return success("Stream processing completed",
buildManuscriptResult(reply, taskId, workflowRunId));
buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed));
} catch (Exception e) {
System.out.println(e.getMessage());
for (StackTraceElement stackTraceElement : e.getStackTrace()) {
@@ -517,8 +523,30 @@ public class AiController extends BaseController {
* 缺失时会回落 {@code body.message}(见 issue 08)。
*/
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<>();
result.put("answer", reply == null ? "" : reply.toString());
if (failed) {
result.put("status", "failed");
}
if (StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId);
}
@@ -782,6 +810,8 @@ public class AiController extends BaseController {
String conversationId = null;
String messageId = null;
boolean endSent = false;
// 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
@@ -829,6 +859,7 @@ public class AiController extends BaseController {
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("activity outline workflow failed, conversationId={}, messageId={}",
conversationId, messageId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
// reset=true:丢弃失败前可能已推给端的半截内容,
@@ -862,15 +893,8 @@ public class AiController extends BaseController {
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
// 返回体是它唯一的正文来源(见 issue 08)。
Map<String, Object> outlineResult = new LinkedHashMap<>();
outlineResult.put("answer", reply.toString());
if (StrUtil.isNotBlank(conversationId)) {
outlineResult.put("conversationId", conversationId);
}
if (StrUtil.isNotBlank(messageId)) {
outlineResult.put("messageId", messageId);
}
return success("Stream processing completed", outlineResult);
return success("Stream processing completed",
buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed));
} catch (Exception e) {
logger.error("activity outline request failed", e);
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) {
sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false);
}