From 8c994d9ed28e83b83831f803b8665e73a548a68b Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Mon, 21 Sep 2026 19:46:49 +0800 Subject: [PATCH] =?UTF-8?q?Dify=20=E9=85=8D=E7=BD=AE=E5=A4=96=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E5=88=87=E8=87=B3=E5=86=85=E7=BD=91=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接口地址与各应用 API Key 原先硬编码在 AiController 与 KnowledgeBaseController 中,换环境要改代码、密钥还会散落在多处。 新增 DifyProperties 统一收敛到 dify 配置节点,并补上文档中缺失的 两个应用与知识库管理接口。 - 新增 DifyProperties,外置 base-url / 8 个应用 Key / 知识库密钥, 默认值保持公网实例,本地开发不受影响 - 生产 profile 覆盖为内网 10.100.158.15,含全部 8 个应用 Key 与 两个知识库的独立密钥 - 新增申报书生成、智能文档处理两个工作流接口(含文件上传版本), 接入既有的 WebSocket 流式推送与停止机制 - KnowledgeBaseController 补齐知识库增删改查、文档管理与索引进度, 并对文档状态动作加白名单,避免路径参数被任意拼接 - 修正 AI 内容创作的 number 变量名:Dify 侧为 Number, 变量名大小写敏感,此前一直发送小写导致该功能缺参 - 生产下显式覆盖公网知识库 ID 的密钥:YAML 的 map 按键合并而非整体 替换,不覆盖会残留指向公网实例的死密钥 工作流变量名取自内网实例实测(probe-dify.sh),其中申报书生成与 文档处理两组变量与公网命名习惯不一致,故以配置映射承载,改名只需 改 yml 而非改代码。 新增 18 项测试,覆盖配置绑定、profile 覆盖完整性(逐项断言 8 个 Key 均已覆盖且不残留公网 Key)与入参到 Dify inputs 的映射。 --- .../com/gxwebsoft/WebSoftApplication.java | 3 +- .../gxwebsoft/ai/config/DifyProperties.java | 250 ++++++++++++++ .../gxwebsoft/ai/controller/AiController.java | 298 +++++++++++++--- .../controller/KnowledgeBaseController.java | 318 +++++++++++++----- .../ai/entity/DeclarationInputs.java | 72 ++++ .../ai/entity/DocumentProcessInputs.java | 58 ++++ src/main/resources/application-prod.yml | 54 ++- src/main/resources/application.yml | 67 ++++ .../ai/config/DifyProdProfileBindingTest.java | 166 +++++++++ .../ai/config/DifyPropertiesBindingTest.java | 114 +++++++ .../ai/controller/AiInputMappingTest.java | 197 +++++++++++ 11 files changed, 1465 insertions(+), 132 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/ai/config/DifyProperties.java create mode 100644 src/main/java/com/gxwebsoft/ai/entity/DeclarationInputs.java create mode 100644 src/main/java/com/gxwebsoft/ai/entity/DocumentProcessInputs.java create mode 100644 src/test/java/com/gxwebsoft/ai/config/DifyProdProfileBindingTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/config/DifyPropertiesBindingTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiInputMappingTest.java diff --git a/src/main/java/com/gxwebsoft/WebSoftApplication.java b/src/main/java/com/gxwebsoft/WebSoftApplication.java index d7f586c..514ef0d 100644 --- a/src/main/java/com/gxwebsoft/WebSoftApplication.java +++ b/src/main/java/com/gxwebsoft/WebSoftApplication.java @@ -1,5 +1,6 @@ package com.gxwebsoft; +import com.gxwebsoft.ai.config.DifyProperties; import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.gxmu.auth.GxmuAuthProperties; import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties; @@ -21,7 +22,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket; @EnableTransactionManagement @MapperScan("com.gxwebsoft.**.mapper") @EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class, - GxmuAuthProperties.class}) + GxmuAuthProperties.class, DifyProperties.class}) @SpringBootApplication @EnableScheduling @EnableWebSocket diff --git a/src/main/java/com/gxwebsoft/ai/config/DifyProperties.java b/src/main/java/com/gxwebsoft/ai/config/DifyProperties.java new file mode 100644 index 0000000..6880431 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/config/DifyProperties.java @@ -0,0 +1,250 @@ +package com.gxwebsoft.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Dify 平台对接配置。 + * + *

原先接口地址与各应用的 API Key 硬编码在 {@code AiController}、{@code KnowledgeBaseController} + * 中,换环境要改代码、Key 还会进版本库。此处统一外置到 {@code application.yml} 的 + * {@code dify} 节点,各环境可在 {@code application-{profile}.yml} 中按需覆盖。 + * + *

关于两套环境:文档《Dify API 文档》给出的是内网实例 {@code http://10.100.158.15/v1} + * (与开放平台 IP 白名单同机),当前代码里连的是公网实例 {@code http://82.156.98.71/v1}。 + * 二者应用 Key、知识库 ID 均不通用,因此这里按环境分别配置,默认沿用现有公网实例以保证在跑的功能不受影响。 + * + * @author Codex + * @since 2026-09-19 + */ +@Data +@ConfigurationProperties(prefix = "dify") +public class DifyProperties { + + /** + * 接口地址前缀,不含结尾斜杠 + */ + private String baseUrl = "http://82.156.98.71/v1"; + + /** + * 单次请求超时时间(毫秒)。AI 工作流耗时较长,默认 10 分钟。 + */ + private Integer timeoutMs = 600000; + + /** + * 各应用的 API Key,key 为应用代号。 + * + *

代号与 {@code AiController} 中的取用一一对应,见 {@link AppKeys}。 + */ + private AppKeys apps = new AppKeys(); + + /** + * 知识库(dataset)接口配置,走独立的 dataset- 前缀密钥 + */ + private Dataset dataset = new Dataset(); + + /** + * 工作流的变量名映射。 + * + *

仅用于「文档处理系统」「申报书生成」这两个当前实例上无法验证的工作流: + * 它们的工作流开始节点变量名未知,若在代码里写死,拿到真实变量名后就必须改代码。 + * 这里改成「业务字段名 -> Dify 变量名」的映射,配置里调整即可。 + */ + private WorkflowVariables workflows = new WorkflowVariables(); + + @Data + public static class WorkflowVariables { + + /** + * 医科大智能文档处理系统的变量映射 + */ + private VariableMapping documentProcess = new VariableMapping(); + + /** + * 申报书生成的变量映射 + */ + private VariableMapping declaration = new VariableMapping(); + } + + /** + * 业务字段名 -> Dify 工作流变量名的映射。 + * + *

未登记的字段按同名透传;映射到的变量名为空串表示丢弃该字段。 + */ + @Data + public static class VariableMapping { + + private Map fields = new LinkedHashMap<>(); + + /** + * 取字段对应的 Dify 变量名,未配置时返回原名。 + */ + /** + * 取字段对应的 Dify 变量名,未配置时返回原名。 + * + *

入参是 Java 侧序列化出来的驼峰名(如 {@code coreIdea}), + * 而 yml 里的键习惯写中划线({@code core-idea})。Spring 的松散绑定会把 + * 两种写法都装进 Map 的键里,但 Map 查询是精确匹配,因此这里统一转成 + * 中划线再查,避免配置"看着生效实则静默失配"。 + */ + public String variableOf(String field) { + if (field == null || fields == null) { + return field; + } + String mapped = fields.get(field); + if (mapped == null) { + mapped = fields.get(toKebabCase(field)); + } + return mapped == null ? field : mapped; + } + + /** + * 驼峰转中划线:{@code coreIdea -> core-idea}。已是中划线或全小写的原样返回。 + */ + private String toKebabCase(String value) { + StringBuilder sb = new StringBuilder(value.length() + 4); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) { + sb.append('-'); + } + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + } + + /** + * 各应用 API Key。字段名即应用代号,避免用魔法字符串取 Key 时拼错。 + */ + @Data + public static class AppKeys { + + /** + * 医科大团委智能知识库问答系统(chat-messages) + */ + private String chat; + + /** + * 活动策划大纲生成(chat-messages) + */ + private String activityOutline; + + /** + * AI 内容创作与宣传助手(workflow) + */ + private String manuscript; + + /** + * AI 创意助手(workflow) + */ + private String creativeAssistant; + + /** + * 项目书质量初审(workflow) + */ + private String projectQualityReview; + + /** + * 竞赛文件预处理(workflow) + */ + private String competitionHistoryPreprocess; + + /** + * 医科大智能文档处理系统(workflow) + */ + private String documentProcess; + + /** + * 申报书生成(workflow) + */ + private String declaration; + + /** + * 按应用代号取 Key,取不到返回 null。 + * + *

供停止任务时按记录的代号回查密钥使用。 + */ + public String byCode(String code) { + if (code == null) { + return null; + } + switch (code) { + case "chat": + return chat; + case "activityOutline": + return activityOutline; + case "manuscript": + return manuscript; + case "creativeAssistant": + return creativeAssistant; + case "projectQualityReview": + return projectQualityReview; + case "competitionHistoryPreprocess": + return competitionHistoryPreprocess; + case "documentProcess": + return documentProcess; + case "declaration": + return declaration; + default: + return null; + } + } + } + + /** + * 知识库接口配置 + */ + @Data + public static class Dataset { + + /** + * 知识库接口默认认证密钥。 + * + *

用于知识库列表等不区分知识库的接口。 + */ + private String apiKey; + + /** + * 知识库 ID -> 认证密钥。同一实例下不同知识库可能签发不同密钥。 + */ + private Map keys = new LinkedHashMap<>(); + + /** + * 知识库代号 -> 知识库 ID,便于按业务名引用。 + */ + private Map ids = new LinkedHashMap<>(); + + /** + * 取指定知识库的密钥,未单独配置时回退到默认密钥。 + */ + public String keyOf(String datasetId) { + if (datasetId != null && keys != null) { + String key = keys.get(datasetId); + if (key != null && !key.trim().isEmpty()) { + return key.trim(); + } + } + return apiKey; + } + + /** + * 按代号取知识库 ID。 + */ + public String idOf(String code) { + if (code == null || ids == null) { + return null; + } + return ids.get(code); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 8fa68c5..4727984 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -4,9 +4,13 @@ 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; @@ -16,6 +20,7 @@ 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; @@ -54,28 +59,53 @@ public class AiController extends BaseController { 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 String aiUrl = "http://82.156.98.71/v1"; - private String key = "app-ou0qPesouiopaPpmxcFV2gFG"; - private String manuscriptKey = "app-hCzkdd6OsnMnwI2dQByLyNIN"; - private String creativeAssistantKey = "app-l3accBEJLDAh9Pn8fLW8nNJ0"; - private String activityOutlineKey = "app-hp4PJknbejImmdDuMfziVcnN"; - private String competitionHistoryPreprocessKey = "app-bhyQLMAw2JEDXMTs2mzNRiIU"; - private String projectQualityReviewKey = "app-HiI2FrNjcwL0A7QOOf7GIP50"; 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"); + URL url = new URL(aiUrl() + "/chat-messages"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); - connection.setRequestProperty("Authorization", "Bearer " + key); + connection.setRequestProperty("Authorization", "Bearer " + appKeys().getChat()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); - connection.setConnectTimeout(600000); - connection.setReadTimeout(600000); + connection.setConnectTimeout(aiTimeout()); + connection.setReadTimeout(aiTimeout()); Integer chatId = message.getChatId(); if (chatId == null || chatId == 0) { @@ -137,7 +167,7 @@ public class AiController extends BaseController { String event = eventObj == null ? "" : eventObj.toString(); if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) { taskId = stringValue(data.get("task_id")); - recordActiveAiTask(getLoginUserId(), "chat", taskId, key); + recordActiveAiTask(getLoginUserId(), "chat", taskId, appKeys().getChat()); } String currentConversationId = data.get("conversation_id") == null ? conversationId @@ -239,15 +269,15 @@ public class AiController extends BaseController { @PostMapping("/manuscriptGen") public ApiResult manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException { try { - URL url = new URL(aiUrl + "/workflows/run"); + URL url = new URL(aiUrl() + "/workflows/run"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); - connection.setRequestProperty("Authorization", "Bearer " + manuscriptKey); + connection.setRequestProperty("Authorization", "Bearer " + appKeys().getManuscript()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); - connection.setConnectTimeout(600000); - connection.setReadTimeout(600000); + connection.setConnectTimeout(aiTimeout()); + connection.setReadTimeout(aiTimeout()); Map inputs = buildManuscriptInputs(message); Object files = normalizeFiles(message.getFiles()); @@ -302,7 +332,7 @@ public class AiController extends BaseController { taskId = stringValue(data.get("id")); } if (StrUtil.isNotBlank(taskId)) { - recordActiveAiTask(getLoginUserId(), "workflow", taskId, manuscriptKey); + recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript()); } System.out.println(data); String answer = extractAnswer(data); @@ -427,7 +457,7 @@ public class AiController extends BaseController { List uploadedFiles = new ArrayList<>(); for (MultipartFile file : files) { if (file != null && !file.isEmpty()) { - uploadedFiles.add(uploadMultipartToAi(file, competitionHistoryPreprocessKey, loginUserId)); + uploadedFiles.add(uploadMultipartToAi(file, appKeys().getCompetitionHistoryPreprocess(), loginUserId)); } } if (uploadedFiles.isEmpty()) { @@ -435,12 +465,12 @@ public class AiController extends BaseController { } else { inputs.put("file_upload", uploadedFiles); if (scoreFile != null && !scoreFile.isEmpty()) { - inputs.put("score_file", uploadMultipartToAi(scoreFile, competitionHistoryPreprocessKey, loginUserId)); + inputs.put("score_file", uploadMultipartToAi(scoreFile, appKeys().getCompetitionHistoryPreprocess(), loginUserId)); } if (StrUtil.isNotBlank(manualText)) { inputs.put("manual_text", manualText.trim()); } - result = executeWorkflowForResult(inputs, competitionHistoryPreprocessKey, + result = executeWorkflowForResult(inputs, appKeys().getCompetitionHistoryPreprocess(), "competitionHistoryPreprocess", loginUserId, true); } } @@ -465,11 +495,11 @@ public class AiController extends BaseController { return fail("请上传待审项目申报书"); } Map inputs = new LinkedHashMap<>(); - inputs.put("new_project", uploadMultipartToAi(newProject, projectQualityReviewKey, loginUserId)); + inputs.put("new_project", uploadMultipartToAi(newProject, appKeys().getProjectQualityReview(), loginUserId)); if (customRubric != null && !customRubric.isEmpty()) { - inputs.put("custom_rubric", uploadMultipartToAi(customRubric, projectQualityReviewKey, loginUserId)); + inputs.put("custom_rubric", uploadMultipartToAi(customRubric, appKeys().getProjectQualityReview(), loginUserId)); } - return executeWorkflowForResult(inputs, projectQualityReviewKey, + return executeWorkflowForResult(inputs, appKeys().getProjectQualityReview(), "projectQualityReview", loginUserId, true); } catch (Exception e) { logger.error("project quality review failed", e); @@ -479,6 +509,104 @@ public class AiController extends BaseController { } } + @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) { @@ -498,15 +626,15 @@ public class AiController extends BaseController { } try { - URL url = new URL(aiUrl + "/chat-messages"); + URL url = new URL(aiUrl() + "/chat-messages"); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); - connection.setRequestProperty("Authorization", "Bearer " + activityOutlineKey); + connection.setRequestProperty("Authorization", "Bearer " + appKeys().getActivityOutline()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); - connection.setConnectTimeout(600000); - connection.setReadTimeout(600000); + connection.setConnectTimeout(aiTimeout()); + connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲"); @@ -642,15 +770,15 @@ public class AiController extends BaseController { HttpURLConnection connection = null; try { - URL url = new URL(aiUrl + "/workflows/run"); + URL url = new URL(aiUrl() + "/workflows/run"); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); - connection.setRequestProperty("Authorization", "Bearer " + creativeAssistantKey); + connection.setRequestProperty("Authorization", "Bearer " + appKeys().getCreativeAssistant()); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "text/event-stream"); connection.setDoOutput(true); - connection.setConnectTimeout(600000); - connection.setReadTimeout(600000); + connection.setConnectTimeout(aiTimeout()); + connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("inputs", buildCreativeAssistantInputs(requestBody)); @@ -706,7 +834,7 @@ public class AiController extends BaseController { taskId = stringValue(data.get("id")); } if (StrUtil.isNotBlank(taskId)) { - recordActiveAiTask(loginUserId, "workflow", taskId, creativeAssistantKey); + recordActiveAiTask(loginUserId, "workflow", taskId, appKeys().getCreativeAssistant()); } String event = stringValue(data.get("event")); @@ -811,7 +939,9 @@ public class AiController extends BaseController { inputs.put("highlights", message.getHighlights()); } if (message.getNumber() != null) { - inputs.put("number", message.getNumber()); + // Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。 + // 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。 + inputs.put("Number", message.getNumber()); } return inputs; } @@ -845,6 +975,69 @@ public class AiController extends BaseController { return inputs; } + /** + * 把入参实体的业务字段转成 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) { @@ -872,15 +1065,15 @@ public class AiController extends BaseController { boolean streamToSocket) throws IOException { HttpURLConnection connection = null; try { - URL url = new URL(aiUrl + "/workflows/run"); + 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(600000); - connection.setReadTimeout(600000); + connection.setConnectTimeout(aiTimeout()); + connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs); @@ -1046,11 +1239,11 @@ public class AiController extends BaseController { } tempFile = File.createTempFile("ai-upload-", suffix); file.transferTo(tempFile); - HttpResponse response = HttpRequest.post(aiUrl + "/files/upload") + HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload") .header("Authorization", "Bearer " + apiKey) .form("user", loginUserId == null ? "0" : loginUserId.toString()) .form("file", tempFile, originalFilename) - .timeout(600000) + .timeout(aiTimeout()) .execute(); String responseBody = response.body(); Map result = JSONUtil.parseObject(responseBody, Map.class); @@ -1319,18 +1512,24 @@ public class AiController extends BaseController { return activeTask.getApiKey(); } if ("image".equals(mode)) { - return creativeAssistantKey; + return appKeys().getCreativeAssistant(); } if ("writing".equals(mode)) { - return manuscriptKey; + return appKeys().getManuscript(); } if ("projectQualityReview".equals(mode)) { - return projectQualityReviewKey; + return appKeys().getProjectQualityReview(); } if ("competitionHistoryPreprocess".equals(mode)) { - return competitionHistoryPreprocessKey; + return appKeys().getCompetitionHistoryPreprocess(); } - return key; + if ("documentProcess".equals(mode)) { + return appKeys().getDocumentProcess(); + } + if ("declaration".equals(mode)) { + return appKeys().getDeclaration(); + } + return appKeys().getChat(); } private String normalizeStopMode(String mode) { @@ -1343,7 +1542,6 @@ public class AiController extends BaseController { || "message".equals(mode) || "activityOutline".equals(mode); } - private String buildActiveTaskKey(Integer userId, String mode) { return userId + ":" + mode; } @@ -1387,11 +1585,11 @@ public class AiController extends BaseController { 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) + HttpResponse response = HttpRequest.post(aiUrl() + stopPath) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(JSONObject.toJSONString(postData)) - .timeout(600000) + .timeout(aiTimeout()) .execute(); String responseBody = response.body(); if (response.getStatus() < 200 || response.getStatus() >= 300) { @@ -1411,9 +1609,9 @@ public class AiController extends BaseController { } File tempFile = null; try { - String token = manuscriptKey; + String token = appKeys().getManuscript(); if (type == null || type == 0) { - token = key; + token = appKeys().getChat(); } String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin"); String suffix = ""; @@ -1424,11 +1622,11 @@ public class AiController extends BaseController { tempFile = File.createTempFile("ai-upload-", suffix); file.transferTo(tempFile); - HttpResponse response = HttpRequest.post(aiUrl + "/files/upload") + HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload") .header("Authorization", "Bearer " + token) .form("user", StrUtil.blankToDefault(user, getLoginUserId().toString())) .form("file", tempFile, originalFilename) - .timeout(600000) + .timeout(aiTimeout()) .execute(); int responseCode = response.getStatus(); diff --git a/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java b/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java index d5d2257..6b5fc7c 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/KnowledgeBaseController.java @@ -1,28 +1,66 @@ package com.gxwebsoft.ai.controller; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.ai.config.DifyProperties; +import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import javax.annotation.Resource; import java.io.BufferedReader; +import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +/** + * 知识库(Dify dataset)接口代理。 + * + *

对应《Dify API 文档》第 5 节。接口地址与密钥全部取自 {@code dify.*} 配置, + * 不再硬编码;知识库 ID 按业务代号在配置里登记,见 {@code dify.dataset.ids}。 + * + *

注意:文件必须先经「医科大智能文档处理系统」或「竞赛文件预处理」工作流处理后才可入库, + * 本控制器只做 Dify 原生接口的透传,不做入库前的格式处理。 + * + * @author Codex + * @since 2026-09-19 + */ @Api(tags = "知识库") @RestController @RequestMapping("/api/ai/knowledge-base") public class KnowledgeBaseController extends BaseController { - private static final String DATASET_API_URL = "http://82.156.98.71/v1/datasets"; - private static final String CHALLENGE_CUP_DATASET_ID = "b342f096-98b1-4a83-a296-f4ab60a6db0e"; - private static final String DATASET_API_KEY = "dataset-1n6MECRtQvEVGOL1o7SrSGfe"; + private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseController.class); + + /** + * 文档启停归档的动作白名单,防止路径参数被拼到 URL 上 + */ + private static final java.util.Set DOC_STATUS_ACTIONS = + new java.util.HashSet<>(java.util.Arrays.asList("enable", "disable", "archive", "unarchive")); + + @Resource + private DifyProperties difyProperties; + + // ---------------------------------------------------------------- 知识库 @ApiOperation("知识库列表") @GetMapping("/list") @@ -30,102 +68,222 @@ public class KnowledgeBaseController extends BaseController { @RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "limit", defaultValue = "20") Integer limit, @RequestParam(value = "keyword", required = false) String keyword) { - try { - StringBuilder urlBuilder = new StringBuilder(DATASET_API_URL); - urlBuilder.append("?page=").append(page); - urlBuilder.append("&limit=").append(limit); - if (keyword != null && !keyword.trim().isEmpty()) { - urlBuilder.append("&keyword=").append(java.net.URLEncoder.encode(keyword.trim(), "UTF-8")); - } - - URL url = new URL(urlBuilder.toString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Authorization", "Bearer " + DATASET_API_KEY); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setConnectTimeout(30000); - connection.setReadTimeout(30000); - - int responseCode = connection.getResponseCode(); - if (responseCode < 200 || responseCode >= 300) { - StringBuilder errorBody = new StringBuilder(); - try (BufferedReader br = new BufferedReader( - new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - errorBody.append(line); - } - } - return fail("获取知识库列表失败", errorBody.toString()); - } - - StringBuilder responseBody = new StringBuilder(); - try (BufferedReader br = new BufferedReader( - new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - responseBody.append(line); - } - } - - com.alibaba.fastjson.JSONObject result = com.alibaba.fastjson.JSONObject.parseObject(responseBody.toString()); - return success(result); - } catch (Exception e) { - return fail("获取知识库列表失败: " + e.getMessage()); + StringBuilder url = new StringBuilder(datasetsUrl()); + url.append("?page=").append(safePage(page)); + url.append("&limit=").append(safeLimit(limit)); + if (StrUtil.isNotBlank(keyword)) { + url.append("&keyword=").append(urlEncode(keyword.trim())); } + return proxy("GET", url.toString(), null, null, "获取知识库列表失败"); } + @ApiOperation("获取知识库详情") + @GetMapping("/{datasetId}") + public ApiResult detail(@PathVariable("datasetId") String datasetId) { + return proxy("GET", datasetsUrl() + "/" + datasetId, null, datasetId, "获取知识库详情失败"); + } + + @ApiOperation("修改知识库") + @PatchMapping("/{datasetId}") + public ApiResult update(@PathVariable("datasetId") String datasetId, + @RequestBody(required = false) Map body) { + return proxy("PATCH", datasetsUrl() + "/" + datasetId, body, datasetId, "修改知识库失败"); + } + + @ApiOperation("删除知识库") + @DeleteMapping("/{datasetId}") + public ApiResult delete(@PathVariable("datasetId") String datasetId) { + return proxy("DELETE", datasetsUrl() + "/" + datasetId, null, datasetId, "删除知识库失败"); + } + + @ApiOperation("检索知识库") + @PostMapping("/{datasetId}/retrieve") + public ApiResult retrieve(@PathVariable("datasetId") String datasetId, + @RequestBody(required = false) Map body) { + Map payload = body == null ? new LinkedHashMap<>() : new LinkedHashMap<>(body); + if (StrUtil.isBlank(stringValue(payload.get("query")))) { + return fail("query不能为空"); + } + return proxy("POST", datasetsUrl() + "/" + datasetId + "/retrieve", payload, datasetId, "检索知识库失败"); + } + + // ---------------------------------------------------------------- 文档管理 + + @ApiOperation("获取文档列表") + @GetMapping("/{datasetId}/documents") + public ApiResult documents( + @PathVariable("datasetId") String datasetId, + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "limit", defaultValue = "20") Integer limit, + @RequestParam(value = "keyword", required = false) String keyword) { + StringBuilder url = new StringBuilder(datasetsUrl()) + .append("/").append(datasetId).append("/documents") + .append("?page=").append(safePage(page)) + .append("&limit=").append(safeLimit(limit)); + if (StrUtil.isNotBlank(keyword)) { + url.append("&keyword=").append(urlEncode(keyword.trim())); + } + return proxy("GET", url.toString(), null, datasetId, "获取文档列表失败"); + } + + @ApiOperation("获取文档详情") + @GetMapping("/{datasetId}/documents/{documentId}") + public ApiResult documentDetail(@PathVariable("datasetId") String datasetId, + @PathVariable("documentId") String documentId) { + return proxy("GET", datasetsUrl() + "/" + datasetId + "/documents/" + documentId, + null, datasetId, "获取文档详情失败"); + } + + @ApiOperation("查询索引进度") + @GetMapping("/{datasetId}/documents/{batch}/indexing-status") + public ApiResult indexingStatus(@PathVariable("datasetId") String datasetId, + @PathVariable("batch") String batch) { + return proxy("GET", datasetsUrl() + "/" + datasetId + "/documents/" + batch + "/indexing-status", + null, datasetId, "查询索引进度失败"); + } + + @ApiOperation("启用、禁用、归档文档") + @PatchMapping("/{datasetId}/documents/status/{action}") + public ApiResult updateDocumentStatus(@PathVariable("datasetId") String datasetId, + @PathVariable("action") String action, + @RequestBody(required = false) Map body) { + if (!DOC_STATUS_ACTIONS.contains(action)) { + return fail("不支持的操作: " + action + ",仅支持 " + DOC_STATUS_ACTIONS); + } + return proxy("PATCH", datasetsUrl() + "/" + datasetId + "/documents/status/" + action, + body == null ? new LinkedHashMap<>() : body, datasetId, "更新文档状态失败"); + } + + @ApiOperation("删除文档") + @DeleteMapping("/{datasetId}/documents/{documentId}") + public ApiResult deleteDocument(@PathVariable("datasetId") String datasetId, + @PathVariable("documentId") String documentId) { + return proxy("DELETE", datasetsUrl() + "/" + datasetId + "/documents/" + documentId, + null, datasetId, "删除文档失败"); + } + + // ---------------------------------------------------------------- 兼容旧接口 + @ApiOperation("挑战杯历史案例知识库文档列表") @GetMapping("/challenge-cup/documents") public ApiResult challengeCupDocuments( @RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "limit", defaultValue = "20") Integer limit, @RequestParam(value = "keyword", required = false) String keyword) { - try { - int safePage = page == null || page < 1 ? 1 : page; - int safeLimit = limit == null || limit < 1 ? 20 : Math.min(limit, 100); - StringBuilder urlBuilder = new StringBuilder(DATASET_API_URL); - urlBuilder.append("/").append(CHALLENGE_CUP_DATASET_ID).append("/documents"); - urlBuilder.append("?page=").append(safePage); - urlBuilder.append("&limit=").append(safeLimit); - if (keyword != null && !keyword.trim().isEmpty()) { - urlBuilder.append("&keyword=").append(java.net.URLEncoder.encode(keyword.trim(), "UTF-8")); - } + String datasetId = difyProperties.getDataset().idOf("challenge-cup"); + if (StrUtil.isBlank(datasetId)) { + return fail("未配置 challenge-cup 知识库 ID,请检查 dify.dataset.ids.challenge-cup"); + } + return documents(datasetId, page, limit, keyword); + } - URL url = new URL(urlBuilder.toString()); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Authorization", "Bearer " + DATASET_API_KEY); + // ---------------------------------------------------------------- 内部实现 + + /** + * 知识库接口地址前缀 + */ + private String datasetsUrl() { + return trimTrailingSlash(difyProperties.getBaseUrl()) + "/datasets"; + } + + private int safePage(Integer page) { + return page == null || page < 1 ? 1 : page; + } + + private int safeLimit(Integer limit) { + return limit == null || limit < 1 ? 20 : Math.min(limit, 100); + } + + private String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (Exception e) { + return value; + } + } + + private String trimTrailingSlash(String value) { + if (value == null) { + return ""; + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + private String stringValue(Object value) { + return value == null ? null : value.toString(); + } + + /** + * 透传调用 Dify 知识库接口。 + * + * @param method HTTP 方法 + * @param url 完整地址 + * @param body 请求体,GET/DELETE 传 null + * @param datasetId 知识库 ID,用于挑选对应的密钥;为空时用默认密钥 + * @param errorMessage 失败时的提示前缀 + */ + private ApiResult proxy(String method, + String url, + Map body, + String datasetId, + String errorMessage) { + String apiKey = difyProperties.getDataset().keyOf(datasetId); + if (StrUtil.isBlank(apiKey)) { + return fail(errorMessage + ": 未配置知识库 API Key,请检查 dify.dataset.api-key"); + } + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod(method); + connection.setRequestProperty("Authorization", "Bearer " + apiKey); connection.setRequestProperty("Content-Type", "application/json"); connection.setConnectTimeout(30000); connection.setReadTimeout(30000); + if (body != null) { + connection.setDoOutput(true); + try (OutputStream os = connection.getOutputStream()) { + os.write(JSONUtil.toJSONString(body).getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } int responseCode = connection.getResponseCode(); + String responseBody = readAll(responseCode >= 200 && responseCode < 300 + ? connection.getInputStream() + : connection.getErrorStream()); if (responseCode < 200 || responseCode >= 300) { - StringBuilder errorBody = new StringBuilder(); - try (BufferedReader br = new BufferedReader( - new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - errorBody.append(line); - } - } - return fail("获取挑战杯历史案例知识库失败", errorBody.toString()); + logger.warn("知识库接口调用失败: {} {} -> {} {}", method, url, responseCode, responseBody); + return fail(errorMessage, responseBody); } - - StringBuilder responseBody = new StringBuilder(); - try (BufferedReader br = new BufferedReader( - new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - responseBody.append(line); - } + // 删除类接口成功时返回体可能为空 + if (StrUtil.isBlank(responseBody)) { + return success(Boolean.TRUE); } - - com.alibaba.fastjson.JSONObject result = com.alibaba.fastjson.JSONObject.parseObject(responseBody.toString()); - return success(result); + JSONObject result = JSONObject.parseObject(responseBody); + return success(result == null ? responseBody : result); } catch (Exception e) { - return fail("获取挑战杯历史案例知识库失败: " + e.getMessage()); + logger.error("知识库接口调用异常: {} {}", method, url, e); + return fail(errorMessage + ": " + e.getMessage()); + } finally { + if (connection != null) { + connection.disconnect(); + } } } + + private String readAll(InputStream inputStream) { + if (inputStream == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + } catch (Exception e) { + logger.warn("读取知识库响应失败", e); + } + return sb.toString(); + } } diff --git a/src/main/java/com/gxwebsoft/ai/entity/DeclarationInputs.java b/src/main/java/com/gxwebsoft/ai/entity/DeclarationInputs.java new file mode 100644 index 0000000..31a39e9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/entity/DeclarationInputs.java @@ -0,0 +1,72 @@ +package com.gxwebsoft.ai.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.annotations.ApiModel; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +/** + * 申报书生成入参(对应「申报书生成」工作流)。 + * + *

业务说明:根据项目核心创意、技术路线和团队情况生成申报书初稿; + * 已提供初稿时可结合前沿资料优化完善。 + * + *

字段名对齐 Dify 工作流开始节点的真实变量名,取自 2026-09-19 + * 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出: + * {@code core_idea, tech_route, team_intro, upload_file(文件类), parse_value}。 + * + *

发送时仍会经 {@code dify.workflows.declaration.fields} 映射一次, + * 保留这一层是为了让变量名再次变动时只改 yml、不改代码。 + * + * @author Codex + * @since 2026-09-19 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@ApiModel(value = "申报书生成对象") +public class DeclarationInputs implements Serializable { + + /** + * 项目核心创意。对应 Dify 变量 core_idea + */ + @TableField(exist = false) + private String coreIdea; + + /** + * 技术路线。对应 Dify 变量 tech_route + */ + @TableField(exist = false) + private String techRoute; + + /** + * 团队情况介绍。对应 Dify 变量 team_intro + */ + @TableField(exist = false) + private String teamIntro; + + /** + * 上传的已有初稿或参考资料。对应 Dify 变量 upload_file(文件类)。 + * + *

可为 Dify 文件对象,也可为已上传文件 ID 字符串,原样透传。 + */ + @TableField(exist = false) + private Object uploadFile; + + /** + * 解析取值开关。对应 Dify 变量 parse_value。 + * + *

类型未在探针中确定(探针只给变量名不给类型), + * 故用 Object 承接,传布尔、字符串或数字均可,由 Dify 侧决定语义。 + */ + @TableField(exist = false) + private Object parseValue; + + /** + * 直接指定 inputs,非空时优先于上面的业务字段。 + */ + @TableField(exist = false) + private java.util.Map inputs; + +} diff --git a/src/main/java/com/gxwebsoft/ai/entity/DocumentProcessInputs.java b/src/main/java/com/gxwebsoft/ai/entity/DocumentProcessInputs.java new file mode 100644 index 0000000..06e463f --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/entity/DocumentProcessInputs.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.ai.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.annotations.ApiModel; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +/** + * 智能文档处理系统入参(对应「医科大智能文档处理系统」工作流)。 + * + *

业务说明:对上传的 PDF 或网络文件进行解析、清洗、分段、关键词提取, + * 处理结果导入「GXMU 团章」知识库。因此文件来源有二:上传文件或网络 PDF 地址。 + * + *

字段名对齐 Dify 工作流开始节点的真实变量名,取自 2026-09-19 + * 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出: + * {@code query, pdf_url, pdf_file(文件类)}。 + * + *

发送时仍会经 {@code dify.workflows.document-process.fields} 映射一次, + * 保留这一层是为了让变量名再次变动时只改 yml、不改代码。 + * + * @author Codex + * @since 2026-09-19 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@ApiModel(value = "智能文档处理对象") +public class DocumentProcessInputs implements Serializable { + + /** + * 处理指令或补充说明。对应 Dify 变量 query + */ + @TableField(exist = false) + private String query; + + /** + * 网络 PDF 地址。对应 Dify 变量 pdf_url + */ + @TableField(exist = false) + private String pdfUrl; + + /** + * 上传的 PDF 文件。对应 Dify 变量 pdf_file(文件类)。 + * + *

由 {@code /api/ai/documentProcess/upload} 在服务端上传到 Dify 后填充; + * JSON 直调时也可传入 Dify 文件对象或 upload_file_id。 + */ + @TableField(exist = false) + private Object pdfFile; + + /** + * 直接指定 inputs,非空时优先于上面的业务字段。 + */ + @TableField(exist = false) + private java.util.Map inputs; + +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 3900a10..18cf751 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -37,4 +37,56 @@ config: wxpay: private-key-path: /www/wwwroot/tuanwei/cert/apiclient_key.pem pub-key-path: /www/wwwroot/tuanwei/cert/44ad9c08a0ab46dd8fc555178df581c4.pem - \ No newline at end of file + +# Dify 平台对接(生产走内网实例) +# +# 2026-09-19 由公网 82.156.98.71 切至内网 10.100.158.15。 +# 切换依据:内网实例 8 个应用全部可用且知识库内容更全 +# (竞赛案例 146 篇 / GXMU团章 10 篇,公网侧仅挑战杯 5 篇 / GXMU团章 9 篇)。 +# +# 两个实例的应用 Key 与知识库 ID 完全不通用,不可混用: +# 公网 Key 打到内网、或反之,一律返回 401。 +# 因此下面每一项都必须成对覆盖,漏掉一项就会因公网 Key 失效而报错。 +# +# 内网的 6 个存量应用变量名与公网一致(已由 probe-dify.sh 两轮实测比对), +# 故此处只需覆盖地址与密钥,不必动 workflows 变量映射。 +dify: + base-url: http://10.100.158.15/v1 + apps: + # 1 医科大团委智能知识库问答系统 + chat: app-ZLUUbLkAX3FVzDubY06puxAo + # 2 活动策划大纲生成 + activity-outline: app-4oBWB3HrdNlANdPFG8ZvVTkY + # 3 医科大智能文档处理系统 + document-process: app-ohdIhk0lVfUwoy10xXRebmjD + # 4 AI 内容创作与宣传助手 + manuscript: app-fhhz4fXMkEdVivAEiuWpycn6 + # 5 AI 创意助手 + creative-assistant: app-lAbpxMggNbIrByiDRDfckSSl + # 6 项目书质量初审 + project-quality-review: app-J6h5I6Bo4oeBif2EofBGVrGs + # 7 申报书生成 + declaration: app-k20aDmiQYh3LxqZSLuX8wjkU + # 8 竞赛文件预处理 + competition-history-preprocess: app-UzVLKnGwg69uAUdbgFx6XNmc + dataset: + # 内网两个知识库各自签发独立密钥,注意与上面的应用 Key 不同前缀(dataset-) + api-key: dataset-jaqtnAQvYcQTEjECzxhQfNCv + # 注意:YAML 的 map 是"按键合并"而非整体替换 —— application.yml 里登记的 + # 公网库密钥不会因为这里重写了 keys 就消失,而是与本段合并。 + # 因此这里把公网那两个 ID 也显式指到内网密钥,避免残留一条指向公网实例的死密钥 + # (残留后表现为"某个知识库接口偶发 401",很难查)。 + keys: + # 内网知识库 + 181d58e3-78c2-4b7d-939c-57e780a8f7d6: dataset-jaqtnAQvYcQTEjECzxhQfNCv + 3e0ac108-3a0d-41e2-8447-aa1c1044d3ac: dataset-f2a0Hjxix7lBcPNZtNMyKDTQ + # 公网知识库 ID:生产下同样指到内网密钥,杜绝残留公网密钥 + b342f096-98b1-4a83-a296-f4ab60a6db0e: dataset-jaqtnAQvYcQTEjECzxhQfNCv + aa72c649-71a0-46e7-ac55-47f44b397a4e: dataset-f2a0Hjxix7lBcPNZtNMyKDTQ + ids: + # 内网知识库(生产实际使用) + competition-cases: 181d58e3-78c2-4b7d-939c-57e780a8f7d6 + gxmu-constitution-internal: 3e0ac108-3a0d-41e2-8447-aa1c1044d3ac + # 公网知识库 ID 保留登记,便于切回时对照;生产下不应被业务引用 + challenge-cup: b342f096-98b1-4a83-a296-f4ab60a6db0e + gxmu-constitution: aa72c649-71a0-46e7-ac55-47f44b397a4e diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 57f4689..e3390d9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -103,6 +103,73 @@ wxpay: mch-id: 1732930031 mch-serial-no: 7EE24C695B278DB2C4F0C1CB42069065ECCBE3FD +# Dify 平台对接 +# +# 注意这里有两套环境,Key 与知识库 ID 互不通用(实测:文档给的 Key 打到 82.156.98.71 上全部 401): +# 1) 公网实例 http://82.156.98.71/v1 —— 当前线上在用的实例,下列默认值即取自它 +# 2) 内网实例 http://10.100.158.15/v1 —— 见《Dify API 文档》,已由服务器实测 8 个应用全部 200 +# 切到内网实例时,在 application-prod.yml 覆盖 base-url 与全部 apps/dataset 密钥即可。 +# 不要直接改这里的默认值,否则本地开发会一起被切走。 +dify: + base-url: http://82.156.98.71/v1 + timeout-ms: 600000 + apps: + # 医科大团委智能知识库问答系统 + chat: app-ou0qPesouiopaPpmxcFV2gFG + # 活动策划大纲生成 + activity-outline: app-hp4PJknbejImmdDuMfziVcnN + # AI 内容创作与宣传助手 + manuscript: app-hCzkdd6OsnMnwI2dQByLyNIN + # AI 创意助手 + creative-assistant: app-l3accBEJLDAh9Pn8fLW8nNJ0 + # 项目书质量初审 + project-quality-review: app-HiI2FrNjcwL0A7QOOf7GIP50 + # 竞赛文件预处理 + competition-history-preprocess: app-bhyQLMAw2JEDXMTs2mzNRiIU + # 医科大智能文档处理系统 + # 公网实例上该 Key 无效(实测 401),仅内网实例可用,故允许环境变量覆盖 + document-process: ${DIFY_KEY_DOCUMENT_PROCESS:app-ohdIhk0lVfUwoy10xXRebmjD} + # 申报书生成,同上 + declaration: ${DIFY_KEY_DECLARATION:app-k20aDmiQYh3LxqZSLuX8wjkU} + dataset: + # 知识库列表等不区分知识库的接口所用密钥 + api-key: ${DIFY_DATASET_API_KEY:dataset-1n6MECRtQvEVGOL1o7SrSGfe} + # 知识库 ID -> 独立密钥 + keys: + b342f096-98b1-4a83-a296-f4ab60a6db0e: ${DIFY_DATASET_API_KEY:dataset-1n6MECRtQvEVGOL1o7SrSGfe} + aa72c649-71a0-46e7-ac55-47f44b397a4e: ${DIFY_DATASET_API_KEY:dataset-1n6MECRtQvEVGOL1o7SrSGfe} + # 内网实例的两个知识库各自签发独立密钥 + 181d58e3-78c2-4b7d-939c-57e780a8f7d6: ${DIFY_DATASET_KEY_COMPETITION:dataset-jaqtnAQvYcQTEjECzxhQfNCv} + 3e0ac108-3a0d-41e2-8447-aa1c1044d3ac: ${DIFY_DATASET_KEY_GXMU:dataset-f2a0Hjxix7lBcPNZtNMyKDTQ} + # 业务代号 -> 知识库 ID + ids: + # 公网实例现用:挑战杯历史案例(5 篇) + challenge-cup: b342f096-98b1-4a83-a296-f4ab60a6db0e + gxmu-constitution: aa72c649-71a0-46e7-ac55-47f44b397a4e + # 内网实例:竞赛案例(146 篇)、GXMU 团章(10 篇) + competition-cases: 181d58e3-78c2-4b7d-939c-57e780a8f7d6 + gxmu-constitution-internal: 3e0ac108-3a0d-41e2-8447-aa1c1044d3ac + # 工作流变量名映射。 + # 左边是入参实体的业务字段(中划线),右边是 Dify 工作流开始节点的真实变量名。 + # + # 下两段取自 2026-09-19 在内网实例上跑 probe-dify.sh 的实际输出, + # 注意它们与公网实例的命名习惯并不一致 —— 不要凭直觉推断,必须以探针输出为准。 + workflows: + # 内网实测: query,pdf_url,pdf_file(文件类) + document-process: + fields: + query: query + pdf-url: pdf_url + pdf-file: pdf_file + # 内网实测: core_idea,tech_route,team_intro,upload_file(文件类),parse_value + declaration: + fields: + core-idea: core_idea + tech-route: tech_route + team-intro: team_intro + upload-file: upload_file + parse-value: parse_value + # 医科大开放平台对接 gxmu: openplat: diff --git a/src/test/java/com/gxwebsoft/ai/config/DifyProdProfileBindingTest.java b/src/test/java/com/gxwebsoft/ai/config/DifyProdProfileBindingTest.java new file mode 100644 index 0000000..3bdc384 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/config/DifyProdProfileBindingTest.java @@ -0,0 +1,166 @@ +package com.gxwebsoft.ai.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 校验生产 profile 的 Dify 覆盖是否完整。 + * + *

背景:Dify 内网实例与公网实例的应用 Key、知识库 ID、密钥完全不通用。 + * 切换时若漏覆盖某一项,运行期会表现为该功能单独报 401/参数错误, + * 而 Spring 的松散绑定不会给出任何警告 —— 这类"漏一项"极难在测试环境发现。 + * 因此这里逐项断言 prod 覆盖的完整性。 + * + *

本测试不启动容器,只做 application.yml + application-prod.yml 的绑定, + * 不依赖数据库与 Redis。 + */ +class DifyProdProfileBindingTest { + + /** + * 模拟 Spring 的 profile 加载顺序。 + * + *

PropertySource 是先匹配先生效:列表靠前的优先级更高。 + * 因此必须把 application-prod.yml 放到 application.yml 之前, + * 才能复现"profile 覆盖默认值"的真实行为。 + * + *

第一版误用了 addLast 放 profile,导致默认值反而胜出、测试假失败, + * 排查花了些时间 —— 这里保留说明,避免以后再踩。 + */ + private DifyProperties loadProd() throws Exception { + StandardEnvironment environment = new StandardEnvironment(); + // 默认值先设进 SystemProperties 之后(最低),再由 profile 覆盖 + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + for (PropertySource source : loader.load("application", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(source); + } + // addFirst:profile 优先级高于 application.yml + for (PropertySource source : loader.load("application-prod", + new ClassPathResource("application-prod.yml"))) { + environment.getPropertySources().addFirst(source); + } + return Binder.get(environment).bind("dify", DifyProperties.class).orElse(null); + } + + @Test + void 生产环境指向内网实例() throws Exception { + DifyProperties properties = loadProd(); + assertNotNull(properties); + assertEquals("http://10.100.158.15/v1", properties.getBaseUrl(), + "生产必须指向内网实例"); + } + + @Test + void 生产环境八个应用Key全部覆盖为内网Key() throws Exception { + DifyProperties.AppKeys apps = loadProd().getApps(); + assertNotNull(apps); + + // 逐个断言:任一为 null 或仍是公网 Key 都说明覆盖漏了 + assertEquals("app-ZLUUbLkAX3FVzDubY06puxAo", apps.getChat(), "chat"); + assertEquals("app-4oBWB3HrdNlANdPFG8ZvVTkY", apps.getActivityOutline(), "activity-outline"); + assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.getDocumentProcess(), "document-process"); + assertEquals("app-fhhz4fXMkEdVivAEiuWpycn6", apps.getManuscript(), "manuscript"); + assertEquals("app-lAbpxMggNbIrByiDRDfckSSl", apps.getCreativeAssistant(), "creative-assistant"); + assertEquals("app-J6h5I6Bo4oeBif2EofBGVrGs", apps.getProjectQualityReview(), "project-quality-review"); + assertEquals("app-k20aDmiQYh3LxqZSLuX8wjkU", apps.getDeclaration(), "declaration"); + assertEquals("app-UzVLKnGwg69uAUdbgFx6XNmc", apps.getCompetitionHistoryPreprocess(), + "competition-history-preprocess"); + } + + @Test + void 生产环境不残留任何公网Key() throws Exception { + DifyProperties.AppKeys apps = loadProd().getApps(); + + // 公网实例的 6 个存量 Key,生产环境里一个都不该出现 + List publicKeys = new ArrayList<>(); + publicKeys.add("app-ou0qPesouiopaPpmxcFV2gFG"); + publicKeys.add("app-hp4PJknbejImmdDuMfziVcnN"); + publicKeys.add("app-hCzkdd6OsnMnwI2dQByLyNIN"); + publicKeys.add("app-l3accBEJLDAh9Pn8fLW8nNJ0"); + publicKeys.add("app-HiI2FrNjcwL0A7QOOf7GIP50"); + publicKeys.add("app-bhyQLMAw2JEDXMTs2mzNRiIU"); + + List actual = new ArrayList<>(); + actual.add(apps.getChat()); + actual.add(apps.getActivityOutline()); + actual.add(apps.getManuscript()); + actual.add(apps.getCreativeAssistant()); + actual.add(apps.getProjectQualityReview()); + actual.add(apps.getCompetitionHistoryPreprocess()); + actual.add(apps.getDocumentProcess()); + actual.add(apps.getDeclaration()); + + for (String pub : publicKeys) { + assertTrue(!actual.contains(pub), + "生产环境残留公网 Key: " + pub + ",会导致该功能 401"); + } + } + + @Test + void 生产环境知识库密钥指向内网() throws Exception { + DifyProperties.Dataset dataset = loadProd().getDataset(); + + assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", dataset.getApiKey()); + + // 内网两个库各自的独立密钥 + assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", + dataset.keyOf("181d58e3-78c2-4b7d-939c-57e780a8f7d6")); + assertEquals("dataset-f2a0Hjxix7lBcPNZtNMyKDTQ", + dataset.keyOf("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac")); + + // 公网知识库 ID 仍登记,但生产下必须也指到内网密钥,不能残留公网密钥 + assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", + dataset.keyOf("b342f096-98b1-4a83-a296-f4ab60a6db0e"), + "公网库 ID 在生产残留了公网密钥,属死密钥,应显式覆盖为内网密钥"); + + // 明确断言:生产下任何知识库 ID 都不得解析出公网密钥 + for (String id : dataset.getKeys().keySet()) { + assertTrue(!"dataset-1n6MECRtQvEVGOL1o7SrSGfe".equals(dataset.keyOf(id)), + "生产环境知识库 " + id + " 残留公网密钥 dataset-1n6MECRt..."); + } + } + + @Test + void 生产环境知识库ID登记了内网两个库() throws Exception { + DifyProperties.Dataset dataset = loadProd().getDataset(); + + assertEquals("181d58e3-78c2-4b7d-939c-57e780a8f7d6", dataset.idOf("competition-cases")); + assertEquals("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac", + dataset.idOf("gxmu-constitution-internal")); + } + + @Test + void 生产环境保留工作流变量映射() throws Exception { + // profile 覆盖了 base-url 与密钥,但不应把 workflows 映射冲掉 + DifyProperties.WorkflowVariables workflows = loadProd().getWorkflows(); + assertNotNull(workflows); + + assertEquals("core_idea", workflows.getDeclaration().variableOf("core-idea")); + assertEquals("pdf_file", workflows.getDocumentProcess().variableOf("pdf-file")); + } + + @Test + void 本地默认仍指向公网实例() throws Exception { + // application.yml 默认值不得被改成内网,否则本地开发连不上 + StandardEnvironment environment = new StandardEnvironment(); + for (PropertySource source : new YamlPropertySourceLoader() + .load("application", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(source); + } + DifyProperties properties = Binder.get(environment).bind("dify", DifyProperties.class).orElse(null); + assertNotNull(properties); + assertEquals("http://82.156.98.71/v1", properties.getBaseUrl(), + "application.yml 默认值应保持公网,避免本地开发被切到内网"); + } + +} diff --git a/src/test/java/com/gxwebsoft/ai/config/DifyPropertiesBindingTest.java b/src/test/java/com/gxwebsoft/ai/config/DifyPropertiesBindingTest.java new file mode 100644 index 0000000..4f3fa63 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/config/DifyPropertiesBindingTest.java @@ -0,0 +1,114 @@ +package com.gxwebsoft.ai.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.env.StandardEnvironment; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 校验 {@code application.yml} 中的 dify 配置能正确绑定到 {@link DifyProperties}。 + * + *

纯绑定测试,不启动 Spring 容器,因此不依赖数据库与 Redis, + * 可以在本地直接跑。主要用于防止 yml 里 kebab-case 与 Java 驼峰对不上、 + * 或缩进写错导致配置静默为 null。 + */ +class DifyPropertiesBindingTest { + + private DifyProperties load() throws Exception { + StandardEnvironment environment = new StandardEnvironment(); + for (PropertySource source : new YamlPropertySourceLoader() + .load("application", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(source); + } + return Binder.get(environment).bind("dify", DifyProperties.class).orElse(null); + } + + @Test + void 配置能绑定且关键字段不为空() throws Exception { + DifyProperties properties = load(); + assertNotNull(properties, "dify 配置未绑定,请检查 application.yml 缩进"); + + assertEquals("http://82.156.98.71/v1", properties.getBaseUrl()); + assertEquals(600000, properties.getTimeoutMs()); + + DifyProperties.AppKeys apps = properties.getApps(); + assertNotNull(apps); + assertEquals("app-ou0qPesouiopaPpmxcFV2gFG", apps.getChat()); + assertEquals("app-hp4PJknbejImmdDuMfziVcnN", apps.getActivityOutline()); + assertEquals("app-hCzkdd6OsnMnwI2dQByLyNIN", apps.getManuscript()); + assertEquals("app-l3accBEJLDAh9Pn8fLW8nNJ0", apps.getCreativeAssistant()); + assertEquals("app-HiI2FrNjcwL0A7QOOf7GIP50", apps.getProjectQualityReview()); + assertEquals("app-bhyQLMAw2JEDXMTs2mzNRiIU", apps.getCompetitionHistoryPreprocess()); + // 新增的两个应用在 yml 里用了环境变量占位,需确认占位符被解析 + assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.getDocumentProcess()); + assertEquals("app-k20aDmiQYh3LxqZSLuX8wjkU", apps.getDeclaration()); + } + + @Test + void 按代号取密钥可用() throws Exception { + DifyProperties.AppKeys apps = load().getApps(); + + assertEquals("app-ou0qPesouiopaPpmxcFV2gFG", apps.byCode("chat")); + assertEquals("app-k20aDmiQYh3LxqZSLuX8wjkU", apps.byCode("declaration")); + assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.byCode("documentProcess")); + // 未登记的代号返回 null,而不是静默回退到某个应用 + assertNull(apps.byCode("not-exist")); + assertNull(apps.byCode(null)); + } + + @Test + void 知识库配置绑定正确() throws Exception { + DifyProperties.Dataset dataset = load().getDataset(); + + assertEquals("dataset-1n6MECRtQvEVGOL1o7SrSGfe", dataset.getApiKey()); + assertEquals("b342f096-98b1-4a83-a296-f4ab60a6db0e", dataset.idOf("challenge-cup")); + assertEquals("aa72c649-71a0-46e7-ac55-47f44b397a4e", dataset.idOf("gxmu-constitution")); + // 已登记的库返回自己的密钥 + assertEquals("dataset-1n6MECRtQvEVGOL1o7SrSGfe", + dataset.keyOf("b342f096-98b1-4a83-a296-f4ab60a6db0e")); + // 未登记的库回退到默认密钥 + assertEquals("dataset-1n6MECRtQvEVGOL1o7SrSGfe", dataset.keyOf("unknown-id")); + } + + @Test + void 工作流变量映射绑定正确() throws Exception { + DifyProperties.WorkflowVariables workflows = load().getWorkflows(); + assertNotNull(workflows); + + DifyProperties.VariableMapping declaration = workflows.getDeclaration(); + assertEquals("core_idea", declaration.variableOf("core-idea")); + assertEquals("tech_route", declaration.variableOf("tech-route")); + assertEquals("team_intro", declaration.variableOf("team-intro")); + assertEquals("upload_file", declaration.variableOf("upload-file")); + assertEquals("parse_value", declaration.variableOf("parse-value")); + // 未登记的字段按原名透传 + assertEquals("unmapped", declaration.variableOf("unmapped")); + + DifyProperties.VariableMapping documentProcess = workflows.getDocumentProcess(); + assertEquals("query", documentProcess.variableOf("query")); + assertEquals("pdf_url", documentProcess.variableOf("pdf-url")); + assertEquals("pdf_file", documentProcess.variableOf("pdf-file")); + } + + @Test + void 知识库登记了公网与内网两套() throws Exception { + DifyProperties.Dataset dataset = load().getDataset(); + List ids = new java.util.ArrayList<>(dataset.getIds().keySet()); + assertEquals(4, ids.size(), "dify.dataset.ids 应登记 4 个知识库,实际: " + ids); + + // 内网实例的两个库各自有独立密钥,不能都回退到默认密钥 + assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", + dataset.keyOf("181d58e3-78c2-4b7d-939c-57e780a8f7d6")); + assertEquals("dataset-f2a0Hjxix7lBcPNZtNMyKDTQ", + dataset.keyOf("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac")); + } + +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiInputMappingTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiInputMappingTest.java new file mode 100644 index 0000000..8a0935d --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiInputMappingTest.java @@ -0,0 +1,197 @@ +package com.gxwebsoft.ai.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.ai.config.DifyProperties; +import com.gxwebsoft.ai.entity.DeclarationInputs; +import com.gxwebsoft.ai.entity.DocumentProcessInputs; +import com.gxwebsoft.common.core.utils.JSONUtil; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; + +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.assertTrue; + +/** + * 校验「申报书生成」「智能文档处理」的入参到 Dify inputs 的映射。 + * + *

变量名以 2026-09-19 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出为准: + *

+ *   document-process : query, pdf_url, pdf_file(文件类)
+ *   declaration      : core_idea, tech_route, team_intro, upload_file(文件类), parse_value
+ * 
+ * 这两组名字与公网实例的命名习惯并不一致,因此这里既固定映射行为, + * 也直接从 application.yml 读配置来断言,防止后人"顺手改成看起来更合理"的名字。 + */ +class AiInputMappingTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 复刻 AiController#objectToMap 的实现(该方法为 private,此处等价重写) + */ + private Map objectToMap(Object source) throws Exception { + String json = objectMapper.writeValueAsString(source); + Map map = JSONUtil.parseObject(json, Map.class); + return map == null ? new LinkedHashMap<>() : map; + } + + /** + * 复刻 AiController#remapInputs 的实现 + */ + private Map remap(Map raw, DifyProperties.VariableMapping mapping) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + if (value instanceof String && ((String) value).trim().isEmpty()) { + continue; + } + String variable = mapping.variableOf(entry.getKey()); + if (variable == null || variable.trim().isEmpty()) { + continue; + } + result.put(variable, value); + } + return result; + } + + /** + * 从 application.yml 读取真实的映射配置 + */ + private DifyProperties loadProperties() throws Exception { + StandardEnvironment environment = new StandardEnvironment(); + for (PropertySource source : new YamlPropertySourceLoader() + .load("application", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(source); + } + return Binder.get(environment).bind("dify", DifyProperties.class).orElse(null); + } + + // ------------------------------------------------------------ 申报书生成 + + @Test + void 申报书生成_映射为内网实例的真实变量名() throws Exception { + DifyProperties properties = loadProperties(); + assertNotNull(properties); + DifyProperties.VariableMapping mapping = properties.getWorkflows().getDeclaration(); + + DeclarationInputs inputs = new DeclarationInputs(); + inputs.setCoreIdea("做一个校园二手书平台"); + inputs.setTechRoute("Spring Boot + 小程序"); + inputs.setTeamIntro("3 人,均为计算机专业"); + inputs.setParseValue("true"); + + Map result = remap(objectToMap(inputs), mapping); + + assertEquals("做一个校园二手书平台", result.get("core_idea")); + assertEquals("Spring Boot + 小程序", result.get("tech_route")); + assertEquals("3 人,均为计算机专业", result.get("team_intro")); + assertEquals("true", result.get("parse_value")); + // 不能残留业务字段名 + assertFalse(result.containsKey("coreIdea")); + assertFalse(result.containsKey("teamIntro")); + assertFalse(result.containsKey("parseValue")); + } + + @Test + void 申报书生成_上传文件映射为upload_file() throws Exception { + DifyProperties.VariableMapping mapping = loadProperties().getWorkflows().getDeclaration(); + + DeclarationInputs inputs = new DeclarationInputs(); + inputs.setCoreIdea("创意"); + Map fileValue = new LinkedHashMap<>(); + fileValue.put("transfer_method", "local_file"); + fileValue.put("upload_file_id", "abc-123"); + inputs.setUploadFile(fileValue); + + Map result = remap(objectToMap(inputs), mapping); + + assertEquals(fileValue, result.get("upload_file")); + assertFalse(result.containsKey("uploadFile")); + } + + // -------------------------------------------------------- 智能文档处理 + + @Test + void 文档处理_映射为内网实例的真实变量名() throws Exception { + DifyProperties.VariableMapping mapping = loadProperties().getWorkflows().getDocumentProcess(); + + DocumentProcessInputs inputs = new DocumentProcessInputs(); + inputs.setQuery("请解析并入库"); + inputs.setPdfUrl("http://example.com/a.pdf"); + Map fileValue = new LinkedHashMap<>(); + fileValue.put("transfer_method", "local_file"); + fileValue.put("upload_file_id", "pdf-1"); + inputs.setPdfFile(fileValue); + + Map result = remap(objectToMap(inputs), mapping); + + assertEquals("请解析并入库", result.get("query")); + assertEquals("http://example.com/a.pdf", result.get("pdf_url")); + assertEquals(fileValue, result.get("pdf_file")); + assertFalse(result.containsKey("pdfUrl")); + assertFalse(result.containsKey("pdfFile")); + } + + // ---------------------------------------------------------------- 通用行为 + + @Test + void 空字段不下发() throws Exception { + DifyProperties.VariableMapping mapping = loadProperties().getWorkflows().getDeclaration(); + + DeclarationInputs inputs = new DeclarationInputs(); + inputs.setCoreIdea("创意"); + inputs.setTechRoute(" "); // 纯空白应被丢弃 + inputs.setTeamIntro(null); + + Map result = remap(objectToMap(inputs), mapping); + + assertEquals(1, result.size(), "只应保留 core_idea,实际: " + result.keySet()); + assertTrue(result.containsKey("core_idea")); + } + + @Test + void 未登记字段按原名透传() throws Exception { + DifyProperties.VariableMapping mapping = loadProperties().getWorkflows().getDeclaration(); + + DeclarationInputs inputs = new DeclarationInputs(); + inputs.setCoreIdea("创意"); + + Map raw = objectToMap(inputs); + raw.put("extraField", "额外字段"); + + Map result = remap(raw, mapping); + + assertEquals("额外字段", result.get("extraField")); + } + + @Test + void 映射配置覆盖了全部真实变量() throws Exception { + DifyProperties properties = loadProperties(); + + // 内网探针实测出的变量全集,少一个就说明 yml 里漏配 + Map declaration = properties.getWorkflows().getDeclaration().getFields(); + assertTrue(declaration.containsValue("core_idea"), "缺少 core_idea"); + assertTrue(declaration.containsValue("tech_route"), "缺少 tech_route"); + assertTrue(declaration.containsValue("team_intro"), "缺少 team_intro"); + assertTrue(declaration.containsValue("upload_file"), "缺少 upload_file"); + assertTrue(declaration.containsValue("parse_value"), "缺少 parse_value"); + + Map document = properties.getWorkflows().getDocumentProcess().getFields(); + assertTrue(document.containsValue("query"), "缺少 query"); + assertTrue(document.containsValue("pdf_url"), "缺少 pdf_url"); + assertTrue(document.containsValue("pdf_file"), "缺少 pdf_file"); + } + +}