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); } }