Dify 配置外置并切至内网实例
接口地址与各应用 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 的映射。
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft;
|
package com.gxwebsoft;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.config.DifyProperties;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.gxmu.auth.GxmuAuthProperties;
|
import com.gxwebsoft.gxmu.auth.GxmuAuthProperties;
|
||||||
import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties;
|
import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties;
|
||||||
@@ -21,7 +22,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@MapperScan("com.gxwebsoft.**.mapper")
|
@MapperScan("com.gxwebsoft.**.mapper")
|
||||||
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class,
|
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class,
|
||||||
GxmuAuthProperties.class})
|
GxmuAuthProperties.class, DifyProperties.class})
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
|
|||||||
@@ -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 平台对接配置。
|
||||||
|
*
|
||||||
|
* <p>原先接口地址与各应用的 API Key 硬编码在 {@code AiController}、{@code KnowledgeBaseController}
|
||||||
|
* 中,换环境要改代码、Key 还会进版本库。此处统一外置到 {@code application.yml} 的
|
||||||
|
* {@code dify} 节点,各环境可在 {@code application-{profile}.yml} 中按需覆盖。
|
||||||
|
*
|
||||||
|
* <p>关于两套环境:文档《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 为应用代号。
|
||||||
|
*
|
||||||
|
* <p>代号与 {@code AiController} 中的取用一一对应,见 {@link AppKeys}。
|
||||||
|
*/
|
||||||
|
private AppKeys apps = new AppKeys();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库(dataset)接口配置,走独立的 dataset- 前缀密钥
|
||||||
|
*/
|
||||||
|
private Dataset dataset = new Dataset();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流的变量名映射。
|
||||||
|
*
|
||||||
|
* <p>仅用于「文档处理系统」「申报书生成」这两个当前实例上无法验证的工作流:
|
||||||
|
* 它们的工作流开始节点变量名未知,若在代码里写死,拿到真实变量名后就必须改代码。
|
||||||
|
* 这里改成「业务字段名 -> Dify 变量名」的映射,配置里调整即可。
|
||||||
|
*/
|
||||||
|
private WorkflowVariables workflows = new WorkflowVariables();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class WorkflowVariables {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医科大智能文档处理系统的变量映射
|
||||||
|
*/
|
||||||
|
private VariableMapping documentProcess = new VariableMapping();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申报书生成的变量映射
|
||||||
|
*/
|
||||||
|
private VariableMapping declaration = new VariableMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务字段名 -> Dify 工作流变量名的映射。
|
||||||
|
*
|
||||||
|
* <p>未登记的字段按同名透传;映射到的变量名为空串表示丢弃该字段。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class VariableMapping {
|
||||||
|
|
||||||
|
private Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取字段对应的 Dify 变量名,未配置时返回原名。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 取字段对应的 Dify 变量名,未配置时返回原名。
|
||||||
|
*
|
||||||
|
* <p>入参是 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。
|
||||||
|
*
|
||||||
|
* <p>供停止任务时按记录的代号回查密钥使用。
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库接口默认认证密钥。
|
||||||
|
*
|
||||||
|
* <p>用于知识库列表等不区分知识库的接口。
|
||||||
|
*/
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库 ID -> 认证密钥。同一实例下不同知识库可能签发不同密钥。
|
||||||
|
*/
|
||||||
|
private Map<String, String> keys = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库代号 -> 知识库 ID,便于按业务名引用。
|
||||||
|
*/
|
||||||
|
private Map<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ import cn.hutool.core.util.StrUtil;
|
|||||||
import cn.hutool.http.HttpRequest;
|
import cn.hutool.http.HttpRequest;
|
||||||
import cn.hutool.http.HttpResponse;
|
import cn.hutool.http.HttpResponse;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
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.ActivityOutlineInputs;
|
||||||
import com.gxwebsoft.ai.entity.AiChatHistory;
|
import com.gxwebsoft.ai.entity.AiChatHistory;
|
||||||
import com.gxwebsoft.ai.entity.AiChatList;
|
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.entity.ManuscriptGenInputs;
|
||||||
import com.gxwebsoft.ai.service.AiChatHistoryService;
|
import com.gxwebsoft.ai.service.AiChatHistoryService;
|
||||||
import com.gxwebsoft.ai.service.AiChatListService;
|
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.config.WebSocketServer;
|
||||||
import com.gxwebsoft.ai.entity.ChatMessage;
|
import com.gxwebsoft.ai.entity.ChatMessage;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -54,28 +59,53 @@ public class AiController extends BaseController {
|
|||||||
private AiChatHistoryService aiChatHistoryService;
|
private AiChatHistoryService aiChatHistoryService;
|
||||||
@Resource
|
@Resource
|
||||||
private AiChatListService aiChatListService;
|
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.*}。
|
||||||
|
*
|
||||||
|
* <p>原先这些 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<String, ActiveAiTask> activeAiTaskMap = new ConcurrentHashMap<>();
|
private final Map<String, ActiveAiTask> activeAiTaskMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@PostMapping("/chat/message")
|
@PostMapping("/chat/message")
|
||||||
public ApiResult<?> message(@RequestBody ChatMessage message) throws IOException {
|
public ApiResult<?> message(@RequestBody ChatMessage message) throws IOException {
|
||||||
try {
|
try {
|
||||||
URL url = new URL(aiUrl + "/chat-messages");
|
URL url = new URL(aiUrl() + "/chat-messages");
|
||||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + key);
|
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getChat());
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
connection.setRequestProperty("Accept", "text/event-stream");
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(600000);
|
connection.setConnectTimeout(aiTimeout());
|
||||||
connection.setReadTimeout(600000);
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Integer chatId = message.getChatId();
|
Integer chatId = message.getChatId();
|
||||||
if (chatId == null || chatId == 0) {
|
if (chatId == null || chatId == 0) {
|
||||||
@@ -137,7 +167,7 @@ public class AiController extends BaseController {
|
|||||||
String event = eventObj == null ? "" : eventObj.toString();
|
String event = eventObj == null ? "" : eventObj.toString();
|
||||||
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
|
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
|
||||||
taskId = 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
|
String currentConversationId = data.get("conversation_id") == null
|
||||||
? conversationId
|
? conversationId
|
||||||
@@ -239,15 +269,15 @@ public class AiController extends BaseController {
|
|||||||
@PostMapping("/manuscriptGen")
|
@PostMapping("/manuscriptGen")
|
||||||
public ApiResult<?> manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException {
|
public ApiResult<?> manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException {
|
||||||
try {
|
try {
|
||||||
URL url = new URL(aiUrl + "/workflows/run");
|
URL url = new URL(aiUrl() + "/workflows/run");
|
||||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + manuscriptKey);
|
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getManuscript());
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
connection.setRequestProperty("Accept", "text/event-stream");
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(600000);
|
connection.setConnectTimeout(aiTimeout());
|
||||||
connection.setReadTimeout(600000);
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Map<String, Object> inputs = buildManuscriptInputs(message);
|
Map<String, Object> inputs = buildManuscriptInputs(message);
|
||||||
Object files = normalizeFiles(message.getFiles());
|
Object files = normalizeFiles(message.getFiles());
|
||||||
@@ -302,7 +332,7 @@ public class AiController extends BaseController {
|
|||||||
taskId = stringValue(data.get("id"));
|
taskId = stringValue(data.get("id"));
|
||||||
}
|
}
|
||||||
if (StrUtil.isNotBlank(taskId)) {
|
if (StrUtil.isNotBlank(taskId)) {
|
||||||
recordActiveAiTask(getLoginUserId(), "workflow", taskId, manuscriptKey);
|
recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript());
|
||||||
}
|
}
|
||||||
System.out.println(data);
|
System.out.println(data);
|
||||||
String answer = extractAnswer(data);
|
String answer = extractAnswer(data);
|
||||||
@@ -427,7 +457,7 @@ public class AiController extends BaseController {
|
|||||||
List<Object> uploadedFiles = new ArrayList<>();
|
List<Object> uploadedFiles = new ArrayList<>();
|
||||||
for (MultipartFile file : files) {
|
for (MultipartFile file : files) {
|
||||||
if (file != null && !file.isEmpty()) {
|
if (file != null && !file.isEmpty()) {
|
||||||
uploadedFiles.add(uploadMultipartToAi(file, competitionHistoryPreprocessKey, loginUserId));
|
uploadedFiles.add(uploadMultipartToAi(file, appKeys().getCompetitionHistoryPreprocess(), loginUserId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uploadedFiles.isEmpty()) {
|
if (uploadedFiles.isEmpty()) {
|
||||||
@@ -435,12 +465,12 @@ public class AiController extends BaseController {
|
|||||||
} else {
|
} else {
|
||||||
inputs.put("file_upload", uploadedFiles);
|
inputs.put("file_upload", uploadedFiles);
|
||||||
if (scoreFile != null && !scoreFile.isEmpty()) {
|
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)) {
|
if (StrUtil.isNotBlank(manualText)) {
|
||||||
inputs.put("manual_text", manualText.trim());
|
inputs.put("manual_text", manualText.trim());
|
||||||
}
|
}
|
||||||
result = executeWorkflowForResult(inputs, competitionHistoryPreprocessKey,
|
result = executeWorkflowForResult(inputs, appKeys().getCompetitionHistoryPreprocess(),
|
||||||
"competitionHistoryPreprocess", loginUserId, true);
|
"competitionHistoryPreprocess", loginUserId, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,11 +495,11 @@ public class AiController extends BaseController {
|
|||||||
return fail("请上传待审项目申报书");
|
return fail("请上传待审项目申报书");
|
||||||
}
|
}
|
||||||
Map<String, Object> inputs = new LinkedHashMap<>();
|
Map<String, Object> 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()) {
|
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);
|
"projectQualityReview", loginUserId, true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("project quality review failed", 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<String, Object> 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<String, Object> 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<ApiResult<?>> 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<ApiResult<?>> 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<String, Object> 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")
|
@PostMapping("/activityOutlineGen")
|
||||||
public ApiResult<?> activityOutlineGen(@RequestBody ActivityOutlineInputs message) {
|
public ApiResult<?> activityOutlineGen(@RequestBody ActivityOutlineInputs message) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
@@ -498,15 +626,15 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
URL url = new URL(aiUrl + "/chat-messages");
|
URL url = new URL(aiUrl() + "/chat-messages");
|
||||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + activityOutlineKey);
|
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getActivityOutline());
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
connection.setRequestProperty("Accept", "text/event-stream");
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(600000);
|
connection.setConnectTimeout(aiTimeout());
|
||||||
connection.setReadTimeout(600000);
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲");
|
payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲");
|
||||||
@@ -642,15 +770,15 @@ public class AiController extends BaseController {
|
|||||||
|
|
||||||
HttpURLConnection connection = null;
|
HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
URL url = new URL(aiUrl + "/workflows/run");
|
URL url = new URL(aiUrl() + "/workflows/run");
|
||||||
connection = (HttpURLConnection) url.openConnection();
|
connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + creativeAssistantKey);
|
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getCreativeAssistant());
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
connection.setRequestProperty("Accept", "text/event-stream");
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(600000);
|
connection.setConnectTimeout(aiTimeout());
|
||||||
connection.setReadTimeout(600000);
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("inputs", buildCreativeAssistantInputs(requestBody));
|
payload.put("inputs", buildCreativeAssistantInputs(requestBody));
|
||||||
@@ -706,7 +834,7 @@ public class AiController extends BaseController {
|
|||||||
taskId = stringValue(data.get("id"));
|
taskId = stringValue(data.get("id"));
|
||||||
}
|
}
|
||||||
if (StrUtil.isNotBlank(taskId)) {
|
if (StrUtil.isNotBlank(taskId)) {
|
||||||
recordActiveAiTask(loginUserId, "workflow", taskId, creativeAssistantKey);
|
recordActiveAiTask(loginUserId, "workflow", taskId, appKeys().getCreativeAssistant());
|
||||||
}
|
}
|
||||||
|
|
||||||
String event = stringValue(data.get("event"));
|
String event = stringValue(data.get("event"));
|
||||||
@@ -811,7 +939,9 @@ public class AiController extends BaseController {
|
|||||||
inputs.put("highlights", message.getHighlights());
|
inputs.put("highlights", message.getHighlights());
|
||||||
}
|
}
|
||||||
if (message.getNumber() != null) {
|
if (message.getNumber() != null) {
|
||||||
inputs.put("number", message.getNumber());
|
// Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。
|
||||||
|
// 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。
|
||||||
|
inputs.put("Number", message.getNumber());
|
||||||
}
|
}
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
@@ -845,6 +975,69 @@ public class AiController extends BaseController {
|
|||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把入参实体的业务字段转成 Dify 工作流的 inputs。
|
||||||
|
*
|
||||||
|
* <p>调用方显式传了 {@code inputs} 时以它为准(可覆盖映射配置);
|
||||||
|
* 否则按 {@code mapping} 把非空业务字段改名后透传,未登记的字段按原名发送。
|
||||||
|
* 这样即使内网工作流的变量名与业务字段不一致,改 yml 即可对齐,无需改代码。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> buildMappedInputs(Object source,
|
||||||
|
Map<String, Object> explicitInputs,
|
||||||
|
DifyProperties.VariableMapping mapping) {
|
||||||
|
if (explicitInputs != null && !explicitInputs.isEmpty()) {
|
||||||
|
return new LinkedHashMap<>(explicitInputs);
|
||||||
|
}
|
||||||
|
Map<String, Object> raw = objectToMap(source);
|
||||||
|
return remapInputs(raw, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按映射表改写 inputs 的键名。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> remapInputs(Map<String, Object> raw, DifyProperties.VariableMapping mapping) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
if (raw == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Object> 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 字段。
|
||||||
|
*
|
||||||
|
* <p>不用 JSONUtil:它对实体走的是 fastjson,字段命名策略与 Jackson 注解
|
||||||
|
* ({@code @JsonProperty}/{@code @JsonAlias})不一致。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> objectToMap(Object source) {
|
||||||
|
if (source == null) {
|
||||||
|
return new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(source);
|
||||||
|
Map<String, Object> 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<String, Object> buildCreativeAssistantInputs(Map<String, Object> requestBody) {
|
private Map<String, Object> buildCreativeAssistantInputs(Map<String, Object> requestBody) {
|
||||||
Map<String, Object> inputs = new LinkedHashMap<>();
|
Map<String, Object> inputs = new LinkedHashMap<>();
|
||||||
if (requestBody == null) {
|
if (requestBody == null) {
|
||||||
@@ -872,15 +1065,15 @@ public class AiController extends BaseController {
|
|||||||
boolean streamToSocket) throws IOException {
|
boolean streamToSocket) throws IOException {
|
||||||
HttpURLConnection connection = null;
|
HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
URL url = new URL(aiUrl + "/workflows/run");
|
URL url = new URL(aiUrl() + "/workflows/run");
|
||||||
connection = (HttpURLConnection) url.openConnection();
|
connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
|
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
connection.setRequestProperty("Accept", "text/event-stream");
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(600000);
|
connection.setConnectTimeout(aiTimeout());
|
||||||
connection.setReadTimeout(600000);
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs);
|
payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs);
|
||||||
@@ -1046,11 +1239,11 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
tempFile = File.createTempFile("ai-upload-", suffix);
|
tempFile = File.createTempFile("ai-upload-", suffix);
|
||||||
file.transferTo(tempFile);
|
file.transferTo(tempFile);
|
||||||
HttpResponse response = HttpRequest.post(aiUrl + "/files/upload")
|
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
|
||||||
.header("Authorization", "Bearer " + apiKey)
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
.form("user", loginUserId == null ? "0" : loginUserId.toString())
|
.form("user", loginUserId == null ? "0" : loginUserId.toString())
|
||||||
.form("file", tempFile, originalFilename)
|
.form("file", tempFile, originalFilename)
|
||||||
.timeout(600000)
|
.timeout(aiTimeout())
|
||||||
.execute();
|
.execute();
|
||||||
String responseBody = response.body();
|
String responseBody = response.body();
|
||||||
Map<String, Object> result = JSONUtil.parseObject(responseBody, Map.class);
|
Map<String, Object> result = JSONUtil.parseObject(responseBody, Map.class);
|
||||||
@@ -1319,18 +1512,24 @@ public class AiController extends BaseController {
|
|||||||
return activeTask.getApiKey();
|
return activeTask.getApiKey();
|
||||||
}
|
}
|
||||||
if ("image".equals(mode)) {
|
if ("image".equals(mode)) {
|
||||||
return creativeAssistantKey;
|
return appKeys().getCreativeAssistant();
|
||||||
}
|
}
|
||||||
if ("writing".equals(mode)) {
|
if ("writing".equals(mode)) {
|
||||||
return manuscriptKey;
|
return appKeys().getManuscript();
|
||||||
}
|
}
|
||||||
if ("projectQualityReview".equals(mode)) {
|
if ("projectQualityReview".equals(mode)) {
|
||||||
return projectQualityReviewKey;
|
return appKeys().getProjectQualityReview();
|
||||||
}
|
}
|
||||||
if ("competitionHistoryPreprocess".equals(mode)) {
|
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) {
|
private String normalizeStopMode(String mode) {
|
||||||
@@ -1343,7 +1542,6 @@ public class AiController extends BaseController {
|
|||||||
|| "message".equals(mode)
|
|| "message".equals(mode)
|
||||||
|| "activityOutline".equals(mode);
|
|| "activityOutline".equals(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildActiveTaskKey(Integer userId, String mode) {
|
private String buildActiveTaskKey(Integer userId, String mode) {
|
||||||
return userId + ":" + mode;
|
return userId + ":" + mode;
|
||||||
}
|
}
|
||||||
@@ -1387,11 +1585,11 @@ public class AiController extends BaseController {
|
|||||||
Map<String, Object> postData = new HashMap<>();
|
Map<String, Object> postData = new HashMap<>();
|
||||||
postData.put("user", loginUserId == null ? "0" : loginUserId.toString());
|
postData.put("user", loginUserId == null ? "0" : loginUserId.toString());
|
||||||
logger.info("AI stop payload: mode={}, taskId={}, path={}", mode, taskId, stopPath);
|
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("Authorization", "Bearer " + apiKey)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(JSONObject.toJSONString(postData))
|
.body(JSONObject.toJSONString(postData))
|
||||||
.timeout(600000)
|
.timeout(aiTimeout())
|
||||||
.execute();
|
.execute();
|
||||||
String responseBody = response.body();
|
String responseBody = response.body();
|
||||||
if (response.getStatus() < 200 || response.getStatus() >= 300) {
|
if (response.getStatus() < 200 || response.getStatus() >= 300) {
|
||||||
@@ -1411,9 +1609,9 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
File tempFile = null;
|
File tempFile = null;
|
||||||
try {
|
try {
|
||||||
String token = manuscriptKey;
|
String token = appKeys().getManuscript();
|
||||||
if (type == null || type == 0) {
|
if (type == null || type == 0) {
|
||||||
token = key;
|
token = appKeys().getChat();
|
||||||
}
|
}
|
||||||
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin");
|
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin");
|
||||||
String suffix = "";
|
String suffix = "";
|
||||||
@@ -1424,11 +1622,11 @@ public class AiController extends BaseController {
|
|||||||
tempFile = File.createTempFile("ai-upload-", suffix);
|
tempFile = File.createTempFile("ai-upload-", suffix);
|
||||||
file.transferTo(tempFile);
|
file.transferTo(tempFile);
|
||||||
|
|
||||||
HttpResponse response = HttpRequest.post(aiUrl + "/files/upload")
|
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
|
||||||
.header("Authorization", "Bearer " + token)
|
.header("Authorization", "Bearer " + token)
|
||||||
.form("user", StrUtil.blankToDefault(user, getLoginUserId().toString()))
|
.form("user", StrUtil.blankToDefault(user, getLoginUserId().toString()))
|
||||||
.form("file", tempFile, originalFilename)
|
.form("file", tempFile, originalFilename)
|
||||||
.timeout(600000)
|
.timeout(aiTimeout())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
int responseCode = response.getStatus();
|
int responseCode = response.getStatus();
|
||||||
|
|||||||
@@ -1,28 +1,66 @@
|
|||||||
package com.gxwebsoft.ai.controller;
|
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.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
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.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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库(Dify dataset)接口代理。
|
||||||
|
*
|
||||||
|
* <p>对应《Dify API 文档》第 5 节。接口地址与密钥全部取自 {@code dify.*} 配置,
|
||||||
|
* 不再硬编码;知识库 ID 按业务代号在配置里登记,见 {@code dify.dataset.ids}。
|
||||||
|
*
|
||||||
|
* <p>注意:文件必须先经「医科大智能文档处理系统」或「竞赛文件预处理」工作流处理后才可入库,
|
||||||
|
* 本控制器只做 Dify 原生接口的透传,不做入库前的格式处理。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-09-19
|
||||||
|
*/
|
||||||
@Api(tags = "知识库")
|
@Api(tags = "知识库")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/ai/knowledge-base")
|
@RequestMapping("/api/ai/knowledge-base")
|
||||||
public class KnowledgeBaseController extends BaseController {
|
public class KnowledgeBaseController extends BaseController {
|
||||||
|
|
||||||
private static final String DATASET_API_URL = "http://82.156.98.71/v1/datasets";
|
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseController.class);
|
||||||
private static final String CHALLENGE_CUP_DATASET_ID = "b342f096-98b1-4a83-a296-f4ab60a6db0e";
|
|
||||||
private static final String DATASET_API_KEY = "dataset-1n6MECRtQvEVGOL1o7SrSGfe";
|
/**
|
||||||
|
* 文档启停归档的动作白名单,防止路径参数被拼到 URL 上
|
||||||
|
*/
|
||||||
|
private static final java.util.Set<String> DOC_STATUS_ACTIONS =
|
||||||
|
new java.util.HashSet<>(java.util.Arrays.asList("enable", "disable", "archive", "unarchive"));
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DifyProperties difyProperties;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 知识库
|
||||||
|
|
||||||
@ApiOperation("知识库列表")
|
@ApiOperation("知识库列表")
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
@@ -30,50 +68,101 @@ public class KnowledgeBaseController extends BaseController {
|
|||||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||||
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
|
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
|
||||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||||
try {
|
StringBuilder url = new StringBuilder(datasetsUrl());
|
||||||
StringBuilder urlBuilder = new StringBuilder(DATASET_API_URL);
|
url.append("?page=").append(safePage(page));
|
||||||
urlBuilder.append("?page=").append(page);
|
url.append("&limit=").append(safeLimit(limit));
|
||||||
urlBuilder.append("&limit=").append(limit);
|
if (StrUtil.isNotBlank(keyword)) {
|
||||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
url.append("&keyword=").append(urlEncode(keyword.trim()));
|
||||||
urlBuilder.append("&keyword=").append(java.net.URLEncoder.encode(keyword.trim(), "UTF-8"));
|
}
|
||||||
|
return proxy("GET", url.toString(), null, null, "获取知识库列表失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
URL url = new URL(urlBuilder.toString());
|
@ApiOperation("获取知识库详情")
|
||||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
@GetMapping("/{datasetId}")
|
||||||
connection.setRequestMethod("GET");
|
public ApiResult<?> detail(@PathVariable("datasetId") String datasetId) {
|
||||||
connection.setRequestProperty("Authorization", "Bearer " + DATASET_API_KEY);
|
return proxy("GET", datasetsUrl() + "/" + datasetId, null, datasetId, "获取知识库详情失败");
|
||||||
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();
|
@ApiOperation("修改知识库")
|
||||||
try (BufferedReader br = new BufferedReader(
|
@PatchMapping("/{datasetId}")
|
||||||
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
|
public ApiResult<?> update(@PathVariable("datasetId") String datasetId,
|
||||||
String line;
|
@RequestBody(required = false) Map<String, Object> body) {
|
||||||
while ((line = br.readLine()) != null) {
|
return proxy("PATCH", datasetsUrl() + "/" + datasetId, body, datasetId, "修改知识库失败");
|
||||||
responseBody.append(line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
com.alibaba.fastjson.JSONObject result = com.alibaba.fastjson.JSONObject.parseObject(responseBody.toString());
|
@ApiOperation("删除知识库")
|
||||||
return success(result);
|
@DeleteMapping("/{datasetId}")
|
||||||
} catch (Exception e) {
|
public ApiResult<?> delete(@PathVariable("datasetId") String datasetId) {
|
||||||
return fail("获取知识库列表失败: " + e.getMessage());
|
return proxy("DELETE", datasetsUrl() + "/" + datasetId, null, datasetId, "删除知识库失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation("检索知识库")
|
||||||
|
@PostMapping("/{datasetId}/retrieve")
|
||||||
|
public ApiResult<?> retrieve(@PathVariable("datasetId") String datasetId,
|
||||||
|
@RequestBody(required = false) Map<String, Object> body) {
|
||||||
|
Map<String, Object> 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<String, Object> 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("挑战杯历史案例知识库文档列表")
|
@ApiOperation("挑战杯历史案例知识库文档列表")
|
||||||
@GetMapping("/challenge-cup/documents")
|
@GetMapping("/challenge-cup/documents")
|
||||||
@@ -81,51 +170,120 @@ public class KnowledgeBaseController extends BaseController {
|
|||||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||||
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
|
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
|
||||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||||
try {
|
String datasetId = difyProperties.getDataset().idOf("challenge-cup");
|
||||||
int safePage = page == null || page < 1 ? 1 : page;
|
if (StrUtil.isBlank(datasetId)) {
|
||||||
int safeLimit = limit == null || limit < 1 ? 20 : Math.min(limit, 100);
|
return fail("未配置 challenge-cup 知识库 ID,请检查 dify.dataset.ids.challenge-cup");
|
||||||
StringBuilder urlBuilder = new StringBuilder(DATASET_API_URL);
|
}
|
||||||
urlBuilder.append("/").append(CHALLENGE_CUP_DATASET_ID).append("/documents");
|
return documents(datasetId, page, limit, keyword);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String, Object> 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.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setConnectTimeout(30000);
|
connection.setConnectTimeout(30000);
|
||||||
connection.setReadTimeout(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();
|
int responseCode = connection.getResponseCode();
|
||||||
|
String responseBody = readAll(responseCode >= 200 && responseCode < 300
|
||||||
|
? connection.getInputStream()
|
||||||
|
: connection.getErrorStream());
|
||||||
if (responseCode < 200 || responseCode >= 300) {
|
if (responseCode < 200 || responseCode >= 300) {
|
||||||
StringBuilder errorBody = new StringBuilder();
|
logger.warn("知识库接口调用失败: {} {} -> {} {}", method, url, responseCode, responseBody);
|
||||||
try (BufferedReader br = new BufferedReader(
|
return fail(errorMessage, responseBody);
|
||||||
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = br.readLine()) != null) {
|
|
||||||
errorBody.append(line);
|
|
||||||
}
|
}
|
||||||
|
// 删除类接口成功时返回体可能为空
|
||||||
|
if (StrUtil.isBlank(responseBody)) {
|
||||||
|
return success(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
return fail("获取挑战杯历史案例知识库失败", errorBody.toString());
|
JSONObject result = JSONObject.parseObject(responseBody);
|
||||||
}
|
return success(result == null ? responseBody : result);
|
||||||
|
|
||||||
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) {
|
} 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申报书生成入参(对应「申报书生成」工作流)。
|
||||||
|
*
|
||||||
|
* <p>业务说明:根据项目核心创意、技术路线和团队情况生成申报书初稿;
|
||||||
|
* 已提供初稿时可结合前沿资料优化完善。
|
||||||
|
*
|
||||||
|
* <p><b>字段名对齐 Dify 工作流开始节点的真实变量名</b>,取自 2026-09-19
|
||||||
|
* 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出:
|
||||||
|
* {@code core_idea, tech_route, team_intro, upload_file(文件类), parse_value}。
|
||||||
|
*
|
||||||
|
* <p>发送时仍会经 {@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(文件类)。
|
||||||
|
*
|
||||||
|
* <p>可为 Dify 文件对象,也可为已上传文件 ID 字符串,原样透传。
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Object uploadFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析取值开关。对应 Dify 变量 parse_value。
|
||||||
|
*
|
||||||
|
* <p>类型未在探针中确定(探针只给变量名不给类型),
|
||||||
|
* 故用 Object 承接,传布尔、字符串或数字均可,由 Dify 侧决定语义。
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Object parseValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接指定 inputs,非空时优先于上面的业务字段。
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private java.util.Map<String, Object> inputs;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能文档处理系统入参(对应「医科大智能文档处理系统」工作流)。
|
||||||
|
*
|
||||||
|
* <p>业务说明:对上传的 PDF 或网络文件进行解析、清洗、分段、关键词提取,
|
||||||
|
* 处理结果导入「GXMU 团章」知识库。因此文件来源有二:上传文件或网络 PDF 地址。
|
||||||
|
*
|
||||||
|
* <p><b>字段名对齐 Dify 工作流开始节点的真实变量名</b>,取自 2026-09-19
|
||||||
|
* 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出:
|
||||||
|
* {@code query, pdf_url, pdf_file(文件类)}。
|
||||||
|
*
|
||||||
|
* <p>发送时仍会经 {@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(文件类)。
|
||||||
|
*
|
||||||
|
* <p>由 {@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<String, Object> inputs;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -38,3 +38,55 @@ wxpay:
|
|||||||
private-key-path: /www/wwwroot/tuanwei/cert/apiclient_key.pem
|
private-key-path: /www/wwwroot/tuanwei/cert/apiclient_key.pem
|
||||||
pub-key-path: /www/wwwroot/tuanwei/cert/44ad9c08a0ab46dd8fc555178df581c4.pem
|
pub-key-path: /www/wwwroot/tuanwei/cert/44ad9c08a0ab46dd8fc555178df581c4.pem
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -103,6 +103,73 @@ wxpay:
|
|||||||
mch-id: 1732930031
|
mch-id: 1732930031
|
||||||
mch-serial-no: 7EE24C695B278DB2C4F0C1CB42069065ECCBE3FD
|
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:
|
gxmu:
|
||||||
openplat:
|
openplat:
|
||||||
|
|||||||
@@ -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 覆盖是否完整。
|
||||||
|
*
|
||||||
|
* <p>背景:Dify 内网实例与公网实例的应用 Key、知识库 ID、密钥<b>完全不通用</b>。
|
||||||
|
* 切换时若漏覆盖某一项,运行期会表现为该功能单独报 401/参数错误,
|
||||||
|
* 而 Spring 的松散绑定不会给出任何警告 —— 这类"漏一项"极难在测试环境发现。
|
||||||
|
* 因此这里逐项断言 prod 覆盖的完整性。
|
||||||
|
*
|
||||||
|
* <p>本测试不启动容器,只做 application.yml + application-prod.yml 的绑定,
|
||||||
|
* 不依赖数据库与 Redis。
|
||||||
|
*/
|
||||||
|
class DifyProdProfileBindingTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟 Spring 的 profile 加载顺序。
|
||||||
|
*
|
||||||
|
* <p>PropertySource 是<b>先匹配先生效</b>:列表靠前的优先级更高。
|
||||||
|
* 因此必须把 application-prod.yml 放到 application.yml <b>之前</b>,
|
||||||
|
* 才能复现"profile 覆盖默认值"的真实行为。
|
||||||
|
*
|
||||||
|
* <p>第一版误用了 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<String> 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<String> 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 默认值应保持公网,避免本地开发被切到内网");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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}。
|
||||||
|
*
|
||||||
|
* <p>纯绑定测试,不启动 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<String> 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 的映射。
|
||||||
|
*
|
||||||
|
* <p>变量名以 2026-09-19 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出为准:
|
||||||
|
* <pre>
|
||||||
|
* document-process : query, pdf_url, pdf_file(文件类)
|
||||||
|
* declaration : core_idea, tech_route, team_intro, upload_file(文件类), parse_value
|
||||||
|
* </pre>
|
||||||
|
* 这两组名字与公网实例的命名习惯并不一致,因此这里既固定映射行为,
|
||||||
|
* 也直接从 application.yml 读配置来断言,防止后人"顺手改成看起来更合理"的名字。
|
||||||
|
*/
|
||||||
|
class AiInputMappingTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复刻 AiController#objectToMap 的实现(该方法为 private,此处等价重写)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> objectToMap(Object source) throws Exception {
|
||||||
|
String json = objectMapper.writeValueAsString(source);
|
||||||
|
Map<String, Object> map = JSONUtil.parseObject(json, Map.class);
|
||||||
|
return map == null ? new LinkedHashMap<>() : map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复刻 AiController#remapInputs 的实现
|
||||||
|
*/
|
||||||
|
private Map<String, Object> remap(Map<String, Object> raw, DifyProperties.VariableMapping mapping) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Object> 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<String, Object> 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<String, Object> fileValue = new LinkedHashMap<>();
|
||||||
|
fileValue.put("transfer_method", "local_file");
|
||||||
|
fileValue.put("upload_file_id", "abc-123");
|
||||||
|
inputs.setUploadFile(fileValue);
|
||||||
|
|
||||||
|
Map<String, Object> 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<String, Object> fileValue = new LinkedHashMap<>();
|
||||||
|
fileValue.put("transfer_method", "local_file");
|
||||||
|
fileValue.put("upload_file_id", "pdf-1");
|
||||||
|
inputs.setPdfFile(fileValue);
|
||||||
|
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> raw = objectToMap(inputs);
|
||||||
|
raw.put("extraField", "额外字段");
|
||||||
|
|
||||||
|
Map<String, Object> result = remap(raw, mapping);
|
||||||
|
|
||||||
|
assertEquals("额外字段", result.get("extraField"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 映射配置覆盖了全部真实变量() throws Exception {
|
||||||
|
DifyProperties properties = loadProperties();
|
||||||
|
|
||||||
|
// 内网探针实测出的变量全集,少一个就说明 yml 里漏配
|
||||||
|
Map<String, String> 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<String, String> document = properties.getWorkflows().getDocumentProcess().getFields();
|
||||||
|
assertTrue(document.containsValue("query"), "缺少 query");
|
||||||
|
assertTrue(document.containsValue("pdf_url"), "缺少 pdf_url");
|
||||||
|
assertTrue(document.containsValue("pdf_file"), "缺少 pdf_file");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user