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 event_member} / {@code tel_people}:两端前端都不采集,
+ * 由 {@link #buildActivityOutlineInputs} 补空串占位。
+ * 实测空串可让工作流 succeeded,加校验会把默认入参直接挡在门外。
+ * - {@code event_funding}:上游只要求"键存在且可解析为数值",
+ * 空串是合法取值(实测 {@code ""} → succeeded + 7268 条 message)。
+ * 而问答助手页的资金是从用户自然语言里解析的,用户没提钱时就是空,
+ * 此时大纲照样生成得出来,不该拦。
+ *
+ *
+ * 由此得到必须守住的不变量:上游声明的必填集合,其键必须全部下发——
+ * 本方法放行的任何入参都不得让 {@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
+ *
+ * 即「不可解析的文本必然让整个工作流失败,空串反而是安全的」,
+ * 因此这里抽出金额;抽不出就降级为空串,绝不原样下发。
+ *
+ * 两条必须先处理的坑,否则会静默发出错误金额(比留空更糟):
+ *
+ * - 日期:{@code "2026年4月办活动,预算5000元"} 里的 2026 会被当成金额,
+ * 故先用 {@link #EVENT_FUNDING_DATE_PATTERN} 剔除日期/时间表达式。
+ * - 数字与单位之间可能有空格:{@code "预算 1 万元"} 若只认紧邻的「万」,
+ * 会抽出 1 元。故用 {@code \s*} 容忍空白。
+ *
+ *
+ * 支持「万 / 亿 / 千 / 百」倍率,以及 {@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}):
+ *
+ * - {@code event_funding} 在 Dify 上是 number。发 "待定"/"预算 5000 元"
+ * 这类不可解析的字符串 → {@code event_funding in input form must be a valid number},
+ * 工作流 0.2s 内直接 failed,一个字都不生成。
+ * - Dify 开始节点要求 {@code event_member} / {@code tel_people}(必填),
+ * 而实体里根本没有这两个字段,自然从不发送 → {@code event_member is required in input form}。
+ *
+ *
+ * 关键实测事实(决定了修复形态,与 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)));
+ }
+}