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
@@ -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");
}
}