feat(ai): 自由模式转创意助手,失败日志补失败节点与上游 error(issue 17 / 18)
自由模式(issue 18): - docType 为空时不调「内容创作与宣传助手」——它开始节点的 doc_type 是必填 select, 空值直接被上游判 failed(error="doc_type is required in input form")。改为转 「AI 创意助手」,只发它唯一的必填入参 user_content。 - ManuscriptGenInputs 增加 userContent;doc_type / Number 仍无条件带键(空串能过 Dify 的必填校验,缺键不行)。 - 用例:AiManuscriptFreeModeRouteTest、AiManuscriptInputsContractTest。 失败日志(issue 17): - 稿件流程、大纲流程、executeWorkflowForResult 三处失败日志补 status + failedNode + upstreamError + elapsed。失败信息拆在 node_finished 与 workflow_finished 两类事件, 入参校验类失败只有后者,只取节点事件等于没取。 - 补 event=error 与「静默断流」(没有 workflow_finished / message_end)的日志, 两者都带 taskId,可与请求对上。 - 日志级用 error 而非 warn:生产 com.gxwebsoft 只开 ERROR。 - 用例:AiWorkflowFailureDetailTest。
This commit is contained in:
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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 创意助手」。
|
||||
*
|
||||
* <p>创意助手的开始节点只有 {@code user_content} 一个必填入参,所以这里把
|
||||
* 用户原话拼成一整段下发,不再经过稿件生成那套 doc_type/event_type 入参。
|
||||
* 返回值沿用创意助手的结果结构。
|
||||
*/
|
||||
private ApiResult<?> runCreativeAssistantForFreeMode(ManuscriptGenInputs message) {
|
||||
String userContent = freeModeText(message);
|
||||
if (StrUtil.isBlank(userContent)) {
|
||||
return fail("请先填写要创作的内容");
|
||||
}
|
||||
Map<String, Object> 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<String> 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<String, Object> 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 一行」的失败日志补全。
|
||||
*
|
||||
* <p>Dify 把失败信息拆在两类事件里,只取其一都会缺一半:
|
||||
* <ul>
|
||||
* <li>{@code node_finished}(status=failed):带<b>节点名</b>和该节点的异常原文,
|
||||
* 这是判断「哪个环节挂了」的唯一来源;</li>
|
||||
* <li>{@code workflow_finished}(status=failed):带 {@code error}(上游整包文案,
|
||||
* 如 {@code doc_type is required in input form})与 {@code elapsed_time},
|
||||
* 但没有节点名。</li>
|
||||
* </ul>
|
||||
* 所以每个已解析的 SSE 事件都交给 {@link #observe(Map)} 收集,收尾时由
|
||||
* {@link #describe()} 拼成一行可直接 grep 的现场描述。
|
||||
*
|
||||
* <p>不变量:只有真的见过 {@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 事件。
|
||||
*
|
||||
* <p>对不认识的事件静默忽略:正常路径上大多数事件(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行现场描述,未失败时为空串。
|
||||
*
|
||||
* <p>错误原文按 {@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=<unknown>");
|
||||
} 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<String, Object> buildChatMessageResult(Map<String, Object> responseData, String responseBody) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (responseData == null) {
|
||||
|
||||
@@ -28,6 +28,15 @@ public class ManuscriptGenInputs implements Serializable {
|
||||
|
||||
private Integer number;
|
||||
|
||||
/**
|
||||
* 自由模式(未选文稿模板)下用户的原话。
|
||||
*
|
||||
* <p>未选 {@code docType} 时后端不调「内容创作与宣传助手」(它的 doc_type
|
||||
* 是上游必填 select,空值直接 failed),而是转「AI 创意助手」,把这段原话
|
||||
* 作为它唯一的必填入参 {@code user_content} 下发。选了模板时该字段被忽略。
|
||||
*/
|
||||
private String userContent;
|
||||
|
||||
/**
|
||||
* 附件材料。
|
||||
*
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>依据:「内容创作与宣传助手」开始节点的 {@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)");
|
||||
}
|
||||
}
|
||||
@@ -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 契约修正)。
|
||||
*
|
||||
* <p>背景:界面上的「文体 / 字数」的<b>自动识别</b>指的是由上游 Dify 识别,
|
||||
* 前端此时不下发 {@code doc_type} / {@code Number} 的值(见
|
||||
* {@code writing-params.js} 头部的契约说明)。
|
||||
*
|
||||
* <p>于是后端必须守住一条不变量:<b>这两个键无条件带上下发</b>。
|
||||
* 依据是 Dify 的必填校验只看「键是否存在」——
|
||||
* 缺键会直接 {@code xxx is required in input form} 失败,而空串能过
|
||||
* (实测对照见 {@code .scratch/dify-probe/README.md});对没声明必填的变量,
|
||||
* 空串与不传等价(开始节点自己会补空)。
|
||||
*
|
||||
* <p>同时锁定既有行为不回归:其余字段仍然「空串不下发」。
|
||||
*/
|
||||
class AiManuscriptInputsContractTest {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> buildManuscriptInputs(ManuscriptGenInputs message) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod("buildManuscriptInputs",
|
||||
ManuscriptGenInputs.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (Map<String, Object>) 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> inputs = buildManuscriptInputs(null);
|
||||
|
||||
assertNotNull(inputs);
|
||||
assertTrue(inputs.isEmpty(), "没有报文时不该编出任何入参");
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>背景:{@code manuscript workflow failed, taskId=..., workflowRunId=...} 这样
|
||||
* 一行日志里没有任何可排查信息——不知道是哪个节点挂的,也不知道上游说了什么。
|
||||
* 修法是 {@link AiController.WorkflowFailure}:把流里的失败事件收集起来,
|
||||
* 在失败日志后追加 {@code failedNode} / {@code upstreamError} / {@code elapsed}。
|
||||
*
|
||||
* <p>本测试直接钉住 {@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<String, Object> event(Object... pairs) {
|
||||
Map<String, Object> 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<String, Object> 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} 里。
|
||||
*
|
||||
* <p>这条路径最容易被写成「只取节点事件」,那样这行日志依旧是空的。
|
||||
*/
|
||||
@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=<unknown>"), "没有节点事件时也要显式标出未知:" + 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user