package com.gxwebsoft.ai.controller; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import com.alibaba.fastjson.JSONObject; import com.fasterxml.jackson.databind.ObjectMapper; import com.gxwebsoft.ai.config.DifyProperties; import com.gxwebsoft.ai.entity.ActivityOutlineInputs; import com.gxwebsoft.ai.entity.AiChatHistory; import com.gxwebsoft.ai.entity.AiChatList; import com.gxwebsoft.ai.entity.DeclarationInputs; import com.gxwebsoft.ai.entity.DocumentProcessInputs; import com.gxwebsoft.ai.entity.ManuscriptGenInputs; import com.gxwebsoft.ai.service.AiChatHistoryService; import com.gxwebsoft.ai.service.AiChatListService; import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.ai.config.WebSocketServer; import com.gxwebsoft.ai.entity.ChatMessage; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; 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; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.LinkedHashMap; 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 @RequestMapping("/api/ai") public class AiController extends BaseController { private static final Logger logger = LoggerFactory.getLogger(AiController.class); private static final long AI_REQUEST_TIMEOUT = 10 * 60 * 1000L; /** * 创意生成工作流以失败收场时给用户看的文案。 * *

上游的真实失败原因(如 {@code Model disabled})属于 Dify 侧配置问题, * 既不面向用户也不该泄露;这里统一给一句可读提示。 */ 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 private AiChatHistoryService aiChatHistoryService; @Resource private AiChatListService aiChatListService; @Resource private DifyProperties difyProperties; /** * 用于把入参实体序列化成 Map,尊重 Jackson 注解 */ private final ObjectMapper objectMapper = new ObjectMapper(); /** * Dify 接口地址前缀,取自 {@code dify.base-url} */ private String aiUrl() { return difyProperties.getBaseUrl(); } /** * 各应用 API Key,取自 {@code dify.apps.*}。 * *

原先这些 Key 硬编码在字段上,现统一外置到配置;取 Key 一律走这里, * 便于换环境时只动 yml。 */ private DifyProperties.AppKeys appKeys() { return difyProperties.getApps(); } /** * 单次请求超时(毫秒) */ private int aiTimeout() { Integer timeout = difyProperties.getTimeoutMs(); return timeout == null || timeout <= 0 ? 600000 : timeout; } private final Map activeAiTaskMap = new ConcurrentHashMap<>(); @PostMapping("/chat/message") public ApiResult message(@RequestBody ChatMessage message) throws IOException { try { URL url = new URL(aiUrl() + "/chat-messages"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", "Bearer " + appKeys().getChat()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); connection.setConnectTimeout(aiTimeout()); connection.setReadTimeout(aiTimeout()); Integer chatId = message.getChatId(); if (chatId == null || chatId == 0) { AiChatList aiChatList = new AiChatList(); aiChatList.setUserId(getLoginUserId()); aiChatList.setTitle(message.getQuery()); aiChatListService.save(aiChatList); chatId = aiChatList.getId(); } Map payload = new HashMap<>(); payload.put("query", message.getQuery()); payload.put("response_mode", StrUtil.blankToDefault(message.getResponseMode(), "streaming")); payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString()); payload.put("conversation_id", StrUtil.blankToDefault(message.getConversationId(), "")); payload.put("inputs", parseInputs(message.getInputs())); System.out.println("payload:" + payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); JSONObject result = JSONObject.parseObject(responseBody); throw new RuntimeException(result != null ? result.toJSONString() : responseBody); } AiChatHistory aiChatHistory = new AiChatHistory(); aiChatHistory.setUserId(getLoginUserId()); aiChatHistory.setChatId(chatId); aiChatHistory.setContent(message.getQuery()); StringBuilder reply = new StringBuilder(); String conversationId = null; String messageId = null; String taskId = null; boolean conversationSaved = false; boolean historySaved = false; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { if (StrUtil.isBlank(line) || !line.startsWith("data:")) { continue; } String json = line.substring(5).trim(); if (StrUtil.isBlank(json)) { continue; } Map data = JSONUtil.parseObject(json, Map.class); if (data == null) { continue; } Object eventObj = data.get("event"); String event = eventObj == null ? "" : eventObj.toString(); if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) { taskId = stringValue(data.get("task_id")); recordActiveAiTask(getLoginUserId(), "chat", taskId, appKeys().getChat()); } String currentConversationId = data.get("conversation_id") == null ? conversationId : data.get("conversation_id").toString(); String currentMessageId = data.get("message_id") == null ? messageId : data.get("message_id").toString(); if (StrUtil.isNotBlank(currentConversationId)) { conversationId = currentConversationId; if (!conversationSaved) { aiChatListService.updateConversationId(chatId, conversationId); conversationSaved = true; } } if (StrUtil.isNotBlank(currentMessageId)) { messageId = currentMessageId; } if ("message".equals(event)) { String answer = data.get("answer") == null ? null : data.get("answer").toString(); if (answer != null) { reply.append(answer); HashMap answerData = new HashMap<>(); answerData.put("answer", answer); answerData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); answerData.put("messageId", StrUtil.blankToDefault(messageId, "0")); answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); answerData.put("stopType", "chat"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); } } else if ("agent_log".equals(event) || "agent_thought".equals(event)) { String answer = data.get("thought") == null ? (data.get("answer") == null ? null : data.get("answer").toString()) : data.get("thought").toString(); if (answer != null) { System.out.println(answer); HashMap answerData = new HashMap<>(); answerData.put("answer", answer); answerData.put("loading", "true"); answerData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); answerData.put("messageId", StrUtil.blankToDefault(messageId, "0")); answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); answerData.put("stopType", "chat"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); } } else if ("message_end".equals(event) || "workflow_finished".equals(event)) { if (!historySaved) { historySaved = true; aiChatHistory.setConversationId(conversationId); aiChatHistory.setReply(reply.toString()); aiChatHistoryService.save(aiChatHistory); } HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); endData.put("messageId", StrUtil.blankToDefault(messageId, "0")); endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); endData.put("stopType", "chat"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); clearActiveAiTask(getLoginUserId(), taskId); } else if ("error".equals(event)) { throw new RuntimeException(json); } } } if (!historySaved) { aiChatHistory.setConversationId(conversationId); aiChatHistory.setReply(reply.toString()); aiChatHistoryService.save(aiChatHistory); HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); endData.put("messageId", StrUtil.blankToDefault(messageId, "0")); endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); endData.put("stopType", "chat"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); clearActiveAiTask(getLoginUserId(), taskId); } } catch (Exception e) { System.out.println(e.getMessage()); for (StackTraceElement stackTraceElement : e.getStackTrace()) { System.out.println(stackTraceElement); } HashMap answerData = new HashMap<>(); answerData.put("answer", "出错了,请晚点再来提问吧~"); answerData.put("taskId", "0"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); // webSocketServer.sendMessage(getLoginUserId().toString(), "出错了,请晚点再来提问吧~"); clearActiveAiTask(getLoginUserId(), null); return fail("出错了,请晚点再来提问吧~"); } // 返回成功响应 return success("Stream processing completed"); } @PostMapping("/manuscriptGen") public ApiResult manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException { try { URL url = new URL(aiUrl() + "/workflows/run"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", "Bearer " + appKeys().getManuscript()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); connection.setConnectTimeout(aiTimeout()); connection.setReadTimeout(aiTimeout()); Map inputs = buildManuscriptInputs(message); // 附件当前不转发给 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()); payload.put("inputs", inputs); System.out.println("manuscript payload:" + payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); JSONObject result = JSONObject.parseObject(responseBody); throw new RuntimeException(result != null ? result.toJSONString() : responseBody); } StringBuilder reply = new StringBuilder(); String workflowRunId = null; String taskId = null; boolean endSent = false; // 上游以 failed 收场时置真,用于给 HTTP 返回体带上 status(见 issue 10)。 // 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了, // 必须在外层作用域里记录。 boolean workflowFailed = false; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { if (StrUtil.isBlank(line) || !line.startsWith("data:")) { continue; } String json = line.substring(5).trim(); if (StrUtil.isBlank(json)) { continue; } Map data = JSONUtil.parseObject(json, Map.class); if (data == null) { continue; } String event = data.get("event") == null ? "" : data.get("event").toString(); if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) { workflowRunId = stringValue(data.get("workflow_run_id")); } if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) { taskId = stringValue(data.get("task_id")); } else if (StrUtil.isNotBlank(stringValue(data.get("id")))) { taskId = stringValue(data.get("id")); } if (StrUtil.isNotBlank(taskId)) { recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript()); } System.out.println(data); // 正文只认正文事件(text_chunk / message);其余事件的 // extractAnswer 结果可能是 data.id 这类内部标识符,不得进 reply(见 issue 06)。 String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); HashMap answerData = new HashMap<>(); answerData.put("answer", answer); answerData.put("taskId", StrUtil.blankToDefault(taskId, "0")); answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); } else if ("workflow_started".equals(event) || "node_started".equals(event) || "node_finished".equals(event) || "agent_log".equals(event) || "agent_thought".equals(event)) { // 这些事件只用于「加载中」提示,不下发正文。 // 不能用 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)。 if (isWorkflowFailed(workflowStatus(data))) { logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}", taskId, workflowRunId); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); HashMap failureData = new HashMap<>(); failureData.put("answer", CREATIVE_FAILED_MESSAGE); failureData.put("taskId", StrUtil.blankToDefault(taskId, "0")); failureData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); // reset=true:丢弃失败前可能已推给端的半截内容 failureData.put("reset", "true"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(failureData)); if (!endSent) { endSent = true; HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); } clearActiveAiTask(getLoginUserId(), taskId); continue; } // 收尾事件不产出正文:正文来自 text_chunk / message。 // 这里只在流式正文缺失时用 outputs 兜底,且兜底值同样要过 // 可展示性校验——否则会重演 issue 06(把 data.id 当正文)。 String finalAnswer = answer; if (finalAnswer == null) { finalAnswer = extractWorkflowOutputs(data); } if (isDisplayableText(finalAnswer) && reply.indexOf(finalAnswer) < 0) { // 注:isDisplayableText 已排除空串,故无需再判 length()==0。 // 反过来若把空串放进来,reply.indexOf("") 恒为 0 会误判"已存在" // 而短路掉正常内容,所以两道判断缺一不可。 reply.append(finalAnswer); HashMap answerData = new HashMap<>(); 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)); } if (!endSent) { endSent = true; HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); } clearActiveAiTask(getLoginUserId(), taskId); } else if ("error".equals(event)) { throw new RuntimeException(json); } } } if (!endSent) { HashMap endData = new HashMap<>(); endData.put("answer", "__END__"); endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); clearActiveAiTask(getLoginUserId(), taskId); } // 必须把累积的正文放进 data 一起回:只回一句状态文案时, // 前端(uni-app 的 apiRequest 会回落 body.message)会把 // "Stream processing completed" 当成正文显示(见 issue 08)。 // 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。 return success("Stream processing completed", buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed)); } catch (Exception e) { System.out.println(e.getMessage()); for (StackTraceElement stackTraceElement : e.getStackTrace()) { System.out.println(stackTraceElement); } HashMap answerData = new HashMap<>(); answerData.put("answer", "出错了,请晚点再试~"); answerData.put("taskId", "0"); webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData)); return fail("出错了,请晚点再试~"); } } /** * 稿件生成的 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) { return buildManuscriptResult(reply, taskId, workflowRunId, false); } /** * 构造稿件生成的 HTTP 返回体。 * *

{@code answer} 键始终存在(空正文给空串):{@code ApiResult} 带 * {@code @JsonInclude(NON_NULL)},若 {@code data} 为 null 会被整个省略, * 前端就会回落到 {@code message}("Stream processing completed")——见 issue 08。 * *

失败时必须带 {@code status=failed}:否则前端拿到的是 * {@code answer="生成失败,请稍后重试"},会把失败文案当正文渲染 * (issue 10 实测:三个 admin 页面都会这样),且无从据此判定失败。 * uniapp 的 {@code isFailedPayload} 读的就是这个字段。 * * @param failed 上游工作流是否以 failed 收场 */ private Map buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId, boolean failed) { Map result = new LinkedHashMap<>(); result.put("answer", reply == null ? "" : reply.toString()); if (failed) { result.put("status", "failed"); } if (StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } if (StrUtil.isNotBlank(workflowRunId)) { result.put("workflowRunId", workflowRunId); } return result; } @PostMapping("/creativeAssistant") public DeferredResult> creativeAssistant(@RequestBody Map requestBody) { final Integer loginUserId = getLoginUserId(); final DeferredResult> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT); final AtomicBoolean requestActive = new AtomicBoolean(true); deferredResult.onTimeout(() -> { requestActive.set(false); if (!deferredResult.isSetOrExpired()) { deferredResult.setResult(fail("AI创意助手处理超时,请稍后重试")); } }); deferredResult.onError((throwable) -> requestActive.set(false)); deferredResult.onCompletion(() -> requestActive.set(false)); CompletableFuture.runAsync(() -> { ApiResult result; try { result = executeCreativeAssistant(requestBody, loginUserId); } catch (Exception e) { logger.error("creative assistant request failed", e); result = fail("出错了,请晚点再试~"); } if (requestActive.get() && !deferredResult.isSetOrExpired()) { deferredResult.setResult(result); } }); return deferredResult; } @PostMapping("/competition-history/preprocess") public DeferredResult> competitionHistoryPreprocess( @RequestParam("files") List files, @RequestParam(value = "scoreFile", required = false) MultipartFile scoreFile, @RequestParam(value = "manualText", required = false) String manualText) { final Integer loginUserId = getLoginUserId(); final DeferredResult> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT); final AtomicBoolean requestActive = new AtomicBoolean(true); deferredResult.onTimeout(() -> { requestActive.set(false); if (!deferredResult.isSetOrExpired()) { deferredResult.setResult(fail("历史竞赛文件预处理超时,请稍后查看知识库列表")); } }); deferredResult.onError((throwable) -> requestActive.set(false)); deferredResult.onCompletion(() -> requestActive.set(false)); CompletableFuture.runAsync(() -> { ApiResult result; try { if (files == null || files.isEmpty()) { result = fail("请至少上传一个历史项目书文件"); } else { Map inputs = new LinkedHashMap<>(); List uploadedFiles = new ArrayList<>(); for (MultipartFile file : files) { if (file != null && !file.isEmpty()) { uploadedFiles.add(uploadMultipartToAi(file, appKeys().getCompetitionHistoryPreprocess(), loginUserId)); } } if (uploadedFiles.isEmpty()) { result = fail("请至少上传一个有效的历史项目书文件"); } else { inputs.put("file_upload", uploadedFiles); if (scoreFile != null && !scoreFile.isEmpty()) { inputs.put("score_file", uploadMultipartToAi(scoreFile, appKeys().getCompetitionHistoryPreprocess(), loginUserId)); } if (StrUtil.isNotBlank(manualText)) { inputs.put("manual_text", manualText.trim()); } result = executeWorkflowForResult(inputs, appKeys().getCompetitionHistoryPreprocess(), "competitionHistoryPreprocess", loginUserId, true); } } } catch (Exception e) { logger.error("competition history preprocess failed", e); result = fail("历史竞赛文件预处理失败: " + e.getMessage()); } if (requestActive.get() && !deferredResult.isSetOrExpired()) { deferredResult.setResult(result); } }); return deferredResult; } @PostMapping("/project-quality-review") public ApiResult projectQualityReview( @RequestParam("newProject") MultipartFile newProject, @RequestParam(value = "customRubric", required = false) MultipartFile customRubric) { Integer loginUserId = getLoginUserId(); try { if (newProject == null || newProject.isEmpty()) { return fail("请上传待审项目申报书"); } Map inputs = new LinkedHashMap<>(); inputs.put("new_project", uploadMultipartToAi(newProject, appKeys().getProjectQualityReview(), loginUserId)); if (customRubric != null && !customRubric.isEmpty()) { inputs.put("custom_rubric", uploadMultipartToAi(customRubric, appKeys().getProjectQualityReview(), loginUserId)); } return executeWorkflowForResult(inputs, appKeys().getProjectQualityReview(), "projectQualityReview", loginUserId, true); } catch (Exception e) { logger.error("project quality review failed", e); sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "projectQualityReview", false, loginUserId); sendWorkflowSocketEnd(null, null, "projectQualityReview", loginUserId); return fail("项目书质量初审失败: " + e.getMessage()); } } @ApiOperation("申报书生成") @PostMapping("/declarationGen") public ApiResult declarationGen(@RequestBody DeclarationInputs message) { Integer loginUserId = getLoginUserId(); if (message == null) { return fail("请求参数不能为空"); } // 核心创意是必填项(内网工作流 core_idea);upload_file 为可选的已有初稿/参考资料 if (StrUtil.isBlank(message.getCoreIdea())) { return fail("coreIdea不能为空"); } try { Map inputs = buildMappedInputs(message, message.getInputs(), difyProperties.getWorkflows().getDeclaration()); return executeWorkflowForResult(inputs, appKeys().getDeclaration(), "declaration", loginUserId, true); } catch (Exception e) { logger.error("declaration gen failed", e); sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "declaration", false, loginUserId); sendWorkflowSocketEnd(null, null, "declaration", loginUserId); return fail("申报书生成失败: " + e.getMessage()); } } @ApiOperation("智能文档处理") @PostMapping("/documentProcess") public ApiResult documentProcess(@RequestBody DocumentProcessInputs message) { Integer loginUserId = getLoginUserId(); if (message == null) { return fail("请求参数不能为空"); } // 网络地址与上传文件至少一个(内网工作流 pdf_url / pdf_file) if (StrUtil.isBlank(message.getPdfUrl()) && !hasFileValue(message.getPdfFile())) { return fail("pdfUrl与pdfFile至少需要一个"); } try { Map inputs = buildMappedInputs(message, message.getInputs(), difyProperties.getWorkflows().getDocumentProcess()); return executeWorkflowForResult(inputs, appKeys().getDocumentProcess(), "documentProcess", loginUserId, true); } catch (Exception e) { logger.error("document process failed", e); sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "documentProcess", false, loginUserId); sendWorkflowSocketEnd(null, null, "documentProcess", loginUserId); return fail("文档处理失败: " + e.getMessage()); } } @ApiOperation("智能文档处理(文件上传)") @PostMapping("/documentProcess/upload") public DeferredResult> documentProcessUpload( @RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(value = "pdfUrl", required = false) String pdfUrl, @RequestParam(value = "query", required = false) String query) { final Integer loginUserId = getLoginUserId(); final DeferredResult> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT); final AtomicBoolean requestActive = new AtomicBoolean(true); deferredResult.onTimeout(() -> { requestActive.set(false); if (!deferredResult.isSetOrExpired()) { deferredResult.setResult(fail("文档处理超时,请稍后查看知识库列表")); } }); deferredResult.onError((throwable) -> requestActive.set(false)); deferredResult.onCompletion(() -> requestActive.set(false)); CompletableFuture.runAsync(() -> { ApiResult result; try { if ((file == null || file.isEmpty()) && StrUtil.isBlank(pdfUrl)) { result = fail("请上传文件或提供pdfUrl"); } else { // 键名直接用业务字段名,再交给 remapInputs 按配置映射成真实变量名, // 避免在这里手写 "pdf_file" 之类的 Dify 变量名造成两处维护。 Map inputs = new LinkedHashMap<>(); if (file != null && !file.isEmpty()) { inputs.put("pdfFile", uploadMultipartToAi(file, appKeys().getDocumentProcess(), loginUserId)); } if (StrUtil.isNotBlank(pdfUrl)) { inputs.put("pdfUrl", pdfUrl.trim()); } if (StrUtil.isNotBlank(query)) { inputs.put("query", query.trim()); } result = executeWorkflowForResult( remapInputs(inputs, difyProperties.getWorkflows().getDocumentProcess()), appKeys().getDocumentProcess(), "documentProcess", loginUserId, true); } } catch (Exception e) { logger.error("document process upload failed", e); result = fail("文档处理失败: " + e.getMessage()); } if (requestActive.get() && !deferredResult.isSetOrExpired()) { deferredResult.setResult(result); } }); return deferredResult; } @PostMapping("/activityOutlineGen") public ApiResult activityOutlineGen(@RequestBody ActivityOutlineInputs message) { if (message == null) { return fail("请求参数不能为空"); } String validationError = validateActivityOutline(message); if (validationError != null) { return fail(validationError); } try { URL url = new URL(aiUrl() + "/chat-messages"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", "Bearer " + appKeys().getActivityOutline()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); connection.setConnectTimeout(aiTimeout()); connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲"); payload.put("response_mode", "streaming"); payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString()); payload.put("conversation_id", ""); payload.put("inputs", buildActivityOutlineInputs(message)); logger.info("activity outline payload: {}", payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); Map responseData = null; try { responseData = JSONUtil.parseObject(responseBody, Map.class); } catch (Exception ignored) { } String messageText = responseData == null ? responseBody : JSONUtil.toJSONString(responseData); return fail("活动策划大纲生成失败", messageText); } StringBuilder reply = new StringBuilder(); String conversationId = null; String messageId = null; boolean endSent = false; // 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。 boolean workflowFailed = false; try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { if (StrUtil.isBlank(line) || !line.startsWith("data:")) { continue; } String json = line.substring(5).trim(); if (StrUtil.isBlank(json)) { continue; } Map data = JSONUtil.parseObject(json, Map.class); if (data == null) { continue; } String event = stringValue(data.get("event")); if (StrUtil.isNotBlank(stringValue(data.get("conversation_id")))) { conversationId = stringValue(data.get("conversation_id")); } if (StrUtil.isNotBlank(stringValue(data.get("message_id")))) { messageId = stringValue(data.get("message_id")); } if ("message".equals(event)) { String answer = stringValue(data.get("answer")); if (answer != null) { reply.append(answer); sendActivityOutlineSocketMessage(answer, conversationId, messageId, false); } } else if ("agent_log".equals(event) || "agent_thought".equals(event)) { String loadingText = stringValue(data.get("thought")); if (loadingText == null) { loadingText = stringValue(data.get("answer")); } if (loadingText != null) { sendActivityOutlineSocketMessage(loadingText, conversationId, messageId, true); } } else if ("message_end".equals(event) || "workflow_finished".equals(event)) { // 失败态必须先判定:此时 outputs 为空、answer 可能是 // workflow_run_id 这类内部标识符,直接下发等于把标识符 // 当正文推给端上(见 issue 03)。 if (isWorkflowFailed(workflowStatus(data))) { logger.warn("activity outline workflow failed, conversationId={}, messageId={}", conversationId, messageId); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); // reset=true:丢弃失败前可能已推给端的半截内容, // 否则失败文案会追加在流式残片后面(见 issue 03)。 sendActivityOutlineSocketMessage(CREATIVE_FAILED_MESSAGE, conversationId, messageId, false, true); if (!endSent) { endSent = true; sendActivityOutlineSocketEnd(conversationId, messageId); } continue; } String answer = extractAnswer(data); if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) { reply.append(answer); sendActivityOutlineSocketMessage(answer, conversationId, messageId, false); } if (!endSent) { endSent = true; sendActivityOutlineSocketEnd(conversationId, messageId); } } else if ("error".equals(event)) { throw new RuntimeException(json); } } } if (!endSent) { sendActivityOutlineSocketEnd(conversationId, messageId); } // 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方 // (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket, // 返回体是它唯一的正文来源(见 issue 08)。 return success("Stream processing completed", buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed)); } catch (Exception e) { logger.error("activity outline request failed", e); sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false); sendActivityOutlineSocketEnd(null, null); return fail("出错了,请晚点再试~"); } } /** * 构造活动策划大纲的 HTTP 返回体。 * *

与 {@link #buildManuscriptResult(StringBuilder, String, String, boolean)} 同构: * {@code answer} 键始终存在(空正文给空串,避免 {@code data} 被 * {@code @JsonInclude(NON_NULL)} 省略而让前端回落到 {@code message},见 issue 08), * 失败时带 {@code status=failed} 以免失败文案被当正文渲染(见 issue 10)。 * * @param failed 上游工作流是否以 failed 收场 */ private Map buildActivityOutlineResult(StringBuilder reply, String conversationId, String messageId, boolean failed) { Map result = new LinkedHashMap<>(); result.put("answer", reply == null ? "" : reply.toString()); if (failed) { result.put("status", "failed"); } if (StrUtil.isNotBlank(conversationId)) { result.put("conversationId", conversationId); } if (StrUtil.isNotBlank(messageId)) { result.put("messageId", messageId); } return result; } private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) { sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false); } /** * @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。 * 失败收场时用它覆盖失败前可能已推给端的半截内容 * (见 issue 03)。 */ private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading, boolean reset) { HashMap data = new HashMap<>(); data.put("answer", answer == null ? "" : answer); data.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); data.put("messageId", StrUtil.blankToDefault(messageId, "0")); if (loading) { data.put("loading", "true"); } if (reset) { data.put("reset", "true"); } Integer loginUserId = getLoginUserId(); try { webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data)); } catch (IOException e) { logger.error("send activity outline socket message failed", e); } } private void sendActivityOutlineSocketEnd(String conversationId, String messageId) { HashMap data = new HashMap<>(); data.put("answer", "__END__"); data.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); data.put("messageId", StrUtil.blankToDefault(messageId, "0")); Integer loginUserId = getLoginUserId(); try { webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data)); } catch (IOException e) { logger.error("send activity outline socket end failed", e); } } private ApiResult executeCreativeAssistant(Map requestBody, Integer loginUserId) { String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content")); if (StrUtil.isBlank(userContent)) { return fail("user_content不能为空"); } HttpURLConnection connection = null; try { URL url = new URL(aiUrl() + "/workflows/run"); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", "Bearer " + appKeys().getCreativeAssistant()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); connection.setConnectTimeout(aiTimeout()); connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("inputs", buildCreativeAssistantInputs(requestBody)); payload.put("response_mode", "streaming"); payload.put("user", loginUserId == null ? "0" : loginUserId.toString()); logger.info("creative assistant payload: {}", payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); Map responseData = null; try { responseData = JSONUtil.parseObject(responseBody, Map.class); } catch (Exception ignored) { } String message = responseData == null ? responseBody : JSONUtil.toJSONString(responseData); return fail("AI创意助手调用失败", message); } StringBuilder reply = new StringBuilder(); String workflowRunId = null; String taskId = null; try (BufferedReader reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { logger.info("creative assistant stream: {}", line); if (StrUtil.isBlank(line) || !line.startsWith("data:")) { continue; } String json = line.substring(5).trim(); if (StrUtil.isBlank(json)) { continue; } Map data = JSONUtil.parseObject(json, Map.class); if (data == null) { continue; } if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) { workflowRunId = stringValue(data.get("workflow_run_id")); } if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) { taskId = stringValue(data.get("task_id")); } else if (StrUtil.isNotBlank(stringValue(data.get("id")))) { taskId = stringValue(data.get("id")); } if (StrUtil.isNotBlank(taskId)) { recordActiveAiTask(loginUserId, "workflow", taskId, appKeys().getCreativeAssistant()); } String event = stringValue(data.get("event")); String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); } if ("error".equals(event)) { throw new RuntimeException(json); } if ("message_end".equals(event) || "workflow_finished".equals(event)) { Map result = buildCreativeAssistantResult(data, json); if (isWorkflowFailed(stringValue(result.get("status")))) { // 失败态:buildCreativeAssistantResult 已给好可读文案, // 这里不得再用流式片段或 taskId 覆盖,也不下发 raw。 clearActiveAiTask(loginUserId, taskId); logger.warn("creative assistant workflow failed, workflowRunId={}, taskId={}", workflowRunId, taskId); return success(result); } if (StrUtil.isBlank(stringValue(result.get("answer"))) && reply.length() > 0) { result.put("answer", reply.toString()); } if (StrUtil.isBlank(stringValue(result.get("workflowRunId"))) && StrUtil.isNotBlank(workflowRunId)) { result.put("workflowRunId", workflowRunId); } if (StrUtil.isBlank(stringValue(result.get("taskId"))) && StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } clearActiveAiTask(loginUserId, taskId); return success(result); } } } Map result = new LinkedHashMap<>(); result.put("answer", reply.toString()); if (StrUtil.isNotBlank(workflowRunId)) { result.put("workflowRunId", workflowRunId); } if (StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } clearActiveAiTask(loginUserId, taskId); return success(result); } catch (Exception e) { logger.error("creative assistant execute failed", e); return fail("出错了,请晚点再试~"); } finally { if (connection != null) { connection.disconnect(); } } } private Map parseInputs(String inputs) { if (StrUtil.isBlank(inputs)) { return new HashMap<>(); } try { return JSONUtil.parseObject(inputs, Map.class); } catch (Exception e) { return Collections.emptyMap(); } } private String readStream(InputStream inputStream) throws IOException { if (inputStream == null) { return ""; } try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { StringBuilder sb = new StringBuilder(); String line; boolean firstLine = true; while ((line = br.readLine()) != null) { if (!firstLine) { sb.append('\n'); } sb.append(line); firstLine = false; } return sb.toString(); } } private Map buildManuscriptInputs(ManuscriptGenInputs message) { Map inputs = new LinkedHashMap<>(); if (message == null) { return inputs; } if (StrUtil.isNotBlank(message.getDocType())) { inputs.put("doc_type", message.getDocType()); } if (StrUtil.isNotBlank(message.getEventType())) { inputs.put("event_type", message.getEventType()); } if (StrUtil.isNotBlank(message.getTheme())) { inputs.put("theme", message.getTheme()); } if (StrUtil.isNotBlank(message.getTime())) { inputs.put("time", message.getTime()); } if (StrUtil.isNotBlank(message.getLocation())) { inputs.put("location", message.getLocation()); } if (StrUtil.isNotBlank(message.getParticipants())) { inputs.put("participants", message.getParticipants()); } if (StrUtil.isNotBlank(message.getHighlights())) { inputs.put("highlights", message.getHighlights()); } if (message.getNumber() != null) { // Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。 // 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。 inputs.put("Number", message.getNumber()); } 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) { 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。 * *

调用方显式传了 {@code inputs} 时以它为准(可覆盖映射配置); * 否则按 {@code mapping} 把非空业务字段改名后透传,未登记的字段按原名发送。 * 这样即使内网工作流的变量名与业务字段不一致,改 yml 即可对齐,无需改代码。 */ private Map buildMappedInputs(Object source, Map explicitInputs, DifyProperties.VariableMapping mapping) { if (explicitInputs != null && !explicitInputs.isEmpty()) { return new LinkedHashMap<>(explicitInputs); } Map raw = objectToMap(source); return remapInputs(raw, mapping); } /** * 按映射表改写 inputs 的键名。 */ private Map remapInputs(Map raw, DifyProperties.VariableMapping mapping) { Map result = new LinkedHashMap<>(); if (raw == null) { return result; } for (Map.Entry entry : raw.entrySet()) { Object value = entry.getValue(); if (value == null) { continue; } if (value instanceof String && StrUtil.isBlank((String) value)) { continue; } String variable = mapping == null ? entry.getKey() : mapping.variableOf(entry.getKey()); // 映射为空串表示该字段不发给 Dify if (StrUtil.isBlank(variable)) { continue; } result.put(variable, value); } return result; } /** * 用 Jackson 把入参实体转成 Map,跳过 null 字段。 * *

不用 JSONUtil:它对实体走的是 fastjson,字段命名策略与 Jackson 注解 * ({@code @JsonProperty}/{@code @JsonAlias})不一致。 */ private Map objectToMap(Object source) { if (source == null) { return new LinkedHashMap<>(); } try { String json = objectMapper.writeValueAsString(source); Map map = JSONUtil.parseObject(json, Map.class); return map == null ? new LinkedHashMap<>() : map; } catch (Exception e) { logger.warn("入参实体转换失败: {}", source.getClass().getSimpleName(), e); return new LinkedHashMap<>(); } } private Map buildCreativeAssistantInputs(Map requestBody) { Map inputs = new LinkedHashMap<>(); if (requestBody == null) { return inputs; } String userContent = stringValue(requestBody.get("user_content")); if (StrUtil.isNotBlank(userContent)) { inputs.put("user_content", userContent); } Object rawUserFiles = requestBody.get("user_files"); if (hasFileValue(rawUserFiles)) { Object userFiles = normalizeFiles(rawUserFiles); Object fileValue = userFiles != null ? userFiles : rawUserFiles; if (hasFileValue(fileValue)) { inputs.put("user_files", fileValue); } } return inputs; } private ApiResult executeWorkflowForResult(Map inputs, String apiKey, String stopType, Integer loginUserId, boolean streamToSocket) throws IOException { HttpURLConnection connection = null; try { URL url = new URL(aiUrl() + "/workflows/run"); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", "Bearer " + apiKey); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); connection.setConnectTimeout(aiTimeout()); connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs); payload.put("response_mode", "streaming"); payload.put("user", loginUserId == null ? "0" : loginUserId.toString()); logger.info("{} workflow payload: {}", stopType, payload); try (OutputStream os = connection.getOutputStream()) { os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { String responseBody = readStream(connection.getErrorStream()); return fail("AI工作流调用失败", responseBody); } StringBuilder reply = new StringBuilder(); String workflowRunId = null; String taskId = null; Map lastData = null; boolean endSent = false; boolean workflowFailed = false; try (BufferedReader reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { if (StrUtil.isBlank(line) || !line.startsWith("data:")) { continue; } String json = line.substring(5).trim(); if (StrUtil.isBlank(json)) { continue; } Map data = JSONUtil.parseObject(json, Map.class); if (data == null) { continue; } lastData = data; if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) { workflowRunId = stringValue(data.get("workflow_run_id")); } if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) { taskId = stringValue(data.get("task_id")); } else if (StrUtil.isNotBlank(stringValue(data.get("id")))) { taskId = stringValue(data.get("id")); } if (StrUtil.isNotBlank(taskId)) { recordActiveAiTask(loginUserId, stopType, taskId, apiKey); } String event = stringValue(data.get("event")); String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); if (streamToSocket) { sendWorkflowSocketMessage(answer, taskId, workflowRunId, stopType, false, loginUserId); } } else if ("workflow_started".equals(event) || "node_started".equals(event) || "node_finished".equals(event) || "agent_log".equals(event) || "agent_thought".equals(event)) { if (streamToSocket) { sendWorkflowSocketMessage(StrUtil.blankToDefault(answer, event), taskId, workflowRunId, stopType, true, loginUserId); } } else if ("workflow_finished".equals(event) || "message_end".equals(event)) { // 失败态必须先判定:outputs 为空时 answer 可能是 // workflow_run_id 这类内部标识符,不能当正文推给端上(见 issue 03)。 String eventStatus = workflowStatus(data); if (isWorkflowFailed(eventStatus)) { logger.warn("workflow failed, stopType={}, taskId={}, workflowRunId={}", stopType, taskId, workflowRunId); workflowFailed = true; reply.setLength(0); reply.append(CREATIVE_FAILED_MESSAGE); if (streamToSocket) { // reset=true:丢弃失败前可能已推给端的半截内容 sendWorkflowSocketMessage(CREATIVE_FAILED_MESSAGE, taskId, workflowRunId, stopType, false, loginUserId, true); } if (streamToSocket) { sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId); endSent = true; } clearActiveAiTask(loginUserId, taskId); continue; } String finalAnswer = answer == null ? extractWorkflowOutputs(data) : answer; if (finalAnswer != null && (finalAnswer.length() == 0 || reply.indexOf(finalAnswer) < 0)) { reply.append(finalAnswer); if (streamToSocket) { sendWorkflowSocketMessage(finalAnswer, taskId, workflowRunId, stopType, false, loginUserId); } } if (streamToSocket) { sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId); endSent = true; } clearActiveAiTask(loginUserId, taskId); } else if ("error".equals(event)) { throw new RuntimeException(json); } } } if (streamToSocket && !endSent) { sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId); } clearActiveAiTask(loginUserId, taskId); Map result = new LinkedHashMap<>(); result.put("answer", reply.toString()); if (StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } if (StrUtil.isNotBlank(workflowRunId)) { result.put("workflowRunId", workflowRunId); } // 失败态只回状态与可读文案,不下发 outputs / raw / 上游错误栈(见 issue 03)。 if (workflowFailed) { result.put("status", "failed"); result.put("answer", CREATIVE_FAILED_MESSAGE); return success(result); } if (lastData != null) { Object data = lastData.get("data"); if (data instanceof Map) { Object outputs = ((Map) data).get("outputs"); if (outputs != null) { result.put("outputs", outputs); } Object status = ((Map) data).get("status"); if (status != null) { result.put("status", status); } } result.put("raw", lastData); } return success(result); } finally { if (connection != null) { connection.disconnect(); } } } private void sendWorkflowSocketMessage(String answer, String taskId, String workflowRunId, String stopType, boolean loading, Integer loginUserId) { sendWorkflowSocketMessage(answer, taskId, workflowRunId, stopType, loading, loginUserId, false); } /** * @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。 * 失败收场时用它覆盖失败前可能已推给端的半截内容(见 issue 03)。 */ private void sendWorkflowSocketMessage(String answer, String taskId, String workflowRunId, String stopType, boolean loading, Integer loginUserId, boolean reset) { HashMap data = new HashMap<>(); data.put("answer", answer == null ? "" : answer); data.put("taskId", StrUtil.blankToDefault(taskId, "0")); data.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); data.put("stopType", StrUtil.blankToDefault(stopType, "workflow")); if (loading) { data.put("loading", "true"); } if (reset) { data.put("reset", "true"); } try { webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data)); } catch (IOException e) { logger.error("send workflow socket message failed", e); } } private void sendWorkflowSocketEnd(String taskId, String workflowRunId, String stopType, Integer loginUserId) { HashMap data = new HashMap<>(); data.put("answer", "__END__"); data.put("taskId", StrUtil.blankToDefault(taskId, "0")); data.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0")); data.put("stopType", StrUtil.blankToDefault(stopType, "workflow")); try { webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data)); } catch (IOException e) { logger.error("send workflow socket end failed", e); } } private Map uploadMultipartToAi(MultipartFile file, String apiKey, Integer loginUserId) throws IOException { File tempFile = null; try { String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin"); String suffix = ""; int suffixIndex = originalFilename.lastIndexOf('.'); if (suffixIndex >= 0) { suffix = originalFilename.substring(suffixIndex); } tempFile = File.createTempFile("ai-upload-", suffix); file.transferTo(tempFile); HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload") .header("Authorization", "Bearer " + apiKey) .form("user", loginUserId == null ? "0" : loginUserId.toString()) .form("file", tempFile, originalFilename) .timeout(aiTimeout()) .execute(); String responseBody = response.body(); Map result = JSONUtil.parseObject(responseBody, Map.class); if (response.getStatus() < 200 || response.getStatus() >= 300) { throw new IOException(result == null ? responseBody : JSONUtil.toJSONString(result)); } if (result == null) { throw new IOException("AI文件上传返回为空"); } Map fileValue = new LinkedHashMap<>(); fileValue.put("transfer_method", "local_file"); fileValue.put("upload_file_id", result.get("id")); fileValue.put("type", resolveAiFileType(originalFilename, stringValue(result.get("mime_type")))); fileValue.put("url", ""); fileValue.put("name", result.get("name")); fileValue.put("raw", result); return fileValue; } finally { if (tempFile != null && tempFile.exists()) { tempFile.delete(); } } } private String resolveAiFileType(String fileName, String mimeType) { String lowerName = StrUtil.blankToDefault(fileName, "").toLowerCase(); String lowerMime = StrUtil.blankToDefault(mimeType, "").toLowerCase(); if (lowerMime.startsWith("image/") || lowerName.matches(".*\\.(png|jpe?g|gif|webp|bmp)$")) { return "image"; } if (lowerMime.startsWith("audio/") || lowerName.matches(".*\\.(mp3|wav|m4a|aac|flac)$")) { return "audio"; } if (lowerMime.startsWith("video/") || lowerName.matches(".*\\.(mp4|mov|avi|mkv|webm)$")) { return "video"; } return "document"; } private boolean hasFileValue(Object value) { if (value == null) { return false; } if (value instanceof String) { return StrUtil.isNotBlank((String) value); } if (value instanceof List) { return !((List) value).isEmpty(); } if (value instanceof Map) { return !((Map) value).isEmpty(); } return true; } private Map buildCreativeAssistantResult(Map responseData, String responseBody) { Map result = new LinkedHashMap<>(); String status = workflowStatus(responseData); if (isWorkflowFailed(status)) { // 工作流失败:只回可读文案与状态,不下发 outputs / raw / 上游错误栈。 // raw 里含 workflow_id / task_id / req_id 与上游异常文本,端上没有任何消费者, // 一旦下发就会被前端兜底逻辑整段贴进聊天气泡(见 issue 01)。 result.put("status", status); result.put("answer", CREATIVE_FAILED_MESSAGE); return result; } if (responseData == null) { result.put("answer", responseBody); return result; } String answer = extractWorkflowOutputs(responseData); if (answer == null) { answer = extractAnswer(responseData); } if (answer != null) { result.put("answer", answer); } String workflowRunId = stringValue(responseData.get("workflow_run_id")); if (StrUtil.isNotBlank(workflowRunId)) { result.put("workflowRunId", workflowRunId); } String taskId = stringValue(responseData.get("task_id")); if (StrUtil.isNotBlank(taskId)) { result.put("taskId", taskId); } Object data = responseData.get("data"); if (data instanceof Map) { // status 已在方法开头经 workflowStatus 统一取出, // 这里只负责下发(含顶层 status 的兜底情况)。 if (status != null) { result.put("status", status); } Object outputs = ((Map) data).get("outputs"); if (outputs != null) { result.put("outputs", outputs); } } if (!result.containsKey("outputs") && responseData.get("outputs") != null) { result.put("outputs", responseData.get("outputs")); } result.put("raw", responseData); return result; } /** * 取 Dify 工作流事件里的执行状态。 * *

{@code workflow_finished} 事件把状态放在 {@code data.status}; * 少数事件会放在顶层,两处都看,取到即返回。 * * @return 状态字符串;取不到时为 {@code null} */ private String workflowStatus(Map responseData) { if (responseData == null) { return null; } Object data = responseData.get("data"); if (data instanceof Map) { Object status = ((Map) data).get("status"); if (status != null && StrUtil.isNotBlank(status.toString())) { return status.toString(); } } Object status = responseData.get("status"); if (status != null && StrUtil.isNotBlank(status.toString())) { return status.toString(); } return null; } /** * 判断工作流是否以失败收场。 * *

只在拿到明确失败状态时为真;状态缺失视为未失败,避免误伤正常响应。 */ private boolean isWorkflowFailed(String status) { return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status)); } private Map buildChatMessageResult(Map responseData, String responseBody) { Map result = new LinkedHashMap<>(); if (responseData == null) { result.put("answer", responseBody); return result; } String answer = extractAnswer(responseData); if (answer != null) { result.put("answer", answer); } String conversationId = stringValue(responseData.get("conversation_id")); if (StrUtil.isNotBlank(conversationId)) { result.put("conversationId", conversationId); } String messageId = stringValue(responseData.get("message_id")); if (StrUtil.isNotBlank(messageId)) { result.put("messageId", messageId); } if (responseData.get("metadata") != null) { result.put("metadata", responseData.get("metadata")); } result.put("raw", responseData); return result; } private Object normalizeFiles(Object files) { if (files == null) { return null; } if (files instanceof List) { return files; } if (files instanceof Map) { List fileList = new ArrayList<>(); fileList.add(files); return fileList; } if (files instanceof String && StrUtil.isNotBlank((String) files)) { String fileStr = (String) files; if (fileStr.trim().startsWith("[")) { Map wrapper = JSONUtil.parseObject("{\"files\":" + fileStr + "}", Map.class); return wrapper == null ? null : wrapper.get("files"); } if (fileStr.trim().startsWith("{")) { Map fileMap = JSONUtil.parseObject(fileStr, Map.class); if (fileMap != null) { List fileList = new ArrayList<>(); fileList.add(fileMap); return fileList; } } } return null; } private String extractAnswer(Map data) { if (data == null) { return null; } String[] keys = new String[]{"answer", "text", "chunk", "thought"}; for (String key : keys) { Object value = data.get(key); if (isDisplayableText(value)) { return (String) value; } } Object dataObj = data.get("data"); if (dataObj instanceof Map) { // 注意用 findPreferredTextValue 而非 findTextValue: // data 里除了正文还有 id / status 等元数据,后者的通用扫描 // 会把内部标识符或 "succeeded" 这类状态词当成正文(见 issue 06)。 String nestedAnswer = findPreferredTextValue((Map) dataObj); if (nestedAnswer != null) { return nestedAnswer; } } 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; } Object outputs = data.get("outputs"); if (outputs instanceof Map) { return findTextValue((Map) outputs); } Object dataObj = data.get("data"); if (dataObj instanceof Map) { Object nestedOutputs = ((Map) dataObj).get("outputs"); if (nestedOutputs instanceof Map) { return findTextValue((Map) nestedOutputs); } return findTextValue((Map) dataObj); } return null; } /** * 为「加载中」事件生成提示文案。 * *

只取**节点标题**这类天然短小、面向用户的字段(如「用户输入」「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", "chunk", "result", "output", "article", "manuscript", "generated_content"}; for (String key : preferredKeys) { Object value = source.get(key); if (isDisplayableText(value)) { return (String) value; } } for (Object value : source.values()) { 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) { String nestedValue = findTextValue((Map) value); if (nestedValue != null) { return nestedValue; } } } // 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。 // 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。 return null; } private String stringValue(Object value) { return value == null ? null : value.toString(); } private void recordActiveAiTask(Integer userId, String mode, String taskId, String apiKey) { if (userId == null || StrUtil.isBlank(taskId)) { return; } String normalizedMode = normalizeStopMode(mode); activeAiTaskMap.put(buildActiveTaskKey(userId, normalizedMode), new ActiveAiTask(taskId, normalizedMode, apiKey)); } private ActiveAiTask findActiveAiTask(Integer userId, String mode) { if (userId == null) { return null; } ActiveAiTask activeTask = activeAiTaskMap.get(buildActiveTaskKey(userId, normalizeStopMode(mode))); if (activeTask != null) { return activeTask; } if (!isChatStopMode(mode)) { return activeAiTaskMap.get(buildActiveTaskKey(userId, "workflow")); } return activeAiTaskMap.get(buildActiveTaskKey(userId, "chat")); } private void clearActiveAiTask(Integer userId, String taskId) { if (userId == null) { return; } if (StrUtil.isBlank(taskId)) { activeAiTaskMap.remove(buildActiveTaskKey(userId, "chat")); activeAiTaskMap.remove(buildActiveTaskKey(userId, "workflow")); return; } activeAiTaskMap.entrySet().removeIf(entry -> entry.getKey().startsWith(userId + ":") && taskId.equals(entry.getValue().getTaskId())); } private String resolveStopApiKey(String mode, ActiveAiTask activeTask) { if (activeTask != null && StrUtil.isNotBlank(activeTask.getApiKey())) { return activeTask.getApiKey(); } if ("image".equals(mode)) { return appKeys().getCreativeAssistant(); } if ("writing".equals(mode)) { return appKeys().getManuscript(); } if ("projectQualityReview".equals(mode)) { return appKeys().getProjectQualityReview(); } if ("competitionHistoryPreprocess".equals(mode)) { return appKeys().getCompetitionHistoryPreprocess(); } if ("documentProcess".equals(mode)) { return appKeys().getDocumentProcess(); } if ("declaration".equals(mode)) { return appKeys().getDeclaration(); } return appKeys().getChat(); } private String normalizeStopMode(String mode) { return isChatStopMode(mode) ? "chat" : "workflow"; } private boolean isChatStopMode(String mode) { return StrUtil.isBlank(mode) || "chat".equals(mode) || "message".equals(mode) || "activityOutline".equals(mode); } private String buildActiveTaskKey(Integer userId, String mode) { return userId + ":" + mode; } private static class ActiveAiTask { private final String taskId; private final String apiKey; ActiveAiTask(String taskId, String mode, String apiKey) { this.taskId = taskId; this.apiKey = apiKey; } String getTaskId() { return taskId; } String getApiKey() { return apiKey; } } @PostMapping("/messageStop") public ApiResult stop(@RequestBody Map data) { Integer loginUserId = getLoginUserId(); String mode = StrUtil.blankToDefault(stringValue(data == null ? null : data.get("mode")), "chat"); String taskId = stringValue(data == null ? null : data.get("taskId")); ActiveAiTask activeTask = findActiveAiTask(loginUserId, mode); if (StrUtil.isBlank(taskId) && activeTask != null) { taskId = activeTask.getTaskId(); } if (StrUtil.isBlank(taskId)) { return success("当前没有可停止的AI任务"); } String stopType = isChatStopMode(mode) ? "chat" : "workflow"; String apiKey = resolveStopApiKey(mode, activeTask); String stopPath = "chat".equals(stopType) ? "/chat-messages/" + taskId + "/stop" : "/workflows/tasks/" + taskId + "/stop"; Map postData = new HashMap<>(); postData.put("user", loginUserId == null ? "0" : loginUserId.toString()); logger.info("AI stop payload: mode={}, taskId={}, path={}", mode, taskId, stopPath); HttpResponse response = HttpRequest.post(aiUrl() + stopPath) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(JSONObject.toJSONString(postData)) .timeout(aiTimeout()) .execute(); String responseBody = response.body(); if (response.getStatus() < 200 || response.getStatus() >= 300) { logger.warn("AI stop failed: status={}, body={}", response.getStatus(), responseBody); return fail("停止AI任务失败", responseBody); } clearActiveAiTask(loginUserId, taskId); return success(responseBody); } @PostMapping("/file/upload") public ApiResult uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(value = "user", required = false) String user, @RequestParam(value = "type", required = false) Integer type) { if (file == null || file.isEmpty()) { return fail("文件不能为空"); } File tempFile = null; try { String token = appKeys().getManuscript(); if (type == null || type == 0) { token = appKeys().getChat(); } String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin"); String suffix = ""; int suffixIndex = originalFilename.lastIndexOf('.'); if (suffixIndex >= 0) { suffix = originalFilename.substring(suffixIndex); } tempFile = File.createTempFile("ai-upload-", suffix); file.transferTo(tempFile); HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload") .header("Authorization", "Bearer " + token) .form("user", StrUtil.blankToDefault(user, getLoginUserId().toString())) .form("file", tempFile, originalFilename) .timeout(aiTimeout()) .execute(); int responseCode = response.getStatus(); String responseBody = response.body(); Map result = JSONUtil.parseObject(responseBody, Map.class); if (responseCode < 200 || responseCode >= 300) { String message = result == null ? responseBody : JSONUtil.toJSONString(result); return fail("上传失败", message); } return success(result == null ? responseBody : result); } catch (Exception e) { return fail("上传失败", e.getMessage()); } finally { if (tempFile != null && tempFile.exists()) { tempFile.delete(); } } } }