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