diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index cc81487..478e63a 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -207,7 +207,6 @@ public class AiController extends BaseController { payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString()); payload.put("conversation_id", StrUtil.blankToDefault(message.getConversationId(), "")); payload.put("inputs", parseInputs(message.getInputs())); - System.out.println("payload:" + payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); @@ -216,6 +215,8 @@ public class AiController extends BaseController { int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); + logger.error("chat message rejected, httpStatus={}, body={}", + responseCode, briefUpstreamError(responseBody)); JSONObject result = JSONObject.parseObject(responseBody); throw new RuntimeException(result != null ? result.toJSONString() : responseBody); } @@ -289,7 +290,6 @@ public class AiController extends BaseController { ? (data.get("answer") == null ? null : data.get("answer").toString()) : data.get("thought").toString(); if (answer != null) { - System.out.println(answer); HashMap answerData = new HashMap<>(); answerData.put("answer", answer); answerData.put("loading", "true"); @@ -334,10 +334,7 @@ public class AiController extends BaseController { clearActiveAiTask(getLoginUserId(), taskId); } } catch (Exception e) { - System.out.println(e.getMessage()); - for (StackTraceElement stackTraceElement : e.getStackTrace()) { - System.out.println(stackTraceElement); - } + logger.error("chat message request failed", e); HashMap answerData = new HashMap<>(); answerData.put("answer", "出错了,请晚点再来提问吧~"); answerData.put("taskId", "0"); @@ -353,6 +350,13 @@ public class AiController extends BaseController { @PostMapping("/manuscriptGen") public ApiResult manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException { + // 未选文稿模板 = 自由模式。此时不调「内容创作与宣传助手」:它开始节点的 + // doc_type 是必填 select,空值会被上游直接判 failed + // (实测 error="doc_type is required in input form"),转「AI 创意助手」, + // 只带 user_content。选了模板才走下面的稿件生成流程(见 issue 18)。 + if (message == null || StrUtil.isBlank(message.getDocType())) { + return runCreativeAssistantForFreeMode(message); + } try { URL url = new URL(aiUrl() + "/workflows/run"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); @@ -374,8 +378,6 @@ public class AiController extends BaseController { payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString()); payload.put("inputs", inputs); - System.out.println("manuscript payload:" + payload); - try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); @@ -384,6 +386,8 @@ public class AiController extends BaseController { int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); + logger.error("manuscript workflow rejected, httpStatus={}, body={}", + responseCode, briefUpstreamError(responseBody)); JSONObject result = JSONObject.parseObject(responseBody); throw new RuntimeException(result != null ? result.toJSONString() : responseBody); } @@ -396,6 +400,13 @@ public class AiController extends BaseController { // 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了, // 必须在外层作用域里记录。 boolean workflowFailed = false; + // 失败现场:失败节点名 + 上游 error。日志里只有 taskId 一行时 + // 无从判断是哪个节点、什么原因挂的(2026-10-21 的排查就卡在这里)。 + WorkflowFailure failure = new WorkflowFailure(); + // 收尾事件是否出现过。没出现说明流被上游或网络提前截断, + // 这种「静默断流」以前在日志里一个字都没有。 + boolean finalEventSeen = false; + String lastEvent = null; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -415,6 +426,10 @@ public class AiController extends BaseController { } String event = data.get("event") == null ? "" : data.get("event").toString(); + lastEvent = event; + // 失败信息散在 node_finished / workflow_finished 事件里,逐个喂进去。 + failure.observe(data); + if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) { workflowRunId = stringValue(data.get("workflow_run_id")); } @@ -426,7 +441,6 @@ public class AiController extends BaseController { if (StrUtil.isNotBlank(taskId)) { recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript()); } - System.out.println(data); // 正文只认正文事件(text_chunk / message);其余事件的 // extractAnswer 结果可能是 data.id 这类内部标识符,不得进 reply(见 issue 06)。 String answer = extractAnswer(data); @@ -454,11 +468,15 @@ public class AiController extends BaseController { webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); } } else if ("workflow_finished".equals(event) || "message_end".equals(event)) { + finalEventSeen = true; // 失败态先判定:outputs 为空时 answer 可能是 workflow_run_id // 这类内部标识符,不能当正文推给端上(见 issue 03)。 if (isWorkflowFailed(workflowStatus(data))) { - logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}", - taskId, workflowRunId); + // 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR, + // warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」 + // (同 executeCreativeAssistantWithRetry 上方的注释)。 + logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}", + taskId, workflowRunId, failure.describe()); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); @@ -511,11 +529,22 @@ public class AiController extends BaseController { } clearActiveAiTask(getLoginUserId(), taskId); } else if ("error".equals(event)) { + // error 事件带的是整段上游异常,以前直接 throw 只留一个 500 的栈, + // taskId 都没进日志,排查时无法与请求对上。 + logger.error("manuscript stream error event, taskId={}, workflowRunId={}, event={}", + taskId, workflowRunId, briefUpstreamError(json)); throw new RuntimeException(json); } } } + if (!finalEventSeen) { + // 流结束却没有 workflow_finished / message_end:正文可能只有半截, + // 也可能一个字都没有,端上看到的就是「什么都没生成」。 + logger.error("manuscript stream ended without finish event, taskId={}, workflowRunId={}, " + + "chars={}, lastEvent={}", + taskId, workflowRunId, reply.length(), lastEvent); + } if (!endSent) { HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); @@ -531,10 +560,7 @@ public class AiController extends BaseController { return success("Stream processing completed", buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed)); } catch (Exception e) { - System.out.println(e.getMessage()); - for (StackTraceElement stackTraceElement : e.getStackTrace()) { - System.out.println(stackTraceElement); - } + logger.error("manuscript request failed", e); HashMap answerData = new HashMap<>(); answerData.put("answer", "出错了,请晚点再试~"); answerData.put("taskId", "0"); @@ -835,6 +861,8 @@ public class AiController extends BaseController { } catch (Exception ignored) { } String messageText = responseData == null ? responseBody : JSONUtil.toJSONString(responseData); + logger.error("activity outline workflow rejected, httpStatus={}, body={}", + responseCode, briefUpstreamError(messageText)); return fail("活动策划大纲生成失败", messageText); } @@ -844,6 +872,11 @@ public class AiController extends BaseController { boolean endSent = false; // 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。 boolean workflowFailed = false; + // 失败现场(失败节点名 + 上游 error),见 WorkflowFailure。 + WorkflowFailure failure = new WorkflowFailure(); + // 收尾事件是否出现过,用于识别上游提前断流。 + boolean finalEventSeen = false; + String lastEvent = null; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -863,6 +896,9 @@ public class AiController extends BaseController { } String event = stringValue(data.get("event")); + lastEvent = event; + // 失败信息散在 node_finished / workflow_finished 事件里,逐个喂进去。 + failure.observe(data); if (StrUtil.isNotBlank(stringValue(data.get("conversation_id")))) { conversationId = stringValue(data.get("conversation_id")); } @@ -885,12 +921,13 @@ public class AiController extends BaseController { sendActivityOutlineSocketMessage(loadingText, conversationId, messageId, true); } } else if ("message_end".equals(event) || "workflow_finished".equals(event)) { + finalEventSeen = true; // 失败态必须先判定:此时 outputs 为空、answer 可能是 // workflow_run_id 这类内部标识符,直接下发等于把标识符 // 当正文推给端上(见 issue 03)。 if (isWorkflowFailed(workflowStatus(data))) { - logger.warn("activity outline workflow failed, conversationId={}, messageId={}", - conversationId, messageId); + logger.error("activity outline workflow failed, conversationId={}, messageId={}, {}", + conversationId, messageId, failure.describe()); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); @@ -914,11 +951,18 @@ public class AiController extends BaseController { sendActivityOutlineSocketEnd(conversationId, messageId); } } else if ("error".equals(event)) { + logger.error("activity outline stream error event, conversationId={}, messageId={}, event={}", + conversationId, messageId, briefUpstreamError(json)); throw new RuntimeException(json); } } } + if (!finalEventSeen) { + logger.error("activity outline stream ended without finish event, conversationId={}, " + + "messageId={}, chars={}, lastEvent={}", + conversationId, messageId, reply.length(), lastEvent); + } if (!endSent) { sendActivityOutlineSocketEnd(conversationId, messageId); } @@ -1388,9 +1432,14 @@ public class AiController extends BaseController { if (message == null) { return inputs; } - if (StrUtil.isNotBlank(message.getDocType())) { - inputs.put("doc_type", message.getDocType()); - } + // doc_type / Number 是上游提示词里的**主控指令**变量,但前端「自动识别」时故意不带值。 + // 这两个键**无条件带上下发**(没值给空串),理由: + // - Dify 的必填校验只看「键是否存在」:缺键直接 failed,而空串能过 + // (见 .scratch/dify-probe/README.md 的对照表);对没声明必填的变量, + // 空串与不传等价(开始节点自己会补空)。 + // - 值留空 = 交给上游工作流自己的识别分支,不在我们这边猜 + // (猜错就是把垃圾值当主控指令,见 writing-params 的契约说明)。 + inputs.put("doc_type", StrUtil.blankToDefault(message.getDocType(), "")); if (StrUtil.isNotBlank(message.getEventType())) { inputs.put("event_type", message.getEventType()); } @@ -1409,14 +1458,53 @@ public class AiController extends BaseController { if (StrUtil.isNotBlank(message.getHighlights())) { inputs.put("highlights", message.getHighlights()); } - if (message.getNumber() != null) { - // Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。 - // 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。 - inputs.put("Number", message.getNumber()); - } + // Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。 + // 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。 + // 与 doc_type 同理:没值也要带键,空串交给上游自己判。 + inputs.put("Number", message.getNumber() == null ? "" : message.getNumber()); return inputs; } + /** + * 自由模式:把「未选文稿模板」的写作请求转给「AI 创意助手」。 + * + *

创意助手的开始节点只有 {@code user_content} 一个必填入参,所以这里把 + * 用户原话拼成一整段下发,不再经过稿件生成那套 doc_type/event_type 入参。 + * 返回值沿用创意助手的结果结构。 + */ + private ApiResult runCreativeAssistantForFreeMode(ManuscriptGenInputs message) { + String userContent = freeModeText(message); + if (StrUtil.isBlank(userContent)) { + return fail("请先填写要创作的内容"); + } + Map body = new LinkedHashMap<>(); + body.put("user_content", userContent); + logger.info("manuscript free mode (docType blank), delegate to creative assistant, chars={}", + userContent.length()); + return executeCreativeAssistant(body, getLoginUserId()); + } + + /** + * 自由模式的创作素材。新前端把用户原话放在 {@code userContent}; + * 老小程序(写作模式仍是"自动识别")只发解析后的 theme / highlights, + * 这里按顺序兜底,免得前端版本差异让自由模式空着入参。 + */ + private String freeModeText(ManuscriptGenInputs message) { + if (message == null) { + return null; + } + if (StrUtil.isNotBlank(message.getUserContent())) { + return message.getUserContent().trim(); + } + List parts = new ArrayList<>(); + for (String part : new String[]{message.getTheme(), message.getHighlights()}) { + if (StrUtil.isNotBlank(part)) { + parts.add(part.trim()); + } + } + return parts.isEmpty() ? null : String.join("。", parts); + } + /** * 「活动策划大纲生成」的前置校验。 * @@ -1713,6 +1801,8 @@ public class AiController extends BaseController { int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); + logger.error("{} workflow rejected, httpStatus={}, body={}", + stopType, responseCode, briefUpstreamError(responseBody)); return fail("AI工作流调用失败", responseBody); } @@ -1722,6 +1812,11 @@ public class AiController extends BaseController { Map lastData = null; boolean endSent = false; boolean workflowFailed = false; + // 失败现场(失败节点名 + 上游 error),见 WorkflowFailure。 + WorkflowFailure failure = new WorkflowFailure(); + // 收尾事件是否出现过,用于识别上游提前断流。 + boolean finalEventSeen = false; + String lastEvent = null; try (BufferedReader reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -1753,6 +1848,9 @@ public class AiController extends BaseController { } String event = stringValue(data.get("event")); + lastEvent = event; + // 失败信息散在 node_finished / workflow_finished 事件里,逐个喂进去。 + failure.observe(data); String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); @@ -1767,12 +1865,13 @@ public class AiController extends BaseController { taskId, workflowRunId, stopType, true, loginUserId); } } else if ("workflow_finished".equals(event) || "message_end".equals(event)) { + finalEventSeen = true; // 失败态必须先判定:outputs 为空时 answer 可能是 // workflow_run_id 这类内部标识符,不能当正文推给端上(见 issue 03)。 String eventStatus = workflowStatus(data); if (isWorkflowFailed(eventStatus)) { - logger.warn("workflow failed, stopType={}, taskId={}, workflowRunId={}", - stopType, taskId, workflowRunId); + logger.error("workflow failed, stopType={}, taskId={}, workflowRunId={}, {}", + stopType, taskId, workflowRunId, failure.describe()); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); @@ -1801,11 +1900,20 @@ public class AiController extends BaseController { } clearActiveAiTask(loginUserId, taskId); } else if ("error".equals(event)) { + logger.error("{} stream error event, taskId={}, workflowRunId={}, event={}", + stopType, taskId, workflowRunId, briefUpstreamError(json)); throw new RuntimeException(json); } } } + if (!finalEventSeen) { + // 没收到 workflow_finished / message_end 就结束:上游提前断流。 + // 这里不加「空正文」告警——competitionHistoryPreprocess 等应用 + // 正常收场时 outputs 就是空的,空正文并不等于出错。 + logger.error("{} stream ended without finish event, taskId={}, workflowRunId={}, lastEvent={}", + stopType, taskId, workflowRunId, lastEvent); + } if (streamToSocket && !endSent) { sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId); } @@ -2059,6 +2167,160 @@ public class AiController extends BaseController { return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status)); } + /** + * 一次流式工作流执行里的失败现场,用来把「只有 taskId 一行」的失败日志补全。 + * + *

Dify 把失败信息拆在两类事件里,只取其一都会缺一半: + *

    + *
  • {@code node_finished}(status=failed):带节点名和该节点的异常原文, + * 这是判断「哪个环节挂了」的唯一来源;
  • + *
  • {@code workflow_finished}(status=failed):带 {@code error}(上游整包文案, + * 如 {@code doc_type is required in input form})与 {@code elapsed_time}, + * 但没有节点名。
  • + *
+ * 所以每个已解析的 SSE 事件都交给 {@link #observe(Map)} 收集,收尾时由 + * {@link #describe()} 拼成一行可直接 grep 的现场描述。 + * + *

不变量:只有真的见过 {@code failed} 才产出内容({@link #happened()}), + * 成功路径的日志里不会冒出 {@code failedNode=null} 这种噪音。 + */ + private class WorkflowFailure { + + /** 最后一次明确的失败状态;未失败时保持 {@code null}。 */ + private String status; + /** 失败节点在编排界面上的名字,可比 nodeId 直接对上「哪个环节」。 */ + private String nodeTitle; + /** 失败节点的内部 id,能直接对上 Dify 的 node_finished 日志。 */ + private String nodeId; + /** 节点级异常原文,语义比工作流级更精确,工作流级缺失时才顶上。 */ + private String nodeError; + /** 工作流级异常原文。 */ + private String workflowError; + /** 工作流级耗时(秒),失败太快(如 0.0s)往往意味着入参校验就挂了。 */ + private String elapsedTime; + + /** + * 吸收一个已解析的 SSE 事件。 + * + *

对不认识的事件静默忽略:正常路径上大多数事件(text_chunk、 + * node_started 等)与本类无关,逐条打日志只会淹没真正的失败。 + */ + void observe(Map event) { + if (event == null) { + return; + } + String name = stringValue(event.get("event")); + if ("node_finished".equals(name)) { + observeNodeFinished(event); + return; + } + if ("workflow_finished".equals(name) || "message_end".equals(name)) { + observeWorkflowFinished(event); + } + } + + private void observeNodeFinished(Map event) { + Object body = event.get("data"); + if (!(body instanceof Map)) { + return; + } + Map node = (Map) body; + if (!isWorkflowFailed(stringValue(node.get("status")))) { + return; + } + status = "failed"; + // node_id 是内部 id,title 才是编排界面上的名字;两者都留: + // id 能直接对上 Dify 日志,title 能让人一眼看出是哪个环节。 + String title = stringValue(node.get("title")); + if (StrUtil.isNotBlank(title)) { + nodeTitle = title; + } + String id = firstNonBlank(stringValue(node.get("node_id")), stringValue(node.get("id"))); + if (id != null) { + nodeId = id; + } + // 不同版本的 Dify 把节点异常放在不同键上,按「最具体 → 最泛化」的顺序取。 + String error = firstNonBlank(stringValue(node.get("error")), + stringValue(node.get("error_message")), stringValue(node.get("message"))); + if (error != null) { + nodeError = error; + } + } + + private void observeWorkflowFinished(Map event) { + Object body = event.get("data"); + Map payload = body instanceof Map ? (Map) body : event; + // 状态既可能在 data.status,也可能在顶层 status,两处都看(同 workflowStatus)。 + String eventStatus = firstNonBlank(stringValue(payload.get("status")), + stringValue(event.get("status"))); + if (!isWorkflowFailed(eventStatus)) { + return; + } + status = "failed"; + String error = firstNonBlank(stringValue(payload.get("error")), + stringValue(payload.get("error_message")), + stringValue(event.get("error")), stringValue(event.get("message"))); + if (error != null) { + workflowError = error; + } + String elapsed = stringValue(payload.get("elapsed_time")); + if (StrUtil.isNotBlank(elapsed)) { + elapsedTime = elapsed; + } + } + + /** 本轮是否见过明确失败。 */ + boolean happened() { + return "failed".equals(status); + } + + /** + * 一行现场描述,未失败时为空串。 + * + *

错误原文按 {@link #UPSTREAM_ERROR_LOG_MAX_LENGTH} 截断:上游会把整段 + * provider 报错(含换行、含 {@code b'{"code":50500,...}'} 这类裸 repr)塞进 + * {@code error},个别情况下可达数 KB,必须压成单行才不至于撑爆日志。 + */ + String describe() { + if (!happened()) { + return ""; + } + StringBuilder sb = new StringBuilder("status=failed"); + if (nodeTitle == null && nodeId == null) { + sb.append(", failedNode="); + } else { + sb.append(", failedNode=").append(StrUtil.blankToDefault(nodeTitle, "?")); + if (nodeId != null) { + sb.append("(").append(nodeId).append(")"); + } + } + String error = workflowError; + if (error == null) { + error = nodeError; + } + sb.append(", upstreamError=") + .append(error == null ? "<事件未带 error 字段>" : briefUpstreamError(error)); + // 节点级原文常常比工作流级的更具体(含 provider 的状态码与报错体), + // 与工作流级不同时一并带上,否则只剩一句概括,排不到根因。 + if (nodeError != null && !nodeError.equals(error)) { + sb.append(", nodeError=").append(briefUpstreamError(nodeError)); + } + if (elapsedTime != null) { + sb.append(", elapsed=").append(elapsedTime).append("s"); + } + return sb.toString(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (StrUtil.isNotBlank(value)) { + return value; + } + } + return null; + } + } + private Map buildChatMessageResult(Map responseData, String responseBody) { Map result = new LinkedHashMap<>(); if (responseData == null) { diff --git a/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java index d99d7cf..248c545 100644 --- a/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java +++ b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java @@ -28,6 +28,15 @@ public class ManuscriptGenInputs implements Serializable { private Integer number; + /** + * 自由模式(未选文稿模板)下用户的原话。 + * + *

未选 {@code docType} 时后端不调「内容创作与宣传助手」(它的 doc_type + * 是上游必填 select,空值直接 failed),而是转「AI 创意助手」,把这段原话 + * 作为它唯一的必填入参 {@code user_content} 下发。选了模板时该字段被忽略。 + */ + private String userContent; + /** * 附件材料。 * diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFreeModeRouteTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFreeModeRouteTest.java new file mode 100644 index 0000000..7c6b1e5 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFreeModeRouteTest.java @@ -0,0 +1,85 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.ai.entity.ManuscriptGenInputs; +import com.gxwebsoft.common.core.web.ApiResult; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 「未选文稿模板 = 自由模式,转创意助手」这条分流的回归(issue 18)。 + * + *

依据:「内容创作与宣传助手」开始节点的 {@code doc_type} 是必填 select, + * 空值会被上游判 {@code doc_type is required in input form} 直接失败; + * 而「AI 创意助手」只有 {@code user_content} 一个必填入参。 + * 所以 {@code docType} 为空时必须改走创意助手,而不是把空 doc_type 发给稿件应用。 + */ +class AiManuscriptFreeModeRouteTest { + + private String freeModeText(ManuscriptGenInputs message) throws Exception { + Method method = AiController.class.getDeclaredMethod("freeModeText", + ManuscriptGenInputs.class); + method.setAccessible(true); + try { + return (String) method.invoke(new AiController(), message); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + // ------------------------------------------------ 自由模式素材的来源 + + @Test + void 自由模式_优先用前端给的原话() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setUserContent("帮我写一段迎新晚会的主持人开场词"); + message.setTheme("迎新晚会"); + message.setHighlights("口语化一点"); + + assertEquals("帮我写一段迎新晚会的主持人开场词", freeModeText(message)); + } + + @Test + void 自由模式_老客户端没有原话时用主题与要求拼接() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setTheme("迎新晚会"); + message.setHighlights("口语化一点"); + + assertEquals("迎新晚会。口语化一点", freeModeText(message)); + } + + @Test + void 自由模式_只有主题也能出素材() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setTheme("校运会"); + + assertEquals("校运会", freeModeText(message)); + } + + @Test + void 自由模式_什么都不填时没有素材() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setTheme(" "); + message.setHighlights(""); + + assertNull(freeModeText(message), "空白素材不该拼成一段空串下发给上游"); + } + + // ------------------------------------------------ 分流本身 + + @Test + void 未选模板且没有素材_直接返回可读错误且不打上游() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + + // docType 为空 → 走自由模式分支;素材为空 → 在发起任何网络请求之前就返回。 + ApiResult result = new AiController().manuscriptGen(message); + + assertEquals("请先填写要创作的内容", result.getMessage()); + assertEquals(Integer.valueOf(com.gxwebsoft.common.core.Constants.RESULT_ERROR_CODE), + result.getCode(), "应是失败结果(code=1)"); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptInputsContractTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptInputsContractTest.java new file mode 100644 index 0000000..6319dbb --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptInputsContractTest.java @@ -0,0 +1,119 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.ai.entity.ManuscriptGenInputs; +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; + +/** + * 稿件生成入参的契约回归(2026-10-19 契约修正)。 + * + *

背景:界面上的「文体 / 字数」的自动识别指的是由上游 Dify 识别, + * 前端此时不下发 {@code doc_type} / {@code Number} 的值(见 + * {@code writing-params.js} 头部的契约说明)。 + * + *

于是后端必须守住一条不变量:这两个键无条件带上下发。 + * 依据是 Dify 的必填校验只看「键是否存在」—— + * 缺键会直接 {@code xxx is required in input form} 失败,而空串能过 + * (实测对照见 {@code .scratch/dify-probe/README.md});对没声明必填的变量, + * 空串与不传等价(开始节点自己会补空)。 + * + *

同时锁定既有行为不回归:其余字段仍然「空串不下发」。 + */ +class AiManuscriptInputsContractTest { + + @SuppressWarnings("unchecked") + private Map buildManuscriptInputs(ManuscriptGenInputs message) throws Exception { + Method method = AiController.class.getDeclaredMethod("buildManuscriptInputs", + ManuscriptGenInputs.class); + method.setAccessible(true); + try { + return (Map) method.invoke(new AiController(), message); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + // ------------------------------------------------ 自动识别:带键、值留空 + + @Test + void 自动识别_两个主控变量仍带键下发空串() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setTheme("迎新晚会"); + message.setHighlights("帮我写篇推文"); + + Map inputs = buildManuscriptInputs(message); + + assertTrue(inputs.containsKey("doc_type"), "缺 doc_type 键会被上游判为必填失败"); + assertEquals("", inputs.get("doc_type"), "自动识别时不该由我们猜一个文体"); + assertTrue(inputs.containsKey("Number"), "缺 Number 键会被上游判为必填失败"); + assertEquals("", inputs.get("Number"), "自动识别时不该由我们猜一个字数"); + } + + @Test + void 自动识别_空串与null等价_都带键() throws Exception { + ManuscriptGenInputs blank = new ManuscriptGenInputs(); + blank.setDocType(""); + blank.setNumber(null); + + Map inputs = buildManuscriptInputs(blank); + + assertEquals("", inputs.get("doc_type")); + assertEquals("", inputs.get("Number")); + } + + // ------------------------------------------------ 显式选择:按值下发 + + @Test + void 显式选择_按值下发() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setDocType("微信推文"); + message.setEventType("迎新"); + message.setNumber(300); + + Map inputs = buildManuscriptInputs(message); + + assertEquals("微信推文", inputs.get("doc_type")); + assertEquals("迎新", inputs.get("event_type")); + assertEquals(300, inputs.get("Number")); + // 变量名大小写敏感:发 "number" 会因缺参报错 + assertFalse(inputs.containsKey("number"), "必须按上游真实变量名 Number 下发"); + } + + // ------------------------------------------------ 其余字段:空串仍不下发(防回归) + + @Test + void 其余字段空串_仍然不下发() throws Exception { + ManuscriptGenInputs message = new ManuscriptGenInputs(); + message.setTheme(""); + message.setTime(""); + message.setLocation(""); + message.setParticipants(""); + message.setHighlights(""); + + Map inputs = buildManuscriptInputs(message); + + assertFalse(inputs.containsKey("theme"), "空主题不该下发空串"); + assertFalse(inputs.containsKey("time")); + assertFalse(inputs.containsKey("location")); + assertFalse(inputs.containsKey("participants")); + assertFalse(inputs.containsKey("highlights")); + // 只有两个主控变量是「无条件带键」的例外 + assertTrue(inputs.containsKey("doc_type")); + assertTrue(inputs.containsKey("Number")); + } + + @Test + void 空报文_返回空入参() throws Exception { + Map inputs = buildManuscriptInputs(null); + + assertNotNull(inputs); + assertTrue(inputs.isEmpty(), "没有报文时不该编出任何入参"); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureDetailTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureDetailTest.java new file mode 100644 index 0000000..8ccf321 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiWorkflowFailureDetailTest.java @@ -0,0 +1,202 @@ +package com.gxwebsoft.ai.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 工作流失败日志的「现场」契约(2026-10-21)。 + * + *

背景:{@code manuscript workflow failed, taskId=..., workflowRunId=...} 这样 + * 一行日志里没有任何可排查信息——不知道是哪个节点挂的,也不知道上游说了什么。 + * 修法是 {@link AiController.WorkflowFailure}:把流里的失败事件收集起来, + * 在失败日志后追加 {@code failedNode} / {@code upstreamError} / {@code elapsed}。 + * + *

本测试直接钉住 {@code describe()} 的输出形状,防止以后为了「少打点日志」 + * 把节点名或错误原文又丢掉——那正是这次要修的问题。 + */ +class AiWorkflowFailureDetailTest { + + private Class failureClass; + private Constructor constructor; + + @BeforeEach + void setUp() throws Exception { + failureClass = Class.forName("com.gxwebsoft.ai.controller.AiController$WorkflowFailure"); + constructor = failureClass.getDeclaredConstructor(AiController.class); + constructor.setAccessible(true); + } + + // ------------------------------------------------ 工具 + + /** 按「键, 值, 键, 值…」构造事件 Map,保持顺序便于阅读。 */ + private static Map event(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + map.put((String) pairs[i], pairs[i + 1]); + } + return map; + } + + private Object newFailure() throws Exception { + return constructor.newInstance(new AiController()); + } + + private void observe(Object failure, Map event) throws Exception { + Method method = failureClass.getDeclaredMethod("observe", Map.class); + method.setAccessible(true); + method.invoke(failure, event); + } + + private boolean happened(Object failure) throws Exception { + Method method = failureClass.getDeclaredMethod("happened"); + method.setAccessible(true); + return (Boolean) method.invoke(failure); + } + + private String describe(Object failure) throws Exception { + Method method = failureClass.getDeclaredMethod("describe"); + method.setAccessible(true); + return (String) method.invoke(failure); + } + + // ------------------------------------------------ 只有 workflow_finished 失败 + + /** + * 复刻 2026-10-21 实际抓到的失败流(见 /tmp/manuscript-fail.txt): + * {@code inputs={}} 触发开始节点必填校验,全程没有 {@code node_finished} 事件, + * 失败信息只在 {@code workflow_finished.data.error} 里。 + * + *

这条路径最容易被写成「只取节点事件」,那样这行日志依旧是空的。 + */ + @Test + void 仅工作流级失败_也要打出上游error与耗时() throws Exception { + Object failure = newFailure(); + + observe(failure, event("event", "workflow_started")); + observe(failure, event( + "event", "workflow_finished", + "data", event( + "status", "failed", + "outputs", null, + "error", "doc_type is required in input form", + "elapsed_time", 0.0))); + + assertTrue(happened(failure), "值得记日志的是 failed 收场"); + String line = describe(failure); + assertTrue(line.startsWith("status=failed"), line); + assertTrue(line.contains("failedNode="), "没有节点事件时也要显式标出未知:" + line); + assertTrue(line.contains("upstreamError=doc_type is required in input form"), line); + assertTrue(line.contains("elapsed=0.0s"), "0.0 秒说明是入参校验就挂了,要看得出来:" + line); + } + + // ------------------------------------------------ 节点级失败 + + /** + * 节点级失败:{@code node_finished} 带节点名与该节点的异常原文, + * {@code workflow_finished} 再补一句工作流级概括。两条原文都要留。 + */ + @Test + void 节点级失败_带节点名与两层error() throws Exception { + Object failure = newFailure(); + + observe(failure, event( + "event", "node_finished", + "data", event( + "node_id", "1711522123456", + "title", "文档内容生成", + "status", "failed", + "error", "HTTP 400: invalid api key"))); + observe(failure, event( + "event", "workflow_finished", + "data", event( + "status", "failed", + "error", "Run failed: 文档内容生成", + "elapsed_time", 3.42))); + + String line = describe(failure); + assertTrue(line.contains("failedNode=文档内容生成(1711522123456)"), + "节点名与 node_id 都要有:名字给人看,id 用来对 Dify 日志:" + line); + assertTrue(line.contains("upstreamError=Run failed: 文档内容生成"), line); + assertTrue(line.contains("nodeError=HTTP 400: invalid api key"), + "节点级原文含 provider 报错体,丢了就排不到根因:" + line); + assertTrue(line.contains("elapsed=3.42s"), line); + } + + // ------------------------------------------------ 不该有噪音 + + /** 成功收场不该产出任何内容,否则成功路径的日志里会冒出 failedNode=null。 */ + @Test + void 成功收场_describe为空() throws Exception { + Object failure = newFailure(); + + observe(failure, event( + "event", "workflow_finished", + "data", event("status", "succeeded", "elapsed_time", 1.5))); + + assertFalse(happened(failure)); + assertTrue(describe(failure).isEmpty(), "成功路径不该打失败现场"); + } + + /** 没收到任何事件时也必须是空串,不能因为「没见过失败」就当成失败。 */ + @Test + void 无事件_describe为空() throws Exception { + Object failure = newFailure(); + assertFalse(happened(failure)); + assertTrue(describe(failure).isEmpty()); + } + + /** 上游只给了 status=failed 却没带 error 字段时,要显式说明缺字段。 */ + @Test + void 失败但无error字段_显式说明() throws Exception { + Object failure = newFailure(); + + observe(failure, event( + "event", "workflow_finished", + "data", event("status", "failed"))); + + String line = describe(failure); + assertTrue(line.contains("upstreamError=<事件未带 error 字段>"), line); + } + + /** + * 上游 error 可能塞进整段 provider 报错(含换行),必须压成单行并截断, + * 否则一条日志能撑出几十行、把真正有用的上下文挤出日志文件。 + */ + @Test + void 超长error_截断成单行() throws Exception { + Object failure = newFailure(); + StringBuilder huge = new StringBuilder("b'{\"code\":50500"); + while (huge.length() < 2000) { + huge.append(", \"message\": \"upstream exploded\"\\n"); + } + + observe(failure, event( + "event", "workflow_finished", + "data", event("status", "failed", "error", huge.toString()))); + + String line = describe(failure); + assertFalse(line.contains("\n"), "日志行不能带换行:" + line.substring(0, Math.min(120, line.length()))); + assertTrue(line.contains("...(truncated)"), "超长原文必须截断:" + line.length()); + assertTrue(line.length() < 900, "截断后仍应是单行短文本,实际 " + line.length()); + } + + @Test + void describe非空时必然带status前缀() throws Exception { + Object failure = newFailure(); + observe(failure, event("event", "workflow_finished", + "data", event("status", "failed", "error", "boom"))); + + String line = describe(failure); + assertNotNull(line); + assertTrue(line.startsWith("status=failed, failedNode="), line); + } +}