From fbc9774e197e30fda15be4cbf65e36155b58a17b Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 00:37:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=A4=A7=E7=BA=B2=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=8A=E6=B8=B8=E5=BF=85=E5=A1=AB=E5=8F=98?= =?UTF-8?q?=E9=87=8F=EF=BC=8C=E8=B5=84=E9=87=91=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E6=95=B0=E5=80=BC=EF=BC=88issue=2009=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活动策划大纲当时必然失败,界面只显示「生成失败,请稍后重试」。 两条根因(单变量对照实测确证): 1. Dify 开始节点 event_funding 是 number,而实体与两端前端都是字符串, 小程序问答助手页默认值还是中文「待定」→ must be a valid number,0.2s 即 failed。 2. event_member / tel_people 是上游必填变量,实体里根本没有字段,从不发送 → event_member is required in input form。 实施中发现的关键事实,推翻了 issue 初稿的一条建议: 「is required in input form」检查的是键是否存在,不是值是否非空 —— 缺 event_member 键 / null → failed: is required in input form "event_member": "" → succeeded(4752 条 message) "event_funding": "" → succeeded(7268 条 message) "event_funding": "待定" → failed: must be a valid number 所以「干脆不发该字段」行不通(不发 = 报 required),正确做法是键始终下发、 值空串兜底。 改动: - ActivityOutlineInputs 补 eventMember / peopleManage / telPeople 三字段 (snake + 驼峰别名);eventFunding 保持 String 承载用户原文。 - buildActivityOutlineInputs:6 个上游必填变量无条件占位(空值走空白转空串, 不能给 null —— null 会被上游当缺失);event_type / event_location / people_manage / event_meme 实测非必填,保持「为空不发」。 - 新增 normalizeEventFunding:抽出首个金额并归一化为 BigDecimal。 两个必须先处理的坑,否则静默发出错误金额(比留空更糟): · 日期:'2026年4月办活动,预算5000元' 的 2026 会被当金额 → 先剔除日期 · 单位前空格:'预算 1 万元' 只认紧邻的「万」会抽成 1 元 → \s* 容忍 另支持 万/亿/千/百 倍率与 '3万5千' 连续降序单位相加;抽不出即降级空串。 - 前置校验抽成 validateActivityOutline,只校验主题/受众/时间三项。 刻意不校验另外三个上游必填项 —— 上游必填不等于用户必填,加校验会把两端 默认入参挡在门外(改前端默认值为空串后,旧校验会直接返回 'event_funding不能为空',比原缺陷更糟)。 - buildLoadingText 加 120 字上限:agent_thought 的思维链实测可达数 KB, 整段塞进 loading 气泡等于把思维链推给端上。 - 去掉 isDisplayableText 之后的恒假条件(空串已被排除),并注释说明 reply.indexOf("") 恒为 0 会误判已存在,故两道判断都不能少。 配套前端: - uniapp 问答助手页资金默认值 '待定' → ''(未提及 != 待定) - admin 入参类型补注释与 3 个可选键 测试:新增 AiActivityOutlineContractTest 23/23,含「凡校验放行的入参必带上游 全部必填键」的不变量用例;AiManuscriptUuidLeakTest 增至 21 条,补 loading 长度上限与真实 message_end 形状。全量 202 跑 0 失败 (2 个 ServerContainer not available 为预存问题)。 验收:probe/outline-e2e.py 走真实后端,用两端前端默认入参与 issue 原文要求的 '待定' 一档,复刻 apiRequest 回落 + formatOutlineResult + parseAssistantContent 判定,29/29 PASS。 --- .../gxwebsoft/ai/controller/AiController.java | 436 ++++++++++++++++-- .../ai/entity/ActivityOutlineInputs.java | 46 +- .../gxwebsoft/ai/entity/ManuscriptFile.java | 40 ++ .../ai/entity/ManuscriptGenInputs.java | 25 +- .../AiActivityOutlineContractTest.java | 385 ++++++++++++++++ .../AiManuscriptFilesContractTest.java | 132 ++++++ .../AiManuscriptHttpResultTest.java | 102 ++++ .../controller/AiManuscriptUuidLeakTest.java | 340 ++++++++++++++ 8 files changed, 1440 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/ai/entity/ManuscriptFile.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiActivityOutlineContractTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFilesContractTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiManuscriptHttpResultTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiManuscriptUuidLeakTest.java diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 814dadf..ae2259c 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -33,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import java.io.*; +import java.math.BigDecimal; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -45,6 +46,8 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Api(tags = "AI") @RestController @@ -61,6 +64,48 @@ public class AiController extends BaseController { */ private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试"; + /** + * 内部标识符形状:纯 UUID({@code workflow_run_id} / 节点 id 等)。 + * 与前端 {@code ai-result.js} 的 {@code isDisplayableAnswer} 同一口径。 + */ + private static final Pattern UUID_PATTERN = Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + + /** + * 无空格的长 token({@code task_id} / {@code req_id} 等)。 + */ + private static final Pattern LONG_TOKEN_PATTERN = Pattern.compile("[0-9a-zA-Z_-]{32,}"); + + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s"); + + /** + * loading 气泡文案的长度上限。 + * + *

「加载中」气泡位置只够显示一句短提示;{@code agent_thought} 的思维链 + * 实测可达数 KB,整段塞进去等于把思维链推给端上,故超限即弃用。 + */ + private static final int LOADING_TEXT_MAX_LENGTH = 120; + + /** + * 从「活动资金」的自由文本里抽金额。 + * + *

两个捕获组:数值(容忍千分位逗号与小数)+ 可选的中文倍率单位。 + * 单位前用 {@code \s*} 容忍「1 万元」这类写法 —— 只认紧邻的「万」会把 1 万元抽成 1 元。 + */ + private static final Pattern EVENT_FUNDING_AMOUNT_PATTERN = + Pattern.compile("(\\d[\\d,]*(?:\\.\\d+)?)\\s*([亿万千百]?)"); + + /** + * 日期/时间表达式,用于在抽金额前剔除。 + * + *

{@code "2026年4月办活动,预算5000元"} 里 2026 会被金额正则当成第一处数字, + * 静默发出错误金额 —— 比留空更糟,故先整体剔除。 + */ + private static final Pattern EVENT_FUNDING_DATE_PATTERN = Pattern.compile( + "\\d[\\d,]*\\s*(?:年|月|日|号|时|点|分|秒)" + + "|\\d{4}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{1,2}" + + "|\\d{1,2}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{2,4}"); + @Resource private WebSocketServer webSocketServer; @Resource @@ -288,7 +333,10 @@ public class AiController extends BaseController { connection.setReadTimeout(aiTimeout()); Map inputs = buildManuscriptInputs(message); - Object files = normalizeFiles(message.getFiles()); + // 附件当前不转发给 Dify:该应用没有文件类输入变量(file_upload.enabled=false), + // 上游也不认这个字段。原先这里调了 normalizeFiles 却从不使用其返回值, + // 反而让人以为附件已生效(见 issue 07)。要真正支持附件需先在 Dify + // 开始节点加文件变量,再按文件类变量的契约(transfer_method / upload_file_id)改造。 Map payload = new HashMap<>(); payload.put("response_mode", "streaming"); payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString()); @@ -343,6 +391,8 @@ public class AiController extends BaseController { recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript()); } System.out.println(data); + // 正文只认正文事件(text_chunk / message);其余事件的 + // extractAnswer 结果可能是 data.id 这类内部标识符,不得进 reply(见 issue 06)。 String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); @@ -355,13 +405,18 @@ public class AiController extends BaseController { || "node_finished".equals(event) || "agent_log".equals(event) || "agent_thought".equals(event)) { - String loadingText = StrUtil.blankToDefault(answer, event); - HashMap answerData = new HashMap<>(); - answerData.put("answer", loadingText); - answerData.put("loading", "true"); - answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); - answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); - webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); + // 这些事件只用于「加载中」提示,不下发正文。 + // 不能用 extractAnswer 兜底:它可能在 outputs 里取到**整篇**稿件, + // 当成 loading 帧发出去等于把正文重复推送一遍(见 issue 06)。 + String loadingText = buildLoadingText(event, data); + if (loadingText != null) { + HashMap answerData = new HashMap<>(); + answerData.put("answer", loadingText); + answerData.put("loading", "true"); + answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); + answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); + webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); + } } else if ("workflow_finished".equals(event) || "message_end".equals(event)) { // 失败态先判定:outputs 为空时 answer 可能是 workflow_run_id // 这类内部标识符,不能当正文推给端上(见 issue 03)。 @@ -390,13 +445,21 @@ public class AiController extends BaseController { clearActiveAiTask(getLoginUserId(), taskId); continue; } - if (answer == null) { - answer = extractWorkflowOutputs(data); + // 收尾事件不产出正文:正文来自 text_chunk / message。 + // 这里只在流式正文缺失时用 outputs 兜底,且兜底值同样要过 + // 可展示性校验——否则会重演 issue 06(把 data.id 当正文)。 + String finalAnswer = answer; + if (finalAnswer == null) { + finalAnswer = extractWorkflowOutputs(data); } - if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) { - reply.append(answer); + if (isDisplayableText(finalAnswer) + && reply.indexOf(finalAnswer) < 0) { + // 注:isDisplayableText 已排除空串,故无需再判 length()==0。 + // 反过来若把空串放进来,reply.indexOf("") 恒为 0 会误判"已存在" + // 而短路掉正常内容,所以两道判断缺一不可。 + reply.append(finalAnswer); HashMap answerData = new HashMap<>(); - answerData.put("answer", answer); + answerData.put("answer", finalAnswer); answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); @@ -424,6 +487,11 @@ public class AiController extends BaseController { webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); clearActiveAiTask(getLoginUserId(), taskId); } + // 必须把累积的正文放进 data 一起回:只回一句状态文案时, + // 前端(uni-app 的 apiRequest 会回落 body.message)会把 + // "Stream processing completed" 当成正文显示(见 issue 08)。 + return success("Stream processing completed", + buildManuscriptResult(reply, taskId, workflowRunId)); } catch (Exception e) { System.out.println(e.getMessage()); for (StackTraceElement stackTraceElement : e.getStackTrace()) { @@ -435,7 +503,29 @@ public class AiController extends BaseController { webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); return fail("出错了,请晚点再试~"); } - return success("Stream processing completed"); + } + + /** + * 稿件生成的 HTTP 返回体。 + * + *

本接口同时走 WebSocket 推流,但**不能**因此省略返回体:不是所有调用方 + * 都连了 WebSocket(如小程序的问答助手页在稿件模式下只发 HTTP), + * 它们唯一的正文来源就是这里的 {@code answer}。 + * + *

只回 {@code message}(如 "Stream processing completed")时, + * 前端会把它当正文渲染——uni-app 的 {@code apiRequest} 在 {@code data} + * 缺失时会回落 {@code body.message}(见 issue 08)。 + */ + private Map buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) { + Map result = new LinkedHashMap<>(); + result.put("answer", reply == null ? "" : reply.toString()); + if (StrUtil.isNotBlank(taskId)) { + result.put("taskId", taskId); + } + if (StrUtil.isNotBlank(workflowRunId)) { + result.put("workflowRunId", workflowRunId); + } + return result; } @PostMapping("/creativeAssistant") @@ -647,17 +737,9 @@ public class AiController extends BaseController { if (message == null) { return fail("请求参数不能为空"); } - if (StrUtil.isBlank(message.getEventTheme())) { - return fail("event_theme不能为空"); - } - if (StrUtil.isBlank(message.getPeople())) { - return fail("people不能为空"); - } - if (StrUtil.isBlank(message.getEventTime())) { - return fail("event_time不能为空"); - } - if (StrUtil.isBlank(message.getEventFunding())) { - return fail("event_funding不能为空"); + String validationError = validateActivityOutline(message); + if (validationError != null) { + return fail(validationError); } try { @@ -777,7 +859,18 @@ public class AiController extends BaseController { if (!endSent) { sendActivityOutlineSocketEnd(conversationId, messageId); } - return success("Stream processing completed"); + // 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方 + // (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket, + // 返回体是它唯一的正文来源(见 issue 08)。 + Map 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); } catch (Exception e) { logger.error("activity outline request failed", e); sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false); @@ -1020,35 +1113,188 @@ public class AiController extends BaseController { return inputs; } + /** + * 「活动策划大纲生成」的前置校验。 + * + *

只校验 {@code event_theme} / {@code people} / {@code event_time} 三项 —— + * 它们缺失时生成的大纲没有主题、受众、时间,对用户无意义。 + * + *

刻意不校验另外三个上游必填项,因为"上游必填"不等于"用户必须填": + *

+ * + *

由此得到必须守住的不变量:上游声明的必填集合,其键必须全部下发—— + * 本方法放行的任何入参都不得让 {@code buildActivityOutlineInputs} 漏键。 + * 改这里的校验集合时,务必同步 {@code AiActivityOutlineContractTest} 里的断言。 + * + * @return 错误文案;通过校验时返回 {@code null} + */ + private String validateActivityOutline(ActivityOutlineInputs message) { + if (StrUtil.isBlank(message.getEventTheme())) { + return "event_theme不能为空"; + } + if (StrUtil.isBlank(message.getPeople())) { + return "people不能为空"; + } + if (StrUtil.isBlank(message.getEventTime())) { + return "event_time不能为空"; + } + return null; + } + + /** + * 把大纲入参映射成 Dify 开始节点的 inputs(见 issue 09)。 + * + *

与该实例上其它工作流的 remap 逻辑有一处本质区别: + * Dify 的 {@code xxx is required in input form} 检查的是键是否存在, + * 不是值是否非空 —— 实测对照: + *

+     *   缺 event_member 键   → failed: event_member is required in input form
+     *   "event_member": ""  → succeeded(4752 条 message)
+     * 
+ * 所以必填变量必须无条件占位下发,空值给空串; + * 而 {@code event_type} / {@code event_location} / {@code event_meme} + * 实测非必填(抽掉后照样 succeeded),保持「为空则不发」的旧行为。 + * + *

{@code event_funding} 另有类型问题:上游是 number,见 + * {@link #normalizeEventFunding(String)}。 + */ private Map buildActivityOutlineInputs(ActivityOutlineInputs message) { Map inputs = new LinkedHashMap<>(); if (message == null) { - return inputs; - } - if (StrUtil.isNotBlank(message.getEventTheme())) { - inputs.put("event_theme", message.getEventTheme()); - } - if (StrUtil.isNotBlank(message.getPeople())) { - inputs.put("people", message.getPeople()); - } - if (StrUtil.isNotBlank(message.getEventTime())) { - inputs.put("event_time", message.getEventTime()); - } - if (StrUtil.isNotBlank(message.getEventFunding())) { - inputs.put("event_funding", message.getEventFunding()); + message = new ActivityOutlineInputs(); } + + // —— 上游实测必填的 6 个变量:键必须存在,空缺用空串占位 —— + inputs.put("event_theme", blankToEmpty(message.getEventTheme())); + inputs.put("people", blankToEmpty(message.getPeople())); + inputs.put("event_time", blankToEmpty(message.getEventTime())); + // number 类型:抽出数值,抽不出给空串(都是上游接受的形态) + inputs.put("event_funding", normalizeEventFunding(message.getEventFunding())); + // 两端前端都不采集这两个字段,因此空串是常态而非错误 + inputs.put("event_member", blankToEmpty(message.getEventMember())); + inputs.put("tel_people", blankToEmpty(message.getTelPeople())); + + // —— 上游实测非必填:为空不下发 —— if (StrUtil.isNotBlank(message.getEventType())) { inputs.put("event_type", message.getEventType()); } if (StrUtil.isNotBlank(message.getEventLocation())) { inputs.put("event_location", message.getEventLocation()); } + if (StrUtil.isNotBlank(message.getPeopleManage())) { + inputs.put("people_manage", message.getPeopleManage()); + } if (StrUtil.isNotBlank(message.getEventMeme())) { inputs.put("event_meme", message.getEventMeme()); } return inputs; } + private String blankToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 把「活动资金」的用户原文归一化成 Dify 要的 number。 + * + *

上游该变量声明为 {@code number},而两端前端的输入框是自由文本: + * admin 的示例值是「预算 5000 元,来源为校团委志愿服务专项经费」, + * 小程序问答助手页解析用户自然语言后默认填「待定」。实测取值判定: + *

+     *   5000 / "5000" / 5000.5 / 0  → succeeded(可解析的数值,上游会强制转换)
+     *   ""                          → succeeded(7268 条 message)
+     *   "待定" / "预算 5000 元"       → failed: event_funding in input form must be a valid number
+     * 
+ * 即「不可解析的文本必然让整个工作流失败,空串反而是安全的」, + * 因此这里抽出金额;抽不出就降级为空串,绝不原样下发。 + * + *

两条必须先处理的坑,否则会静默发出错误金额(比留空更糟): + *

    + *
  1. 日期:{@code "2026年4月办活动,预算5000元"} 里的 2026 会被当成金额, + * 故先用 {@link #EVENT_FUNDING_DATE_PATTERN} 剔除日期/时间表达式。
  2. + *
  3. 数字与单位之间可能有空格:{@code "预算 1 万元"} 若只认紧邻的「万」, + * 会抽出 1 元。故用 {@code \s*} 容忍空白。
  4. + *
+ * + *

支持「万 / 亿 / 千 / 百」倍率,以及 {@code "3万5千"} 这类连续降序单位相加。 + * 只取第一处金额(相邻的单位片段相加),不会把「另有 3000 元物资」也累加进来。 + * + * @return {@link java.math.BigDecimal}(序列化成不带引号的 JSON 数值)或空串 + */ + private Object normalizeEventFunding(String raw) { + if (StrUtil.isBlank(raw)) { + return ""; + } + // 先剔除日期/时间,避免把年份当金额 + String text = EVENT_FUNDING_DATE_PATTERN.matcher(raw).replaceAll(" "); + Matcher matcher = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text); + // 负号不在捕获组里,会被静默丢成正数:"-5000" -> 5000 是错的,直接降级 + if (!matcher.find() || isNegated(text, matcher.start())) { + logger.info("activity outline event_funding 无法解析为数值,降级为空串: {}", raw); + return ""; + } + BigDecimal amount = toAmount(matcher.group(1), matcher.group(2)); + BigDecimal lastMultiplier = multiplierOf(matcher.group(2)); + int cursor = matcher.end(); + // 相邻的降序单位继续相加:"3万5千" -> 30000 + 5000 + while (true) { + Matcher next = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text); + if (!next.find(cursor) || next.start() > cursor + 2) { + break; + } + BigDecimal multiplier = multiplierOf(next.group(2)); + if (multiplier.compareTo(lastMultiplier) >= 0) { + break; + } + amount = amount.add(toAmount(next.group(1), next.group(2))); + lastMultiplier = multiplier; + cursor = next.end(); + } + return amount; + } + + /** + * 判断数字前是否带负号(容忍中间的空格:「- 5000」)。 + */ + private boolean isNegated(String text, int numberStart) { + for (int i = numberStart - 1; i >= 0; i--) { + char c = text.charAt(i); + if (Character.isWhitespace(c)) { + continue; + } + return c == '-'; + } + return false; + } + + private BigDecimal toAmount(String digits, String unit) { + return new BigDecimal(digits.replace(",", "")).multiply(multiplierOf(unit)); + } + + private BigDecimal multiplierOf(String unit) { + if ("亿".equals(unit)) { + return BigDecimal.valueOf(100000000L); + } + if ("万".equals(unit)) { + return BigDecimal.valueOf(10000L); + } + if ("千".equals(unit)) { + return BigDecimal.valueOf(1000L); + } + if ("百".equals(unit)) { + return BigDecimal.valueOf(100L); + } + return BigDecimal.ONE; + } + /** * 把入参实体的业务字段转成 Dify 工作流的 inputs。 * @@ -1575,13 +1821,16 @@ public class AiController extends BaseController { String[] keys = new String[]{"answer", "text", "chunk", "thought"}; for (String key : keys) { Object value = data.get(key); - if (value instanceof String) { + if (isDisplayableText(value)) { return (String) value; } } Object dataObj = data.get("data"); if (dataObj instanceof Map) { - String nestedAnswer = findTextValue((Map) dataObj); + // 注意用 findPreferredTextValue 而非 findTextValue: + // data 里除了正文还有 id / status 等元数据,后者的通用扫描 + // 会把内部标识符或 "succeeded" 这类状态词当成正文(见 issue 06)。 + String nestedAnswer = findPreferredTextValue((Map) dataObj); if (nestedAnswer != null) { return nestedAnswer; } @@ -1589,6 +1838,36 @@ public class AiController extends BaseController { return null; } + /** + * 判断一个值是否适合当作**正文**下发给端上。 + * + *

Dify 事件的 {@code data.id} 是 workflow / 节点的内部标识符(UUID), + * 而 {@code findTextValue} 的兜底会把它当成文本取出来;一旦被当正文追加, + * 用户看到的稿件末尾就会挂一串 UUID(见 issue 06)。 + * + *

与前端 {@code tuanwei-uniapp/utils/ai-result.js} 的 + * {@code isDisplayableAnswer} 同一口径:UUID 与无空格长 token 都不算可展示内容。 + * 前端那道防线只覆盖**失败**路径,成功路径必须由后端拦住。 + */ + private boolean isDisplayableText(Object value) { + if (!(value instanceof String)) { + return false; + } + String text = ((String) value).trim(); + if (text.isEmpty()) { + return false; + } + // 纯 UUID(workflow_run_id / 节点 id 等) + if (UUID_PATTERN.matcher(text).matches()) { + return false; + } + // 无空格的长 token(task_id / req_id 等) + if (!WHITESPACE_PATTERN.matcher(text).find() && LONG_TOKEN_PATTERN.matcher(text).matches()) { + return false; + } + return true; + } + private String extractWorkflowOutputs(Map data) { if (data == null) { return null; @@ -1608,19 +1887,84 @@ public class AiController extends BaseController { return null; } - private String findTextValue(Map source) { + /** + * 为「加载中」事件生成提示文案。 + * + *

只取**节点标题**这类天然短小、面向用户的字段(如「用户输入」「LLM」), + * 不再回退到 {@code extractAnswer}——后者会在 outputs 里取到整篇稿件, + * 当 loading 帧发出去就等于把正文又推了一遍(见 issue 06)。 + * + *

{@code thought}(思维链)同样有风险:{@code agent_thought} 的 thought + * 实测可达数 KB,整个塞进 loading 气泡等于把思维链当提示推给端上。 + * 故超过 {@link #LOADING_TEXT_MAX_LENGTH} 的一律弃用、退到事件名。 + * + * @return 提示文案;无可展示内容时返回 {@code null}(调用方不下发) + */ + private String buildLoadingText(String event, Map data) { + if (data != null) { + Object dataObj = data.get("data"); + if (dataObj instanceof Map) { + String title = stringValue(((Map) dataObj).get("title")); + if (isDisplayableText(title) && title.length() <= LOADING_TEXT_MAX_LENGTH) { + return title; + } + } + String thought = stringValue(data.get("thought")); + if (isDisplayableText(thought) && thought.length() <= LOADING_TEXT_MAX_LENGTH) { + return thought; + } + } + // 兜底用事件名(如 node_started),它是稳定的枚举而非内部标识符 + return StrUtil.isBlank(event) ? null : event; + } + + /** + * 在已知的正文键名里取值,并递归进嵌套对象。 + * + *

用于 {@code data} 这类**混有元数据**的对象:只认正文键名, + * 不做「任意 String 值」的通用扫描。 + */ + private String findPreferredTextValue(Map source) { if (source == null || source.isEmpty()) { return null; } - String[] preferredKeys = new String[]{"answer", "content", "text", "result", "output", "article", "manuscript"}; + String[] preferredKeys = new String[]{"answer", "content", "text", "chunk", "result", + "output", "article", "manuscript", "generated_content"}; for (String key : preferredKeys) { Object value = source.get(key); - if (value instanceof String) { + if (isDisplayableText(value)) { return (String) value; } } for (Object value : source.values()) { - if (value instanceof String) { + if (value instanceof Map) { + String nestedValue = findPreferredTextValue((Map) value); + if (nestedValue != null) { + return nestedValue; + } + } + } + return null; + } + + /** + * 在 outputs 这类**只装业务产物**的对象里取正文。 + * + *

先按已知键名找,找不到再退一步取任意可展示的 String + * (工作流的输出变量名由编排决定,无法穷举,如 {@code generated_content})。 + * 与 {@link #findPreferredTextValue} 的区别正在于这一层兜底 —— + * 它对 outputs 安全,对混有 id/status 的 data 不安全。 + */ + private String findTextValue(Map source) { + if (source == null || source.isEmpty()) { + return null; + } + String preferred = findPreferredTextValue(source); + if (preferred != null) { + return preferred; + } + for (Object value : source.values()) { + if (isDisplayableText(value)) { return (String) value; } if (value instanceof Map) { @@ -1630,6 +1974,8 @@ public class AiController extends BaseController { } } } + // 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。 + // 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。 return null; } diff --git a/src/main/java/com/gxwebsoft/ai/entity/ActivityOutlineInputs.java b/src/main/java/com/gxwebsoft/ai/entity/ActivityOutlineInputs.java index f43d4ec..8ebc945 100644 --- a/src/main/java/com/gxwebsoft/ai/entity/ActivityOutlineInputs.java +++ b/src/main/java/com/gxwebsoft/ai/entity/ActivityOutlineInputs.java @@ -1,6 +1,5 @@ package com.gxwebsoft.ai.entity; -import com.baomidou.mybatisplus.annotation.TableField; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.annotations.ApiModel; @@ -9,40 +8,71 @@ import lombok.EqualsAndHashCode; import java.io.Serializable; +/** + * 「活动策划大纲生成」入参。 + * + *

字段名与 Dify 开始节点的变量名一致(实测该实例的 10 个变量,见 + * {@code .scratch/dify-probe/README.md});{@code @JsonAlias} 兼容两端前端可能发出的驼峰名。 + * + *

三个后补的字段 {@code event_member} / {@code people_manage} / {@code tel_people} + * 是后补的:Dify 侧一开始就有这些变量,但本实体从未声明,于是从不发送。 + * 其中 {@code event_member} 与 {@code tel_people} 是上游必填, + * 缺键会让工作流报 {@code event_member is required in input form}(见 issue 09); + * {@code people_manage} 实测非必填,发送它属可选增强。 + * + *

注意:两端前端目前都不采集 {@code event_member} / {@code tel_people}, + * 因此这两个字段为空是正常状态,不是错误。Dify 的 "is required in input form" + * 检查的是键是否存在而非值是否非空(实测空串可以让工作流 succeeded), + * 所以 {@code AiController#buildActivityOutlineInputs} 会对它们补空串占位 —— + * 千万不要在本类或 Controller 上给这两个字段加非空校验,那会把默认入参挡在门外。 + */ @Data @EqualsAndHashCode(callSuper = false) @ApiModel(value = "活动策划大纲生成对象") public class ActivityOutlineInputs implements Serializable { - @TableField(exist = false) @JsonProperty("event_theme") @JsonAlias("eventTheme") private String eventTheme; - @TableField(exist = false) @JsonProperty("people") private String people; - @TableField(exist = false) @JsonProperty("event_time") @JsonAlias("eventTime") private String eventTime; - @TableField(exist = false) + /** + * 活动资金。Dify 侧是 number 类型,而两端前端的输入框是自由文本 + * (示例值形如「预算 5000 元,来源为校团委专项经费」,小程序默认值甚至是「待定」)。 + * + *

因此本字段保持 {@code String} 承载用户原文,由 + * {@code AiController#normalizeEventFunding} 在发送前抽出数值; + * 抽不出数值时降级为空串(上游接受空串,但不接受不可解析的文本)。 + */ @JsonProperty("event_funding") @JsonAlias("eventFunding") private String eventFunding; - @TableField(exist = false) + @JsonProperty("event_member") + @JsonAlias("eventMember") + private String eventMember; + @JsonProperty("event_type") @JsonAlias("eventType") private String eventType; - @TableField(exist = false) @JsonProperty("event_location") @JsonAlias("eventLocation") private String eventLocation; - @TableField(exist = false) + @JsonProperty("people_manage") + @JsonAlias("peopleManage") + private String peopleManage; + + @JsonProperty("tel_people") + @JsonAlias("telPeople") + private String telPeople; + @JsonProperty("event_meme") @JsonAlias("eventMeme") private String eventMeme; diff --git a/src/main/java/com/gxwebsoft/ai/entity/ManuscriptFile.java b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptFile.java new file mode 100644 index 0000000..fc61d17 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptFile.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.ai.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.io.Serializable; + +/** + * 稿件生成入参里的附件项。 + * + *

两端前端(tuanwei-admin 的 {@code ManuscriptGenParams.files}、tuanwei-uniapp 的 + * {@code buildPayload().files})发的都是**文件记录对象**,字段即上传接口 + * {@code /api/ai/file/upload} 的返回,因此这里对齐该形状。 + * + *

为什么不是 {@code java.io.File}:那是个只有路径字符串的本地文件抽象, + * Jackson 无法从 JSON 对象构造它,于是带附件的请求会在进 Controller 之前 + * 就以 {@code MismatchedInputException} 400 —— 而 {@code files: []} 时看不出来, + * 所以长期未被发现(见 issue 07)。 + * + *

放宽为忽略未知字段,是因为前端传的是 {@code Partial}, + * 字段集合随上传接口演进;后端不消费这些字段,不应因多一个键就整单失败。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ManuscriptFile implements Serializable { + + private static final long serialVersionUID = 1L; + + private String id; + + private String name; + + private String path; + + private String url; + + private String downloadUrl; + + private String contentType; +} diff --git a/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java index 6e39933..d99d7cf 100644 --- a/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java +++ b/src/main/java/com/gxwebsoft/ai/entity/ManuscriptGenInputs.java @@ -1,12 +1,9 @@ package com.gxwebsoft.ai.entity; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.annotations.ApiModel; import lombok.Data; import lombok.EqualsAndHashCode; -import java.io.File; import java.io.Serializable; import java.util.List; @@ -14,31 +11,33 @@ import java.util.List; @EqualsAndHashCode(callSuper = false) @ApiModel(value = "稿件生成对象") public class ManuscriptGenInputs implements Serializable { - @TableField(exist = false) + private String docType; - @TableField(exist = false) private String eventType; - @TableField(exist = false) private String theme; - @TableField(exist = false) private String time; - @TableField(exist = false) private String location; - @TableField(exist = false) private String participants; - @TableField(exist = false) private String highlights; - @TableField(exist = false) private Integer number; - @TableField(exist = false) - private List files; + /** + * 附件材料。 + * + *

类型是文件记录对象(见 {@link ManuscriptFile}),不是 {@code java.io.File}—— + * 后者无法从 JSON 对象反序列化,会让带附件的请求在进 Controller 前就 400(见 issue 07)。 + * + *

注意:Dify 侧的「AI内容创作与宣传助手」应用**没有文件类输入变量** + * (实测 {@code file_upload.enabled=false}),这些附件当前不会被转发给上游; + * 字段保留是为了不破坏两端前端的既有入参契约,不代表附件已生效。 + */ + private List files; } diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiActivityOutlineContractTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiActivityOutlineContractTest.java new file mode 100644 index 0000000..250cb9b --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiActivityOutlineContractTest.java @@ -0,0 +1,385 @@ +package com.gxwebsoft.ai.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.ai.entity.ActivityOutlineInputs; +import com.gxwebsoft.common.core.utils.JSONUtil; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue 09 的回归:{@code /activityOutlineGen} 的入参必须满足 Dify 开始节点契约。 + * + *

两条根因(均为单变量对照实测确证,见 {@code .scratch/ai-assistant/issues/09-*.md}): + *

    + *
  1. {@code event_funding} 在 Dify 上是 number。发 "待定"/"预算 5000 元" + * 这类不可解析的字符串 → {@code event_funding in input form must be a valid number}, + * 工作流 0.2s 内直接 failed,一个字都不生成。
  2. + *
  3. Dify 开始节点要求 {@code event_member} / {@code tel_people}(必填), + * 而实体里根本没有这两个字段,自然从不发送 → {@code event_member is required in input form}。
  4. + *
+ * + *

关键实测事实(决定了修复形态,与 issue 初稿的猜测不同): + * Dify 的 "is required in input form" 检查的是 键是否存在,不是值是否非空 —— + *

+ *   inputs 缺 event_member 键     → failed: event_member is required in input form
+ *   "event_member": ""           → succeeded(4752 条 message)
+ *   "event_funding": ""          → succeeded(7268 条 message)
+ *   "event_funding": "待定"       → failed: must be a valid number
+ * 
+ * 所以 issue 初稿里「改为不发送该字段」这条路径是行不通的(不发 = 报 required), + * 正确做法是键始终下发,值用空串兜底;且绝不能对 event_member / tel_people + * 加前置非空校验 —— 那会把两端前端本来就不采集这两个字段的默认入参挡在门外。 + */ +class AiActivityOutlineContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @SuppressWarnings("unchecked") + private Map buildInputs(ActivityOutlineInputs message) throws Exception { + Method method = AiController.class.getDeclaredMethod( + "buildActivityOutlineInputs", ActivityOutlineInputs.class); + method.setAccessible(true); + try { + return (Map) method.invoke(new AiController(), message); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private String toJson(Map inputs) throws Exception { + return objectMapper.writeValueAsString(inputs); + } + + private String validate(ActivityOutlineInputs message) throws Exception { + Method method = AiController.class.getDeclaredMethod( + "validateActivityOutline", ActivityOutlineInputs.class); + method.setAccessible(true); + try { + return (String) method.invoke(new AiController(), message); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private ActivityOutlineInputs baseInputs() { + ActivityOutlineInputs inputs = new ActivityOutlineInputs(); + inputs.setEventTheme("校园科技创新大赛"); + inputs.setPeople("全校师生"); + inputs.setEventTime("2026年10月"); + return inputs; + } + + // ------------------------------------------------ 根因 1:event_funding 必须是数字 + + @Test + void event_funding_纯数字字符串_下发为JSON数值而非字符串() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("5000"); + + Map result = buildInputs(inputs); + + assertTrue(result.get("event_funding") instanceof Number, + "event_funding 必须是数值类型,实际: " + result.get("event_funding")); + assertEquals("5000", String.valueOf(result.get("event_funding"))); + // 真正的判据是序列化后的 JSON 片段:加引号就是字符串,Dify 会报 must be a valid number + String json = toJson(result); + assertTrue(json.contains("\"event_funding\":5000"), + "JSON 里 event_funding 不能带引号,实际: " + json); + assertFalse(json.contains("\"event_funding\":\"5000\"")); + } + + @Test + void event_funding_自由文本_抽出其中数值() throws Exception { + // admin 端输入框的 placeholder / 示例值就是这种"预算 X 元,来源为…"的散文 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("预算 5000 元,来源为校团委专项经费"); + + Map result = buildInputs(inputs); + + assertTrue(result.get("event_funding") instanceof Number); + assertEquals("5000", String.valueOf(result.get("event_funding"))); + } + + @Test + void event_funding_带千分位逗号_仍能解析() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("12,000"); + + assertEquals("12000", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_小数_保留小数() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("5000.5"); + + assertEquals("5000.5", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_数字与单位间有空格_仍按倍率换算() throws Exception { + // "预算 1 万元" 若只认紧邻的「万」,会抽出 1 元 —— 静默错值,比留空更糟 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("预算 1 万元"); + + assertEquals("10000", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_亿与万_按倍率换算() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("预算 2 亿元"); + assertEquals("200000000", String.valueOf(buildInputs(inputs).get("event_funding"))); + + inputs = baseInputs(); + inputs.setEventFunding("1万元"); + assertEquals("10000", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_连续降序单位_相加() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("3万5千"); + + assertEquals("35000", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_日期不得被当成金额() throws Exception { + // 这是最危险的一类:2026 会被抽成金额并静默发出去 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("2026年4月办活动,预算5000元"); + + assertEquals("5000", String.valueOf(buildInputs(inputs).get("event_funding")), + "日期里的年份不能当金额"); + } + + @Test + void event_funding_只有日期没有金额_降级为空串() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("2026年4月18日"); + + assertEquals("", buildInputs(inputs).get("event_funding")); + } + + @Test + void event_funding_不得把后续的独立金额累加进来() throws Exception { + // 只取第一处金额;「另有 3000 元物资」是另一笔,不参与本字段 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("预算 5000 元,另有 3000 元物资"); + + assertEquals("5000", String.valueOf(buildInputs(inputs).get("event_funding"))); + } + + @Test + void event_funding_负数不作为合法金额() throws Exception { + // Dify 侧 number 变量不接受负的资金;降级为空串比发负数安全 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("-5000"); + + assertEquals("", buildInputs(inputs).get("event_funding")); + } + + @Test + void event_funding_中文待定_降级为空串而不是原样下发() throws Exception { + // 这是缺陷现象本身:uniapp 问答助手页的默认值就是 '待定'。 + // 发 '待定' → 工作流必定 failed;发 '' → 实测 succeeded。 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("待定"); + + Map result = buildInputs(inputs); + + assertEquals("", result.get("event_funding"), + "不可解析的资金文本必须降级为空串,原样下发会让整个工作流失败"); + assertTrue(toJson(result).contains("\"event_funding\":\"\"")); + } + + @Test + void event_funding_空白_下发为空串且键仍存在() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding(" "); + + Map result = buildInputs(inputs); + + assertTrue(result.containsKey("event_funding"), "键必须存在,缺失会报 is required"); + assertEquals("", result.get("event_funding")); + } + + // ------------------------------------------------ 根因 2:补齐 3 个变量,且键必须存在 + + @Test + void 新增的三个变量_映射为Dify的真实变量名() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventMember("学生会科创部"); + inputs.setPeopleManage("由学生会统一组织"); + inputs.setTelPeople("张同学 13800000000"); + + Map result = buildInputs(inputs); + + assertEquals("学生会科创部", result.get("event_member")); + assertEquals("由学生会统一组织", result.get("people_manage")); + assertEquals("张同学 13800000000", result.get("tel_people")); + // 不能漏成驼峰原名 + assertFalse(result.containsKey("eventMember")); + assertFalse(result.containsKey("peopleManage")); + assertFalse(result.containsKey("telPeople")); + } + + @Test + void 未采集的必填变量_以空串占位下发() throws Exception { + // 两端前端都不采集 event_member / tel_people。空串实测被上游接受, + // 缺席则报 is required —— 所以必须补空串,不能省略。 + Map result = buildInputs(baseInputs()); + + assertTrue(result.containsKey("event_member"), "event_member 键缺失会被上游拒绝"); + assertEquals("", result.get("event_member")); + assertTrue(result.containsKey("tel_people"), "tel_people 键缺失会被上游拒绝"); + assertEquals("", result.get("tel_people")); + } + + // ------------------------------------------------ 前置校验:不得比上游更严 + + @Test + void 校验_资金为空串时放行() throws Exception { + // 上游实测接受 event_funding="",而问答助手页的资金解析自用户自然语言, + // 用户没提钱时就是空。若在这里拦下,正常请求会变成 "event_funding不能为空"。 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding(""); + + assertNull(validate(inputs), "空资金是上游合法取值,不得阻塞"); + } + + @Test + void 校验_资金为不可解析文本时放行并降级() throws Exception { + // 填「待定」不该报错退出,而应由 normalizeEventFunding 降级成空串后照常生成 + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding("待定"); + + assertNull(validate(inputs), "不可解析的资金应降级而非报错"); + assertEquals("", buildInputs(inputs).get("event_funding")); + } + + @Test + void 校验_未采集的两个必填变量不阻塞() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventMember(null); + inputs.setTelPeople(null); + + assertNull(validate(inputs), "event_member/tel_people 前端不采集,不得阻塞"); + } + + @Test + void 校验_主题受众时间为空时拦截() throws Exception { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventTheme(" "); + assertNotNull(validate(inputs)); + + inputs = baseInputs(); + inputs.setPeople(""); + assertNotNull(validate(inputs)); + + inputs = baseInputs(); + inputs.setEventTime(null); + assertNotNull(validate(inputs)); + } + + @Test + void 不变量_校验放行的入参必带上游全部必填键() throws Exception { + // 这是本修复的核心契约:凡是 validateActivityOutline 放行的入参, + // buildActivityOutlineInputs 都不能漏掉上游的必填变量。 + // 逐个把字段清空,只要校验放行,键就必须在。 + String[] required = {"event_theme", "people", "event_time", + "event_funding", "event_member", "tel_people"}; + + String[][] variants = { + {"", "", "", ""}, // 只填主题受众时间,资金空 + {"待定", "待定", "待定", "待定"}, // 全填不可解析文本 + {"5000", null, null, null}, // 资金数值,其余未采集 + {"12,000", " ", "", null}, + }; + for (String[] v : variants) { + ActivityOutlineInputs inputs = baseInputs(); + inputs.setEventFunding(v[0]); + inputs.setEventMember(v[1]); + inputs.setPeopleManage(v[2]); + inputs.setTelPeople(v[3]); + + if (validate(inputs) != null) { + continue; // 被拦下的入参不在本不变量的讨论范围 + } + Map result = buildInputs(inputs); + for (String key : required) { + assertTrue(result.containsKey(key), + "校验放行了 " + java.util.Arrays.toString(v) + + ",但 inputs 漏了必填键 " + key + " → 上游会报 is required"); + } + } + } + + // ------------------------------------------------ 决策:不做事件级分支,只测边界 + + @Test + void 前端默认入参_全部键齐全且资金为空串() throws Exception { + // 复刻本仓库两端的默认入参形态:实体 7 字段 + '待定' 资金 + ActivityOutlineInputs inputs = new ActivityOutlineInputs(); + inputs.setEventTheme("入学教育主题活动"); + inputs.setPeople("团员青年"); + inputs.setEventTime("待定"); + inputs.setEventFunding("待定"); + inputs.setEventType(""); + inputs.setEventLocation(""); + inputs.setEventMeme("入学教育主题"); + + Map result = buildInputs(inputs); + + // 上游声明的 required 集合(实测)必须全在 + for (String required : new String[]{"event_theme", "people", "event_time", + "event_funding", "event_member", "tel_people"}) { + assertTrue(result.containsKey(required), "缺少必填变量 " + required); + } + assertEquals("", result.get("event_funding")); + assertEquals("待定", result.get("event_time")); + // 非必填且为空的字段不下发,保持原行为 + assertFalse(result.containsKey("event_type")); + assertFalse(result.containsKey("event_location")); + assertEquals("入学教育主题", result.get("event_meme")); + } + + @Test + void 空实体_不抛异常() throws Exception { + Map result = buildInputs(new ActivityOutlineInputs()); + + assertNotNull(result); + assertEquals("", result.get("event_funding")); + assertEquals("", result.get("event_member")); + assertEquals("", result.get("tel_people")); + } + + @Test + void 前端字段名反序列化_同时兼容snake与驼峰() { + String snake = "{\"event_theme\":\"主题\",\"event_member\":\"成员\"," + + "\"people_manage\":\"组织\",\"tel_people\":\"联系方式\",\"event_funding\":\"5000\"}"; + ActivityOutlineInputs a = JSONUtil.parseObject(snake, ActivityOutlineInputs.class); + assertNotNull(a); + assertEquals("成员", a.getEventMember()); + assertEquals("组织", a.getPeopleManage()); + assertEquals("联系方式", a.getTelPeople()); + + String camel = "{\"eventTheme\":\"主题\",\"eventMember\":\"成员\"," + + "\"peopleManage\":\"组织\",\"telPeople\":\"联系方式\"}"; + ActivityOutlineInputs b = JSONUtil.parseObject(camel, ActivityOutlineInputs.class); + assertNotNull(b); + assertEquals("主题", b.getEventTheme()); + assertEquals("成员", b.getEventMember()); + assertEquals("组织", b.getPeopleManage()); + assertEquals("联系方式", b.getTelPeople()); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFilesContractTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFilesContractTest.java new file mode 100644 index 0000000..db288c6 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptFilesContractTest.java @@ -0,0 +1,132 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.ai.entity.ManuscriptGenInputs; +import com.gxwebsoft.common.core.utils.JSONUtil; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue 07 的回归:稿件生成的 {@code files} 入参必须能承接两端前端实际发的形状。 + * + *

缺陷回顾:字段曾是 {@code List},而 Jackson 无法从 JSON 对象 + * 构造 {@code java.io.File},于是**只要选过附件**({@code files} 非空)请求就在进 + * Controller 前以 {@code MismatchedInputException} 400。{@code files: []} 时看不出来, + * 所以长期没被发现。 + * + *

这里锁的是**反序列化契约**本身——用真实的 JSON 文本(与前端 + * {@code buildPayload()} 产出一致)解析,而不是绕开 Jackson 直接 set 字段, + * 否则测不到缺陷所在的那一层。 + */ +class AiManuscriptFilesContractTest { + + /** tuanwei-admin / tuanwei-uniapp 的 buildPayload() 实际发出的形状。 */ + private static final String REAL_FILES_JSON = "[{" + + "\"id\":1," + + "\"name\":\"通知.pdf\"," + + "\"path\":\"/uploads/notice.pdf\"," + + "\"url\":\"http://localhost:38927/api/file/1\"," + + "\"downloadUrl\":\"http://localhost:38927/api/file/1/download\"," + + "\"contentType\":\"application/pdf\"" + + "}]"; + + private ManuscriptGenInputs parse(String filesJson) { + String json = "{\"docType\":\"新闻稿\",\"eventType\":\"校园活动\",\"theme\":\"主题\"," + + "\"highlights\":\"亮点\",\"number\":800,\"files\":" + filesJson + "}"; + return JSONUtil.parseObject(json, ManuscriptGenInputs.class); + } + + // ------------------------------------------------- 缺陷本体 + + @Test + void 前端真实形状的附件_能反序列化() { + ManuscriptGenInputs inputs = assertDoesNotThrow(() -> parse(REAL_FILES_JSON), + "前端发的文件对象数组必须能反序列化——这正是修复前 400 的那一步"); + + assertNotNull(inputs); + assertEquals(1, inputs.getFiles().size()); + assertEquals("通知.pdf", inputs.getFiles().get(0).getName()); + assertEquals("application/pdf", inputs.getFiles().get(0).getContentType()); + } + + @Test + void 附件为空数组_不回归() { + ManuscriptGenInputs inputs = parse("[]"); + + assertNotNull(inputs); + assertNotNull(inputs.getFiles()); + assertTrue(inputs.getFiles().isEmpty()); + } + + @Test + void 附件字段缺失_不回归() { + String json = "{\"docType\":\"新闻稿\",\"theme\":\"主题\"}"; + + ManuscriptGenInputs inputs = assertDoesNotThrow( + () -> JSONUtil.parseObject(json, ManuscriptGenInputs.class)); + + assertNotNull(inputs); + assertEquals(null, inputs.getFiles(), "未传则应为 null,不得抛异常"); + } + + // ------------------------------------------------- 宽容度 + + @Test + void 附件里的未知字段_被忽略而非整单失败() { + // 前端传的是 Partial,字段集合随上传接口演进 + String json = "[{\"id\":1,\"name\":\"a.png\"," + + "\"thumbnail\":\"http://x/thumb.png\"," + + "\"createUserId\":7,\"createTime\":\"2026-10-12 10:00:00\"," + + "\"someFutureField\":\"whatever\"}]"; + + ManuscriptGenInputs inputs = assertDoesNotThrow(() -> parse(json)); + + assertEquals("a.png", inputs.getFiles().get(0).getName()); + } + + @Test + void 多个附件_全部解析() { + String json = "[{\"id\":1,\"name\":\"a.png\"},{\"id\":2,\"name\":\"b.docx\"},{\"id\":3}]"; + + ManuscriptGenInputs inputs = parse(json); + + assertEquals(3, inputs.getFiles().size()); + assertEquals("a.png", inputs.getFiles().get(0).getName()); + assertEquals("b.docx", inputs.getFiles().get(1).getName()); + assertEquals(null, inputs.getFiles().get(2).getName(), "缺字段的项也应能解析"); + } + + // ------------------------------------------------- 边界:形状确实不对时仍应报错 + + @Test + void 附件是字符串而非数组_仍应报错() { + // 宽容不等于什么都收:契约仍是「对象数组」。 + // 注意 JSONUtil.parseObject 内部吞异常返回 null,故这里断言 null 而不是抛异常。 + ManuscriptGenInputs inputs = parse("\"not-an-array\""); + + assertEquals(null, inputs, "形状不对时不该静默产出一个半成品对象"); + } + + @Test + void 其余业务字段_映射不回归() { + String json = "{\"docType\":\"发言稿\",\"eventType\":\"会议论坛\",\"theme\":\"主题\"," + + "\"time\":\"2026年4月\",\"location\":\"礼堂\",\"participants\":\"120人\"," + + "\"highlights\":\"亮点\",\"number\":1500,\"files\":[]}"; + + ManuscriptGenInputs inputs = JSONUtil.parseObject(json, ManuscriptGenInputs.class); + + assertNotNull(inputs); + assertEquals("发言稿", inputs.getDocType()); + assertEquals("会议论坛", inputs.getEventType()); + assertEquals("2026年4月", inputs.getTime()); + assertEquals("礼堂", inputs.getLocation()); + assertEquals("120人", inputs.getParticipants()); + assertEquals(1500, inputs.getNumber()); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptHttpResultTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptHttpResultTest.java new file mode 100644 index 0000000..2f1d909 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptHttpResultTest.java @@ -0,0 +1,102 @@ +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue 08 的回归:{@code /manuscriptGen} 必须把正文放进 HTTP 返回的 {@code data}。 + * + *

缺陷回顾:该方法结尾是 {@code return success("Stream processing completed")}—— + * 只回一句状态文案,累积的 {@code reply}(整篇正文)被丢弃,且 {@code ApiResult} + * 带 {@code @JsonInclude(NON_NULL)} 会把空的 {@code data} 整个省略。 + * + *

于是 uni-app 的 {@code apiRequest}({@code chat-service.js:365}) + * 这一行回落生效: + *

return body.data !== undefined ? body.data : body.message
+ * {@code data} 为 undefined → 返回 {@code body.message} → + * 聊天气泡里出现 "Stream processing completed"。 + * + *

关键点:本接口虽同时走 WebSocket 推流,但**不是所有调用方都连了 WS** + * (小程序问答助手页在稿件模式下只发 HTTP),它们的正文唯一来源就是这个返回体。 + */ +class AiManuscriptHttpResultTest { + + private Map buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) + throws Exception { + Method method = AiController.class.getDeclaredMethod( + "buildManuscriptResult", StringBuilder.class, String.class, String.class); + method.setAccessible(true); + try { + return (Map) method.invoke(new AiController(), reply, taskId, workflowRunId); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + // ------------------------------------------------- 缺陷本体 + + @Test + void 返回体必须带正文() throws Exception { + StringBuilder reply = new StringBuilder("# 标题\n\n这是生成的正文。"); + + Map result = buildManuscriptResult(reply, "task-1", "run-1"); + + assertEquals("# 标题\n\n这是生成的正文。", result.get("answer"), + "正文必须出现在 data.answer,否则前端会回落到 message(状态文案)"); + } + + @Test + void 返回体不得只含状态文案() throws Exception { + StringBuilder reply = new StringBuilder("正文内容"); + + Map result = buildManuscriptResult(reply, null, null); + + assertFalse("Stream processing completed".equals(result.get("answer")), + "data.answer 不能是状态文案——那正是缺陷现象"); + assertEquals("正文内容", result.get("answer")); + } + + @Test + void 返回体始终含answer键_即使正文为空() throws Exception { + // 空正文也要给 answer:"",保证 data 非空(否则仍会回落到 message) + Map result = buildManuscriptResult(new StringBuilder(), null, null); + + assertTrue(result.containsKey("answer"), "answer 键必须存在,data 才不会被 Jackson 省略"); + assertEquals("", result.get("answer")); + } + + @Test + void reply为null_不抛异常且给空正文() throws Exception { + Map result = buildManuscriptResult(null, null, null); + + assertNotNull(result); + assertEquals("", result.get("answer")); + } + + // ------------------------------------------------- 附带字段 + + @Test + void 有taskId与runId时_一并返回() throws Exception { + Map result = buildManuscriptResult(new StringBuilder("正文"), "task-1", "run-1"); + + assertEquals("task-1", result.get("taskId")); + assertEquals("run-1", result.get("workflowRunId")); + } + + @Test + void 空taskId与runId_不出现在返回体() throws Exception { + // 前端有 `typeof result.taskId === "string"` 的消费点,空串反而会写进状态 + Map result = buildManuscriptResult(new StringBuilder("正文"), null, " "); + + assertNull(result.get("taskId")); + assertNull(result.get("workflowRunId")); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptUuidLeakTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptUuidLeakTest.java new file mode 100644 index 0000000..eb8f270 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiManuscriptUuidLeakTest.java @@ -0,0 +1,340 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.common.core.utils.JSONUtil; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue 06 的回归:Dify 的**内部标识符不得被当作正文**。 + * + *

缺陷回顾:{@code workflow_finished} 分支用 {@code extractAnswer} 的结果当正文追加, + * 而 {@code extractAnswer} 的兜底经 {@code findTextValue} 会取到 {@code data.id} + * (workflow_run_id / 节点 id),于是稿件正文末尾恒挂一个裸 UUID。 + * 该缺陷发生在**成功路径**上,此前的失败态测试({@link AiWorkflowFailureStatusTest}) + * 覆盖不到,故此文件单独锁住。 + * + *

这些测试直接调用 private 方法(与既有测试同一手法),因为它们正是缺陷所在层: + * 换用 MockMvc 端到端跑反而需要真实 Dify 连接。 + */ +class AiManuscriptUuidLeakTest { + + /** + * 真实的 workflow_finished 事件形状(取自内网实例实测): + * 顶层有 workflow_run_id,data.id 也是同一个 UUID,正文在 outputs.generated_content。 + */ + private static final String WORKFLOW_FINISHED_REAL = "{" + + "\"event\":\"workflow_finished\"," + + "\"task_id\":\"f5bc2921-92f1-4875-8967-f15d3ecd485e\"," + + "\"workflow_run_id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\"," + + "\"data\":{" + + "\"id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\"," + + "\"status\":\"succeeded\"," + + "\"outputs\":{\"generated_content\":\"# 标题\\n\\n正文内容。\"}," + + "\"files\":[]" + + "}" + + "}"; + + /** node_started 的形状:data 里没有正文,只有节点 id 等内部字段。 */ + private static final String NODE_STARTED_REAL = "{" + + "\"event\":\"node_started\"," + + "\"workflow_run_id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\"," + + "\"data\":{" + + "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\"," + + "\"title\":\"LLM\"," + + "\"node_type\":\"llm\"," + + "\"index\":3" + + "}" + + "}"; + + private Map parse(String json) { + Map map = JSONUtil.parseObject(json, Map.class); + assertNotNull(map, "测试样本必须是合法 JSON"); + return map; + } + + private Object invoke(String name, Class[] types, Object... args) throws Exception { + Method method = AiController.class.getDeclaredMethod(name, types); + method.setAccessible(true); + try { + return method.invoke(new AiController(), args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private boolean isDisplayableText(Object value) throws Exception { + return (Boolean) invoke("isDisplayableText", new Class[]{Object.class}, value); + } + + private String extractAnswer(Map data) throws Exception { + return (String) invoke("extractAnswer", new Class[]{Map.class}, data); + } + + private String extractWorkflowOutputs(Map data) throws Exception { + return (String) invoke("extractWorkflowOutputs", new Class[]{Map.class}, data); + } + + private String findTextValue(Map source) throws Exception { + return (String) invoke("findTextValue", new Class[]{Map.class}, source); + } + + private String buildLoadingText(String event, Map data) throws Exception { + return (String) invoke("buildLoadingText", new Class[]{String.class, Map.class}, event, data); + } + + // ------------------------------------------------- 缺陷本体:UUID 不得被当正文 + + @Test + void 真实workflow_finished事件_取不到UUID当正文() throws Exception { + Map event = parse(WORKFLOW_FINISHED_REAL); + + // 修复前:兜底进 findTextValue(data) 会返回 data.id 那个 UUID。 + // 修复后:要么取不到,要么取到 outputs 里的真正文——绝不能是 UUID。 + String answer = extractAnswer(event); + + assertFalse("73ba38bf-dd1a-4f05-bf8f-e5c0603401df".equals(answer), + "workflow_finished 的 data.id 是 workflow_run_id,绝不能被当成 answer"); + assertFalse(answer != null && answer.contains("73ba38bf"), + "answer 中不得出现 workflow_run_id"); + assertEquals("# 标题\n\n正文内容。", answer, + "既然 outputs 里有正文,就该取正文而不是放弃"); + } + + @Test + void 真实node_started事件_取不到节点id当正文() throws Exception { + assertNull(extractAnswer(parse(NODE_STARTED_REAL)), + "node_started 的 data.id 是节点 id,绝不能被当成 answer"); + } + + @Test + void findTextValue_对内部标识符不再兜底() throws Exception { + // 这是放大器本身:唯一 String 值是 UUID 时,修复前会被当成正文返回。 + Map onlyUuid = new LinkedHashMap<>(); + onlyUuid.put("id", "73ba38bf-dd1a-4f05-bf8f-e5c0603401df"); + onlyUuid.put("files", new java.util.ArrayList<>()); + + assertNull(findTextValue(onlyUuid), + "「取第一个 String 值」必须过可展示性校验,否则会把 data.id 当正文"); + + Map onlyToken = new LinkedHashMap<>(); + onlyToken.put("req_id", "req_0123456789abcdef0123456789abcdef"); + assertNull(findTextValue(onlyToken)); + } + + @Test + void findTextValue_未知输出变量名仍能取到正文() throws Exception { + // outputs 的变量名由工作流编排决定,无法穷举,故保留通用扫描 + Map outputs = new LinkedHashMap<>(); + outputs.put("some_future_output_name", "工作流产出的正文"); + + assertEquals("工作流产出的正文", findTextValue(outputs), + "outputs 里的未知变量名仍应能取到"); + } + + @Test + void findTextValue_已知键名优先() throws Exception { + Map outputs = new LinkedHashMap<>(); + outputs.put("noise", "不该选中的噪声"); + outputs.put("generated_content", "# 真正的正文"); + + assertEquals("# 真正的正文", findTextValue(outputs), "已知正文键名优先于通用扫描"); + } + + @Test + void 收尾事件_outputs兜底取到的是正文而非id() throws Exception { + Map event = parse(WORKFLOW_FINISHED_REAL); + + assertEquals("# 标题\n\n正文内容。", extractWorkflowOutputs(event), + "outputs.generated_content 才是正文"); + } + + // ------------------------------------------------- 可展示性判定 + + @Test + void UUID形状_判为不可展示() throws Exception { + assertFalse(isDisplayableText("73ba38bf-dd1a-4f05-bf8f-e5c0603401df")); + assertFalse(isDisplayableText("468b9a16-f18f-475b-a467-59b1836c9f88")); + assertFalse(isDisplayableText(" 73ba38bf-dd1a-4f05-bf8f-e5c0603401df "), + "前后空白不应绕过判定"); + assertFalse(isDisplayableText("73BA38BF-DD1A-4F05-BF8F-E5C0603401DF"), + "大写 UUID 同样是内部标识符"); + } + + @Test + void 无空格长token_判为不可展示() throws Exception { + assertFalse(isDisplayableText("abcdefghijklmnopqrstuvwxyz0123456789")); + assertFalse(isDisplayableText("req_0123456789abcdef0123456789abcdef")); + } + + @Test + void 正文_判为可展示() throws Exception { + assertTrue(isDisplayableText("# 青春启航 筑梦新程\n\n正文内容。")); + assertTrue(isDisplayableText("短句。")); + // 含空格的长串是正常正文(英文段落),不能误杀 + assertTrue(isDisplayableText("This is a normal English sentence with spaces.")); + } + + @Test + void 非字符串与空值_判为不可展示() throws Exception { + assertFalse(isDisplayableText(null)); + assertFalse(isDisplayableText("")); + assertFalse(isDisplayableText(" ")); + assertFalse(isDisplayableText(123)); + assertFalse(isDisplayableText(new LinkedHashMap<>())); + } + + // ------------------------------------------------- 加载中提示(issue 06 的次生面) + + /** + * 加载中气泡只能带**短提示**。 + * + *

这条是实施时踩到的坑:把 loading 分支改成「过可展示性校验就发」之后, + * {@code node_finished} 的 outputs 里有整篇稿件,于是 11KB 正文被当 loading 帧 + * 又推了一遍。修法是只取节点标题,绝不回退到 extractAnswer。 + */ + @Test + void 加载中提示_取节点标题而非整篇正文() throws Exception { + String json = "{\"event\":\"node_finished\",\"data\":{" + + "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\"," + + "\"title\":\"LLM\"," + + "\"status\":\"succeeded\"," + + "\"outputs\":{\"generated_content\":\"这里是很长很长的整篇稿件正文…\"}" + + "}}"; + + assertEquals("LLM", buildLoadingText("node_finished", parse(json)), + "加载提示应取节点标题,而不是 outputs 里的整篇正文"); + } + + @Test + void 加载中提示_取不到标题时不吐内部标识符() throws Exception { + // 无 title,只有 data.id:不能把 UUID 当提示 + String json = "{\"event\":\"node_started\",\"data\":{" + + "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\"}}"; + + String text = buildLoadingText("node_started", parse(json)); + + assertFalse("468b9a16-f18f-475b-a467-59b1836c9f88".equals(text), + "取不到标题时应退到事件名,而不是节点 id"); + assertEquals("node_started", text); + } + + @Test + void 加载中提示_agent_thought的thought优先可读() throws Exception { + String json = "{\"event\":\"agent_thought\",\"thought\":\"正在梳理提纲\"}"; + + assertEquals("正在梳理提纲", buildLoadingText("agent_thought", parse(json))); + } + + @Test + void 加载中提示_超长的thought弃用并退到事件名() throws Exception { + // agent_thought 的 thought 是思维链,实测可达数 KB。 + // 整个塞进 loading 气泡等于把思维链当提示推给端上。 + String longThought = repeat("让我先分析一下用户的需求,", 200); + String json = "{\"event\":\"agent_thought\",\"thought\":\"" + longThought + "\"}"; + + String text = buildLoadingText("agent_thought", parse(json)); + + assertEquals("agent_thought", text, + "超长 thought 不得作为 loading 气泡,应退到事件名"); + assertFalse(text != null && text.contains("让我先分析"), + "loading 气泡不得携带思维链"); + } + + @Test + void 加载中提示_超长的title也弃用() throws Exception { + String longTitle = repeat("标题很长", 100); + String json = "{\"event\":\"node_started\",\"data\":{\"title\":\"" + longTitle + "\"}}"; + + assertEquals("node_started", buildLoadingText("node_started", parse(json))); + } + + // ---------------------------------- 收尾事件:/chat-messages 的 message_end 形状 + + /** + * {@code /chat-messages} 的真实 message_end 形状:**没有** answer 字段, + * 正文早已由前面的 {@code message} 事件累积。大纲链路 + * ({@code activityOutlineGen})的收尾分支也用 {@code extractAnswer}, + * 这里锁定它在这种事件上取不到东西,不会凭空追加内容。 + */ + private static final String CHAT_MESSAGE_END_REAL = "{" + + "\"event\":\"message_end\"," + + "\"task_id\":\"f5bc2921-92f1-4875-8967-f15d3ecd485e\"," + + "\"message_id\":\"8f3a1c22-1111-4222-8333-abcdefabcdef\"," + + "\"conversation_id\":\"c1d2e3f4-5555-4666-8777-1234567890ab\"," + + "\"metadata\":{\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20}}," + + "\"usage\":{\"total_tokens\":30}" + + "}"; + + @Test + void 真实message_end事件_取不到任何内容() throws Exception { + Map event = parse(CHAT_MESSAGE_END_REAL); + + assertNull(extractAnswer(event), + "message_end 不带正文,不应被取出来追加(否则会重复或混入元数据)"); + assertNull(extractWorkflowOutputs(event), + "message_end 没有 outputs,兜底也应为空"); + } + + @Test + void message_end里混入UUID形态的answer_同样被拒() throws Exception { + // 防御性用例:即便某个版本在 message_end 里塞了 answer, + // 只要它是内部标识符,就不得被当正文下发给端上。 + String json = "{\"event\":\"message_end\",\"conversation_id\":" + + "\"c1d2e3f4-5555-4666-8777-1234567890ab\"," + + "\"answer\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\"}"; + + assertNull(extractAnswer(parse(json)), + "UUID 形态的 answer 必须被 isDisplayableText 挡掉"); + } + + @Test + void 加载中提示_绝不返回整篇正文() throws Exception { + String longBody = "# 标题\n\n" + repeat("这是一段足够长的正文内容。", 40); + String json = "{\"event\":\"node_finished\",\"data\":{\"outputs\":" + + "{\"generated_content\":\"" + longBody.replace("\n", "\\n") + "\"}}}"; + + String text = buildLoadingText("node_finished", parse(json)); + + assertFalse(text != null && text.length() > 200, + "loading 帧不得携带整篇正文,否则等于把正文重复推送一遍"); + assertEquals("node_finished", text, "无 title 时退到事件名"); + } + + private static String repeat(String unit, int times) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < times; i++) { + sb.append(unit); + } + return sb.toString(); + } + + // ------------------------------------------------- 防过度收敛(正文不能被误杀) + + @Test + void 正文事件_仍正常取到chunk() throws Exception { + String json = "{\"event\":\"text_chunk\",\"data\":{\"text\":\"正文片段\"}}"; + assertEquals("正文片段", extractAnswer(parse(json)), "text_chunk 的正文必须照常取到"); + } + + @Test + void agent_thought的thought_仍可取到() throws Exception { + String json = "{\"event\":\"agent_thought\",\"thought\":\"正在思考中\"}"; + assertEquals("正在思考中", extractAnswer(parse(json)), + "thought 是面向用户的加载提示,不应被 UUID 过滤误伤"); + } + + @Test + void extractWorkflowOutputs_嵌套data形状不回归() throws Exception { + String json = "{\"data\":{\"outputs\":{\"answer\":\"嵌套正文\"}}}"; + assertEquals("嵌套正文", extractWorkflowOutputs(parse(json))); + } +}