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:
2026-09-21 19:46:49 +08:00
parent 3e2d7b65a6
commit 8c994d9ed2
11 changed files with 1465 additions and 132 deletions
@@ -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.*}。
*
* <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<>();
@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<String, Object> 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<Object> uploadedFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file != null && !file.isEmpty()) {
uploadedFiles.add(uploadMultipartToAi(file, competitionHistoryPreprocessKey, loginUserId));
uploadedFiles.add(uploadMultipartToAi(file, appKeys().getCompetitionHistoryPreprocess(), loginUserId));
}
}
if (uploadedFiles.isEmpty()) {
@@ -435,12 +465,12 @@ public class AiController extends BaseController {
} else {
inputs.put("file_upload", uploadedFiles);
if (scoreFile != null && !scoreFile.isEmpty()) {
inputs.put("score_file", uploadMultipartToAi(scoreFile, competitionHistoryPreprocessKey, loginUserId));
inputs.put("score_file", uploadMultipartToAi(scoreFile, appKeys().getCompetitionHistoryPreprocess(), loginUserId));
}
if (StrUtil.isNotBlank(manualText)) {
inputs.put("manual_text", manualText.trim());
}
result = executeWorkflowForResult(inputs, competitionHistoryPreprocessKey,
result = executeWorkflowForResult(inputs, appKeys().getCompetitionHistoryPreprocess(),
"competitionHistoryPreprocess", loginUserId, true);
}
}
@@ -465,11 +495,11 @@ public class AiController extends BaseController {
return fail("请上传待审项目申报书");
}
Map<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()) {
inputs.put("custom_rubric", uploadMultipartToAi(customRubric, projectQualityReviewKey, loginUserId));
inputs.put("custom_rubric", uploadMultipartToAi(customRubric, appKeys().getProjectQualityReview(), loginUserId));
}
return executeWorkflowForResult(inputs, projectQualityReviewKey,
return executeWorkflowForResult(inputs, appKeys().getProjectQualityReview(),
"projectQualityReview", loginUserId, true);
} catch (Exception e) {
logger.error("project quality review failed", e);
@@ -479,6 +509,104 @@ public class AiController extends BaseController {
}
}
@ApiOperation("申报书生成")
@PostMapping("/declarationGen")
public ApiResult<?> declarationGen(@RequestBody DeclarationInputs message) {
Integer loginUserId = getLoginUserId();
if (message == null) {
return fail("请求参数不能为空");
}
// 核心创意是必填项(内网工作流 core_idea);upload_file 为可选的已有初稿/参考资料
if (StrUtil.isBlank(message.getCoreIdea())) {
return fail("coreIdea不能为空");
}
try {
Map<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")
public ApiResult<?> activityOutlineGen(@RequestBody ActivityOutlineInputs message) {
if (message == null) {
@@ -498,15 +626,15 @@ public class AiController extends BaseController {
}
try {
URL url = new URL(aiUrl + "/chat-messages");
URL url = new URL(aiUrl() + "/chat-messages");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + activityOutlineKey);
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getActivityOutline());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(600000);
connection.setReadTimeout(600000);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲");
@@ -642,15 +770,15 @@ public class AiController extends BaseController {
HttpURLConnection connection = null;
try {
URL url = new URL(aiUrl + "/workflows/run");
URL url = new URL(aiUrl() + "/workflows/run");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + creativeAssistantKey);
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getCreativeAssistant());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(600000);
connection.setReadTimeout(600000);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", buildCreativeAssistantInputs(requestBody));
@@ -706,7 +834,7 @@ public class AiController extends BaseController {
taskId = stringValue(data.get("id"));
}
if (StrUtil.isNotBlank(taskId)) {
recordActiveAiTask(loginUserId, "workflow", taskId, creativeAssistantKey);
recordActiveAiTask(loginUserId, "workflow", taskId, appKeys().getCreativeAssistant());
}
String event = stringValue(data.get("event"));
@@ -811,7 +939,9 @@ public class AiController extends BaseController {
inputs.put("highlights", message.getHighlights());
}
if (message.getNumber() != null) {
inputs.put("number", message.getNumber());
// Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。
// 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。
inputs.put("Number", message.getNumber());
}
return inputs;
}
@@ -845,6 +975,69 @@ public class AiController extends BaseController {
return inputs;
}
/**
* 把入参实体的业务字段转成 Dify 工作流的 inputs。
*
* <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) {
Map<String, Object> inputs = new LinkedHashMap<>();
if (requestBody == null) {
@@ -872,15 +1065,15 @@ public class AiController extends BaseController {
boolean streamToSocket) throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL(aiUrl + "/workflows/run");
URL url = new URL(aiUrl() + "/workflows/run");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(600000);
connection.setReadTimeout(600000);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs);
@@ -1046,11 +1239,11 @@ public class AiController extends BaseController {
}
tempFile = File.createTempFile("ai-upload-", suffix);
file.transferTo(tempFile);
HttpResponse response = HttpRequest.post(aiUrl + "/files/upload")
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
.header("Authorization", "Bearer " + apiKey)
.form("user", loginUserId == null ? "0" : loginUserId.toString())
.form("file", tempFile, originalFilename)
.timeout(600000)
.timeout(aiTimeout())
.execute();
String responseBody = response.body();
Map<String, Object> result = JSONUtil.parseObject(responseBody, Map.class);
@@ -1319,18 +1512,24 @@ public class AiController extends BaseController {
return activeTask.getApiKey();
}
if ("image".equals(mode)) {
return creativeAssistantKey;
return appKeys().getCreativeAssistant();
}
if ("writing".equals(mode)) {
return manuscriptKey;
return appKeys().getManuscript();
}
if ("projectQualityReview".equals(mode)) {
return projectQualityReviewKey;
return appKeys().getProjectQualityReview();
}
if ("competitionHistoryPreprocess".equals(mode)) {
return competitionHistoryPreprocessKey;
return appKeys().getCompetitionHistoryPreprocess();
}
return key;
if ("documentProcess".equals(mode)) {
return appKeys().getDocumentProcess();
}
if ("declaration".equals(mode)) {
return appKeys().getDeclaration();
}
return appKeys().getChat();
}
private String normalizeStopMode(String mode) {
@@ -1343,7 +1542,6 @@ public class AiController extends BaseController {
|| "message".equals(mode)
|| "activityOutline".equals(mode);
}
private String buildActiveTaskKey(Integer userId, String mode) {
return userId + ":" + mode;
}
@@ -1387,11 +1585,11 @@ public class AiController extends BaseController {
Map<String, Object> postData = new HashMap<>();
postData.put("user", loginUserId == null ? "0" : loginUserId.toString());
logger.info("AI stop payload: mode={}, taskId={}, path={}", mode, taskId, stopPath);
HttpResponse response = HttpRequest.post(aiUrl + stopPath)
HttpResponse response = HttpRequest.post(aiUrl() + stopPath)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(JSONObject.toJSONString(postData))
.timeout(600000)
.timeout(aiTimeout())
.execute();
String responseBody = response.body();
if (response.getStatus() < 200 || response.getStatus() >= 300) {
@@ -1411,9 +1609,9 @@ public class AiController extends BaseController {
}
File tempFile = null;
try {
String token = manuscriptKey;
String token = appKeys().getManuscript();
if (type == null || type == 0) {
token = key;
token = appKeys().getChat();
}
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin");
String suffix = "";
@@ -1424,11 +1622,11 @@ public class AiController extends BaseController {
tempFile = File.createTempFile("ai-upload-", suffix);
file.transferTo(tempFile);
HttpResponse response = HttpRequest.post(aiUrl + "/files/upload")
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
.header("Authorization", "Bearer " + token)
.form("user", StrUtil.blankToDefault(user, getLoginUserId().toString()))
.form("file", tempFile, originalFilename)
.timeout(600000)
.timeout(aiTimeout())
.execute();
int responseCode = response.getStatus();