feat(dify): 全部迁移到内网实例,统一为单份配置

VPN 打通后开发机可直连内网,原先「默认公网 + prod 覆盖内网」的双份配置
已无必要,且两套 Key/知识库 ID 完全不通用(实测互打一律 401),
留着只会造成误用。公网那套整体移除,回退到 git 历史取。

- application.yml:base-url 与 8 个应用 Key、全部知识库密钥/ID 改指内网;
  原默认值来自公网,其注释同步改写
- application-dev.yml / application-prod.yml:删除 dify 覆盖段。
  YAML map 是按键合并而非整体替换,保留一份与默认值相同的覆盖
  只会在将来漏改时混出「一半内网一半公网」的状态
- DifyProperties:默认 baseUrl 改内网,类注释同步

修复一个上线起就坏的接口:

- admin 端「竞赛历史」文档列表按 challenge-cup 取库,而该 ID
  b342f096-… 在**公网实例上也返回 404**(公网真实的挑战杯库是
  7c812029-…,从未登记),故该接口一直不可用。改指内网「竞赛案例」
  库(181d58e3-…,145 篇),实测已能列出文档。对外路径保持
  /challenge-cup/documents 不变,以免影响 admin 端调用

清理失效的工作流变量(内网实测已从开始节点移除):

- document-process 的 query / pdf_url,declaration 的 parse_value。
  三者配置为空串以显式丢弃 —— 直接删行会退回「按原名透传」,
  反而把 pdfUrl / parseValue 这类驼峰名发给 Dify。实体字段保留,
  不破坏 /documentProcess/upload 的入参契约(pdfUrl 仍参与
  「必须提供文件或 pdfUrl」的校验,只是不再转发)
- 注:Dify 对多传变量静默忽略,故此前不报错、功能未受影响,
  属「看着对其实无效」的配置

测试:改断言为内网值,并新增「不残留任何公网配置」「失效变量必须
配成空串」等防回归用例;变异验证(base-url 改回公网 / 空串改回生效名)
确认用例有真实判别力。Dify 相关 44 例全绿。

Refs: .scratch/dify-probe/README.md, .scratch/ai-assistant/map.md
This commit is contained in:
2026-10-12 12:37:33 +08:00
parent a2a83f7d9e
commit a8c8dd8643
10 changed files with 240 additions and 158 deletions
@@ -11,16 +11,21 @@ import java.util.ArrayList;
import java.util.List;
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;
/**
* 校验生产 profile 的 Dify 覆盖是否完整。
* 校验生产 profile 下的 Dify 配置。
*
* <p>背景:Dify 内网实例与公网实例的应用 Key、知识库 ID、密钥<b>完全不通用</b>。
* 切换时若漏覆盖某一项,运行期会表现为该功能单独报 401/参数错误,
* 而 Spring 的松散绑定不会给出任何警告 —— 这类"漏一项"极难在测试环境发现。
* 因此这里逐项断言 prod 覆盖的完整性。
* <p>背景:2026-10-12 迁移前,application.yml 默认指向公网实例,由 application-prod.yml
* 整段覆盖为内网;那时"漏覆盖一项"会在运行期表现为该功能单独 401/404,
* 而 Spring 的松散绑定不会给出任何警告,因此本测试逐项断言覆盖完整性。
*
* <p>迁移后默认值本身即内网实例,prod 不再覆盖 dify(见 application.yml 的 dify 段注释)。
* 于是本测试的职责转为:<b>确认生产解析出的确实是内网实例、且不残留任何公网配置</b>。
* 这仍能挡住两类回归:有人重新往 prod 里加一份半截覆盖(YAML map 按键合并,
* 会与默认值混出"一半内网一半公网"的状态),或有人把默认值改回公网。
*
* <p>本测试不启动容器,只做 application.yml + application-prod.yml 的绑定,
* 不依赖数据库与 Redis。
@@ -61,11 +66,11 @@ class DifyProdProfileBindingTest {
}
@Test
void 生产环境八个应用Key全部覆盖为内网Key() throws Exception {
void 生产环境八个应用Key全部为内网Key() throws Exception {
DifyProperties.AppKeys apps = loadProd().getApps();
assertNotNull(apps);
// 逐个断言:任一为 null 或仍是公网 Key 都说明覆盖漏了
// 逐个断言:任一为 null 或仍是公网 Key 都说明配置错了
assertEquals("app-ZLUUbLkAX3FVzDubY06puxAo", apps.getChat(), "chat");
assertEquals("app-4oBWB3HrdNlANdPFG8ZvVTkY", apps.getActivityOutline(), "activity-outline");
assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.getDocumentProcess(), "document-process");
@@ -101,13 +106,13 @@ class DifyProdProfileBindingTest {
actual.add(apps.getDeclaration());
for (String pub : publicKeys) {
assertTrue(!actual.contains(pub),
assertFalse(actual.contains(pub),
"生产环境残留公网 Key: " + pub + ",会导致该功能 401");
}
}
@Test
void 生产环境知识库密钥指向内网() throws Exception {
void 生产环境知识库密钥全部指向内网() throws Exception {
DifyProperties.Dataset dataset = loadProd().getDataset();
assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", dataset.getApiKey());
@@ -118,16 +123,14 @@ class DifyProdProfileBindingTest {
assertEquals("dataset-f2a0Hjxix7lBcPNZtNMyKDTQ",
dataset.keyOf("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac"));
// 公网知识库 ID 仍登记,但生产下必须也指到内网密钥,不能残留公网密钥
assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv",
dataset.keyOf("b342f096-98b1-4a83-a296-f4ab60a6db0e"),
"公网库 ID 在生产残留了公网密钥,属死密钥,应显式覆盖为内网密钥");
// 明确断言:生产下任何知识库 ID 都不得解析出公网密钥
// 明确断言:生产下任何知识库 ID 都不得解析出公网密钥。
// 涵盖登记过的 ID 与未登记的回退路径(回退走 api-key,也已断言为内网密钥)。
for (String id : dataset.getKeys().keySet()) {
assertTrue(!"dataset-1n6MECRtQvEVGOL1o7SrSGfe".equals(dataset.keyOf(id)),
assertFalse("dataset-1n6MECRtQvEVGOL1o7SrSGfe".equals(dataset.keyOf(id)),
"生产环境知识库 " + id + " 残留公网密钥 dataset-1n6MECRt...");
}
assertFalse("dataset-1n6MECRtQvEVGOL1o7SrSGfe".equals(dataset.keyOf("unregistered-id")),
"生产环境未登记知识库的回退密钥是公网密钥");
}
@Test
@@ -135,13 +138,17 @@ class DifyProdProfileBindingTest {
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"));
assertEquals("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac", dataset.idOf("gxmu-constitution"));
// 公网知识库代号不应再登记:admin 端「竞赛历史」接口曾按 challenge-cup 取到
// 一个两边都 404 的死 ID,2026-10-12 已改指 competition-cases,这里钉住防回归
assertFalse(dataset.getIds().containsKey("challenge-cup"),
"生产环境仍登记公网知识库代号 challenge-cup");
}
@Test
void 生产环境保留工作流变量映射() throws Exception {
// profile 覆盖了 base-url 与密钥,但不应把 workflows 映射冲掉
// profile 不再覆盖 dify,但 workflows 映射必须仍然可读到
DifyProperties.WorkflowVariables workflows = loadProd().getWorkflows();
assertNotNull(workflows);
@@ -150,8 +157,9 @@ class DifyProdProfileBindingTest {
}
@Test
void 本地默认仍指向公网实例() throws Exception {
// application.yml 默认值不得被改成内网,否则本地开发连不上
void 生产与开发共用内网默认值() throws Exception {
// application.yml 默认值即内网实例:本地开发与生产同一套地址,
// 这是 2026-10-12 VPN 打通后作出的决定(原先默认公网、prod 覆盖内网)
StandardEnvironment environment = new StandardEnvironment();
for (PropertySource<?> source : new YamlPropertySourceLoader()
.load("application", new ClassPathResource("application.yml"))) {
@@ -159,8 +167,21 @@ class DifyProdProfileBindingTest {
}
DifyProperties properties = Binder.get(environment).bind("dify", DifyProperties.class).orElse(null);
assertNotNull(properties);
assertEquals("http://82.156.98.71/v1", properties.getBaseUrl(),
"application.yml 默认值应保持公网,避免本地开发被切到内网");
assertEquals("http://10.100.158.15/v1", properties.getBaseUrl(),
"application.yml 默认值应为内网实例");
}
@Test
void 生产环境不重复覆盖dify() throws Exception {
// prod 里若再次出现 dify 段,会与默认值按键合并成混合状态。
// 这条用"prod 单独加载后 dify 为空"来钉住该约定。
StandardEnvironment environment = new StandardEnvironment();
for (PropertySource<?> source : new YamlPropertySourceLoader()
.load("application-prod", new ClassPathResource("application-prod.yml"))) {
environment.getPropertySources().addLast(source);
}
assertTrue(environment.getProperty("dify.base-url") == null,
"application-prod.yml 又加回了 dify 覆盖,请改 application.yml 的默认值");
}
}
@@ -10,6 +10,7 @@ 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -36,18 +37,19 @@ class DifyPropertiesBindingTest {
DifyProperties properties = load();
assertNotNull(properties, "dify 配置未绑定,请检查 application.yml 缩进");
assertEquals("http://82.156.98.71/v1", properties.getBaseUrl());
// 2026-10-12 迁移:默认值即内网实例,开发与生产共用
assertEquals("http://10.100.158.15/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-ZLUUbLkAX3FVzDubY06puxAo", apps.getChat());
assertEquals("app-4oBWB3HrdNlANdPFG8ZvVTkY", apps.getActivityOutline());
assertEquals("app-fhhz4fXMkEdVivAEiuWpycn6", apps.getManuscript());
assertEquals("app-lAbpxMggNbIrByiDRDfckSSl", apps.getCreativeAssistant());
assertEquals("app-J6h5I6Bo4oeBif2EofBGVrGs", apps.getProjectQualityReview());
assertEquals("app-UzVLKnGwg69uAUdbgFx6XNmc", apps.getCompetitionHistoryPreprocess());
// 这两个应用在 yml 里用了环境变量占位,需确认占位符被解析
assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.getDocumentProcess());
assertEquals("app-k20aDmiQYh3LxqZSLuX8wjkU", apps.getDeclaration());
}
@@ -56,7 +58,7 @@ class DifyPropertiesBindingTest {
void 按代号取密钥可用() throws Exception {
DifyProperties.AppKeys apps = load().getApps();
assertEquals("app-ou0qPesouiopaPpmxcFV2gFG", apps.byCode("chat"));
assertEquals("app-ZLUUbLkAX3FVzDubY06puxAo", apps.byCode("chat"));
assertEquals("app-k20aDmiQYh3LxqZSLuX8wjkU", apps.byCode("declaration"));
assertEquals("app-ohdIhk0lVfUwoy10xXRebmjD", apps.byCode("documentProcess"));
// 未登记的代号返回 null,而不是静默回退到某个应用
@@ -68,14 +70,16 @@ class DifyPropertiesBindingTest {
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-jaqtnAQvYcQTEjECzxhQfNCv", dataset.getApiKey());
assertEquals("181d58e3-78c2-4b7d-939c-57e780a8f7d6", dataset.idOf("competition-cases"));
assertEquals("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac", dataset.idOf("gxmu-constitution"));
// 已登记的库返回自己的密钥
assertEquals("dataset-1n6MECRtQvEVGOL1o7SrSGfe",
dataset.keyOf("b342f096-98b1-4a83-a296-f4ab60a6db0e"));
assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv",
dataset.keyOf("181d58e3-78c2-4b7d-939c-57e780a8f7d6"));
assertEquals("dataset-f2a0Hjxix7lBcPNZtNMyKDTQ",
dataset.keyOf("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac"));
// 未登记的库回退到默认密钥
assertEquals("dataset-1n6MECRtQvEVGOL1o7SrSGfe", dataset.keyOf("unknown-id"));
assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv", dataset.keyOf("unknown-id"));
}
@Test
@@ -88,21 +92,27 @@ class DifyPropertiesBindingTest {
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"));
// parse_value 已从开始节点移除,配置成空串表示丢弃。
// 这里必须断言为 "" 而不是 null/原名 —— remapInputs 只把"空串"当作丢弃,
// 若配成 null 就会退回按原名透传,把 parseValue 发给 Dify。
assertEquals("", 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"));
// 同理:query / pdf_url 已移除,空串丢弃
assertEquals("", documentProcess.variableOf("query"));
assertEquals("", documentProcess.variableOf("pdf-url"));
// 驼峰入参也必须命中同一条丢弃规则(variableOf 内部转中划线再查)
assertEquals("", documentProcess.variableOf("pdfUrl"));
}
@Test
void 知识库登记了公网与内网两套() throws Exception {
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(2, ids.size(), "dify.dataset.ids 应只登记内网 2 个知识库,实际: " + ids);
// 内网实例的两个库各自有独立密钥,不能都回退到默认密钥
assertEquals("dataset-jaqtnAQvYcQTEjECzxhQfNCv",
@@ -111,4 +121,44 @@ class DifyPropertiesBindingTest {
dataset.keyOf("3e0ac108-3a0d-41e2-8447-aa1c1044d3ac"));
}
@Test
void 不残留公网实例的任何配置() throws Exception {
DifyProperties properties = load();
// 地址与 Key 都不应再出现公网实例的痕迹
assertFalse(properties.getBaseUrl().contains("82.156.98.71"),
"base-url 仍指向公网实例");
List<String> publicKeys = new java.util.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");
DifyProperties.AppKeys apps = properties.getApps();
List<String> actual = new java.util.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) {
assertFalse(actual.contains(pub), "残留公网应用 Key: " + pub);
}
// 公网知识库 ID 与其密钥都不应再登记
assertFalse(properties.getDataset().getKeys()
.containsValue("dataset-1n6MECRtQvEVGOL1o7SrSGfe"),
"残留公网知识库密钥");
assertFalse(properties.getDataset().getIds()
.containsKey("challenge-cup"),
"仍登记公网知识库代号 challenge-cup");
}
}
@@ -23,13 +23,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 校验「申报书生成」「智能文档处理」的入参到 Dify inputs 的映射。
*
* <p>变量名以 2026-09-19 在内网实例 10.100.158.15 上跑 probe-dify.sh 的实测输出为准:
* <p>变量名以内网实例 10.100.158.15 上的 probe-dify.sh 实测输出为准。
* 2026-10-12 在 VPN 打通后重跑探针,发现该实例的变量名<b>已再次漂移</b>:
* <pre>
* document-process : query, pdf_url, pdf_file(文件类)
* declaration : core_idea, tech_route, team_intro, upload_file(文件类), parse_value
* document-process : pdf_file(文件类) —— query / pdf_url 已从开始节点移除
* declaration : core_idea, tech_route, team_intro, upload_file(文件类)
* —— parse_value 已移除
* </pre>
* 这两组名字与公网实例的命名习惯并不一致,因此这里既固定映射行为,
* 也直接从 application.yml 读配置来断言,防止后人"顺手改成看起来更合理"的名字。
* 映射表仍保留这些旧键(Dify 对多传的变量静默忽略,不报错),
* 但测试不再断言它们"必须是开始节点变量",只断言映射行为本身。
*/
class AiInputMappingTest {
@@ -97,11 +99,13 @@ class AiInputMappingTest {
assertEquals("做一个校园二手书平台", result.get("core_idea"));
assertEquals("Spring Boot + 小程序", result.get("tech_route"));
assertEquals("3 人,均为计算机专业", result.get("team_intro"));
assertEquals("true", result.get("parse_value"));
// parse_value 已从开始节点移除,配置成空串即丢弃:
// 既不能出现 parse_value,也不能退回驼峰原名 parseValue
assertFalse(result.containsKey("parse_value"), "失效变量 parse_value 不应下发");
assertFalse(result.containsKey("parseValue"));
// 不能残留业务字段名
assertFalse(result.containsKey("coreIdea"));
assertFalse(result.containsKey("teamIntro"));
assertFalse(result.containsKey("parseValue"));
}
@Test
@@ -137,11 +141,14 @@ class AiInputMappingTest {
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"));
// query / pdf_url 已从开始节点移除,配置成空串即丢弃
assertFalse(result.containsKey("query"), "失效变量 query 不应下发");
assertFalse(result.containsKey("pdf_url"), "失效变量 pdf_url 不应下发");
// 丢弃不等于退回原名:驼峰名同样不能出现
assertFalse(result.containsKey("pdfUrl"));
assertFalse(result.containsKey("pdfFile"));
// 唯一真实变量仍应正常映射
assertEquals(fileValue, result.get("pdf_file"));
}
// ---------------------------------------------------------------- 通用行为
@@ -180,18 +187,24 @@ class AiInputMappingTest {
void 映射配置覆盖了全部真实变量() throws Exception {
DifyProperties properties = loadProperties();
// 内网探针实测出的变量全集,少一个就说明 yml 里漏配
// 2026-10-12 探针实测确认仍存在的开始节点变量,少一个就说明 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");
// 已从开始节点移除的变量必须显式配置成空串(丢弃),而不是把整行删掉 ——
// 删掉会退回"按原名透传",把 pdfUrl / parseValue 之类的驼峰名发给 Dify。
assertTrue(declaration.containsKey("parse-value"), "parse-value 应配置为空串以显式丢弃");
assertEquals("", declaration.get("parse-value"));
assertTrue(document.containsKey("query"), "query 应配置为空串以显式丢弃");
assertEquals("", document.get("query"));
assertTrue(document.containsKey("pdf-url"), "pdf-url 应配置为空串以显式丢弃");
assertEquals("", document.get("pdf-url"));
}
}