From 37b478a0524f06b96ac4929254e68c2f69b9669c Mon Sep 17 00:00:00 2001
From: Jerry Yan <792602257@qq.com>
Date: Wed, 18 Feb 2026 09:25:00 +0800
Subject: [PATCH] =?UTF-8?q?fix(kg):=20=E4=BF=AE=E5=A4=8D=20Codex=20?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=20P1/P2=20?=
=?UTF-8?q?=E9=97=AE=E9=A2=98=E5=B9=B6=E8=A1=A5=E5=85=A8=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
修复内容:
P1 级别(关键):
1. 数据隔离漏洞:邻居查询添加 graph_id 路径约束,防止跨图谱数据泄漏
2. 空快照误删风险:添加 allowPurgeOnEmptySnapshot 保护开关(默认 false)
3. 弱默认凭据:启动自检,生产环境检测到默认密码直接拒绝启动
P2 级别(重要):
4. 配置校验:importBatchSize 添加 @Min(1) 验证,启动时 fail-fast
5. N+1 性能:重写 upsertEntity 为单条 Cypher 查询(从 3 条优化到 1 条)
6. 服务认证:添加 mTLS/JWT 文档说明
7. 错误处理:改进 Schema 初始化和序列化错误处理
测试覆盖:
- 新增 69 个单元测试,全部通过
- GraphEntityServiceTest: 13 个测试(CRUD、验证、分页)
- GraphRelationServiceTest: 13 个测试(CRUD、方向验证)
- GraphSyncServiceTest: 5 个测试(验证、全量同步)
- GraphSyncStepServiceTest: 14 个测试(空快照保护、N+1 验证)
- GraphQueryServiceTest: 13 个测试(邻居/路径/子图/搜索)
- GraphInitializerTest: 11 个测试(凭据验证、Schema 初始化)
技术细节:
- 数据隔离:使用 ALL() 函数约束路径中所有节点和关系的 graph_id
- 空快照保护:新增配置项 allow-purge-on-empty-snapshot 和错误码 EMPTY_SNAPSHOT_PURGE_BLOCKED
- 凭据检查:Java 和 Python 双端实现,根据环境(dev/test/prod)采取不同策略
- 性能优化:使用 SDN 复合属性格式(properties.key)在 MERGE 中直接设置属性
- 属性安全:使用白名单 [a-zA-Z0-9_] 防止 Cypher 注入
代码变更:+210 行,-29 行
---
.../services/knowledge-graph-service/pom.xml | 5 +
.../application/GraphSyncStepService.java | 80 ++++-
.../repository/GraphEntityRepository.java | 5 +-
.../repository/GraphRelationRepository.java | 7 +-
.../exception/KnowledgeGraphErrorCode.java | 5 +-
.../neo4j/GraphInitializer.java | 75 ++++-
.../neo4j/KnowledgeGraphProperties.java | 14 +-
.../interfaces/rest/GraphSyncController.java | 5 +
.../resources/application-knowledgegraph.yml | 2 +
.../application/GraphEntityServiceTest.java | 233 +++++++++++++
.../application/GraphQueryServiceTest.java | 177 ++++++++++
.../application/GraphRelationServiceTest.java | 270 +++++++++++++++
.../application/GraphSyncServiceTest.java | 129 +++++++
.../application/GraphSyncStepServiceTest.java | 318 ++++++++++++++++++
.../neo4j/GraphInitializerTest.java | 157 +++++++++
runtime/datamate-python/app/core/config.py | 41 +++
16 files changed, 1494 insertions(+), 29 deletions(-)
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java
create mode 100644 backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java
diff --git a/backend/services/knowledge-graph-service/pom.xml b/backend/services/knowledge-graph-service/pom.xml
index 276d770..0667f76 100644
--- a/backend/services/knowledge-graph-service/pom.xml
+++ b/backend/services/knowledge-graph-service/pom.xml
@@ -104,6 +104,11 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java
index 1619981..178a10e 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphSyncStepService.java
@@ -149,18 +149,25 @@ public class GraphSyncStepService {
* 删除 Neo4j 中 source_type=SYNC 但 source_id 不在活跃集合中的实体。
* 使用 DETACH DELETE 同时清理关联关系。
*
- * 当 activeSourceIds 为空时,说明数据源中该类型实体已全部移除,
- * 将清除图中所有对应的 SYNC 实体。调用方需确保空集是数据拉取成功后的
- * 真实结果(fetchDatasetsWithRetry 失败时会抛异常,不会到达此处)。
+ * 空快照保护:当 activeSourceIds 为空时,默认拒绝 purge 以防误删全部同步实体。
+ * 仅当配置 {@code allowPurgeOnEmptySnapshot=true} 时才允许空集触发 purge。
*/
@Transactional
public int purgeStaleEntities(String graphId, String type, Set activeSourceIds, String syncId) {
+ if (activeSourceIds.isEmpty()) {
+ if (!properties.getSync().isAllowPurgeOnEmptySnapshot()) {
+ log.warn("[{}] Empty snapshot protection: active source IDs empty for type={}, " +
+ "purge BLOCKED (set allowPurgeOnEmptySnapshot=true to override)", syncId, type);
+ return 0;
+ }
+ log.warn("[{}] Active source IDs empty for type={}, purging ALL SYNC entities " +
+ "(allowPurgeOnEmptySnapshot=true)", syncId, type);
+ }
+
String cypher;
Map params;
if (activeSourceIds.isEmpty()) {
- log.warn("[{}] Active source IDs empty for type={}, purging ALL SYNC entities of this type",
- syncId, type);
cypher = "MATCH (e:Entity {graph_id: $graphId, type: $type, source_type: 'SYNC'}) " +
"DETACH DELETE e " +
"RETURN count(*) AS deleted";
@@ -309,10 +316,13 @@ public class GraphSyncStepService {
// -----------------------------------------------------------------------
/**
- * 使用 Cypher MERGE 原子创建或匹配实体,再通过 SDN 更新扩展属性。
+ * 使用单条 Cypher MERGE 原子创建或匹配实体,同时写入扩展属性。
*
- * MERGE 基于 (graph_id, source_id, type) 复合唯一约束,
- * 保证多实例并发写入时不会产生重复节点。
+ * 相比之前的 MERGE + find + save(3 次 DB 调用),
+ * 现在合并为单条 Cypher(1 次 DB 调用),消除 N+1 性能问题。
+ *
+ * 扩展属性通过 SDN composite property 格式存储({@code properties.key}),
+ * 属性键经过字符白名单过滤,防止 Cypher 注入。
*/
private void upsertEntity(String graphId, String sourceId, String type,
String name, String description,
@@ -327,14 +337,31 @@ public class GraphSyncStepService {
params.put("name", name != null ? name : "");
params.put("description", description != null ? description : "");
- // Phase 1: Atomic MERGE — 创建或匹配实体骨架
+ // 构建扩展属性的 SET 子句,使用 SDN composite property 格式(properties.key)
+ StringBuilder propSetClauses = new StringBuilder();
+ if (props != null) {
+ int idx = 0;
+ for (Map.Entry entry : props.entrySet()) {
+ if (entry.getValue() != null) {
+ String sanitizedKey = sanitizePropertyKey(entry.getKey());
+ if (sanitizedKey.isEmpty()) {
+ continue;
+ }
+ String paramName = "prop" + idx++;
+ propSetClauses.append(", e.`properties.").append(sanitizedKey).append("` = $").append(paramName);
+ params.put(paramName, toNeo4jValue(entry.getValue()));
+ }
+ }
+ }
+ String extraSet = propSetClauses.toString();
+
Boolean isNew = neo4jClient.query(
"MERGE (e:Entity {graph_id: $graphId, source_id: $sourceId, type: $type}) " +
"ON CREATE SET e.id = $newId, e.source_type = 'SYNC', e.confidence = 1.0, " +
" e.name = $name, e.description = $description, " +
- " e.created_at = datetime(), e.updated_at = datetime() " +
+ " e.created_at = datetime(), e.updated_at = datetime()" + extraSet + " " +
"ON MATCH SET e.name = $name, e.description = $description, " +
- " e.updated_at = datetime() " +
+ " e.updated_at = datetime()" + extraSet + " " +
"RETURN e.id = $newId AS isNew"
)
.bindAll(params)
@@ -343,13 +370,6 @@ public class GraphSyncStepService {
.one()
.orElse(false);
- // Phase 2: 通过 SDN 更新扩展属性(Map 序列化由 SDN 处理)
- entityRepository.findByGraphIdAndSourceIdAndType(graphId, sourceId, type)
- .ifPresent(entity -> {
- entity.setProperties(props != null ? props : new HashMap<>());
- entityRepository.save(entity);
- });
-
if (isNew) {
result.incrementCreated();
} else {
@@ -357,6 +377,30 @@ public class GraphSyncStepService {
}
}
+ /**
+ * 清理属性键,仅允许字母、数字和下划线,防止 Cypher 注入。
+ */
+ private static String sanitizePropertyKey(String key) {
+ return key.replaceAll("[^a-zA-Z0-9_]", "");
+ }
+
+ /**
+ * 将 Java 值转换为 Neo4j 兼容的属性值。
+ *
+ * Neo4j 属性值必须为原始类型或同类型列表,
+ * 不支持嵌套 Map 或异构列表。
+ */
+ private static Object toNeo4jValue(Object value) {
+ if (value instanceof List> list) {
+ if (list.isEmpty()) {
+ return List.of();
+ }
+ // Neo4j 要求列表元素类型一致,统一转为 String 列表
+ return list.stream().map(Object::toString).toList();
+ }
+ return value;
+ }
+
/**
* 使用 Cypher MERGE 创建或匹配关系,保证幂等性。
*
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java
index 52b88b9..f2120dd 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java
@@ -31,7 +31,10 @@ public interface GraphEntityRepository extends Neo4jRepository neighbor " +
+ " AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " +
+ " AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " +
"RETURN DISTINCT neighbor LIMIT $limit")
List findNeighbors(
@Param("graphId") String graphId,
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java
index 0c29dd8..8b9578b 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java
@@ -463,8 +463,8 @@ public class GraphRelationRepository {
try {
return MAPPER.writeValueAsString(properties);
} catch (JsonProcessingException e) {
- log.warn("Failed to serialize properties, falling back to empty: {}", e.getMessage());
- return "{}";
+ // 序列化失败不应静默吞掉,向上抛出以暴露数据问题
+ throw new IllegalArgumentException("Failed to serialize relation properties to JSON", e);
}
}
@@ -475,7 +475,8 @@ public class GraphRelationRepository {
try {
return MAPPER.readValue(json, MAP_TYPE);
} catch (JsonProcessingException e) {
- log.warn("Failed to deserialize properties_json, returning empty: {}", e.getMessage());
+ log.warn("Failed to deserialize properties_json (returning empty map): json='{}', error={}",
+ json.length() > 100 ? json.substring(0, 100) + "..." : json, e.getMessage());
return new HashMap<>();
}
}
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
index ec76994..7585062 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
@@ -19,7 +19,10 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
IMPORT_FAILED("knowledge_graph.0006", "图谱导入失败"),
QUERY_DEPTH_EXCEEDED("knowledge_graph.0007", "查询深度超出限制"),
MAX_NODES_EXCEEDED("knowledge_graph.0008", "查询结果节点数超出限制"),
- SYNC_FAILED("knowledge_graph.0009", "数据同步失败");
+ SYNC_FAILED("knowledge_graph.0009", "数据同步失败"),
+ EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
+ SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
+ INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码");
private final String code;
private final String message;
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java
index b3f588f..ea81ad2 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializer.java
@@ -2,6 +2,7 @@ package com.datamate.knowledgegraph.infrastructure.neo4j;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
@@ -9,6 +10,7 @@ import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.stereotype.Component;
import java.util.List;
+import java.util.Set;
/**
* 图谱 Schema 初始化器。
@@ -17,6 +19,8 @@ import java.util.List;
* 所有语句使用 {@code IF NOT EXISTS},保证幂等性。
*
* 对应 {@code docs/knowledge-graph/schema/schema.cypher} 中的第 1-3 部分。
+ *
+ * 安全自检:在非开发环境中,检测到默认 Neo4j 密码时拒绝启动。
*/
@Component
@Slf4j
@@ -24,9 +28,25 @@ import java.util.List;
@Order(1)
public class GraphInitializer implements ApplicationRunner {
+ /** 已知的弱默认密码,启动时拒绝。 */
+ private static final Set BLOCKED_DEFAULT_PASSWORDS = Set.of(
+ "datamate123", "neo4j", "password", "123456", "admin"
+ );
+
+ /** 仅识别「已存在」类错误消息的关键词,其余错误不应吞掉。 */
+ private static final Set ALREADY_EXISTS_KEYWORDS = Set.of(
+ "already exists", "already exist", "EquivalentSchemaRuleAlreadyExists"
+ );
+
private final Neo4jClient neo4jClient;
private final KnowledgeGraphProperties properties;
+ @Value("${spring.neo4j.authentication.password:}")
+ private String neo4jPassword;
+
+ @Value("${spring.profiles.active:default}")
+ private String activeProfile;
+
/**
* 需要在启动时执行的 Cypher 语句。
* 每条语句必须独立执行(Neo4j 不支持多条 DDL 在同一事务中)。
@@ -57,6 +77,9 @@ public class GraphInitializer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
+ // ── 安全自检:默认凭据检测 ──
+ validateCredentials();
+
if (!properties.getSync().isAutoInitSchema()) {
log.info("Schema auto-init is disabled, skipping");
return;
@@ -73,17 +96,59 @@ public class GraphInitializer implements ApplicationRunner {
succeeded++;
log.debug("Schema statement executed: {}", truncate(statement));
} catch (Exception e) {
- failed++;
- // 约束/索引可能已存在(不同于 IF NOT EXISTS 的实现版本),
- // 记录警告但不中断启动。
- log.warn("Schema statement failed (may already exist): {} — {}",
- truncate(statement), e.getMessage());
+ if (isAlreadyExistsError(e)) {
+ // 约束/索引已存在,安全跳过
+ succeeded++;
+ log.debug("Schema element already exists (safe to skip): {}", truncate(statement));
+ } else {
+ // 非「已存在」错误:记录并抛出,阻止启动
+ failed++;
+ log.error("Schema statement FAILED: {} — {}", truncate(statement), e.getMessage());
+ throw new IllegalStateException(
+ "Neo4j schema initialization failed: " + truncate(statement), e);
+ }
}
}
log.info("Neo4j schema initialization completed: succeeded={}, failed={}", succeeded, failed);
}
+ /**
+ * 检测是否使用了默认凭据。
+ *
+ * 在 dev/test 环境中仅发出警告,在其他环境(prod、staging 等)中直接拒绝启动。
+ */
+ private void validateCredentials() {
+ if (neo4jPassword == null || neo4jPassword.isBlank()) {
+ return;
+ }
+ if (BLOCKED_DEFAULT_PASSWORDS.contains(neo4jPassword)) {
+ boolean isDev = activeProfile.contains("dev") || activeProfile.contains("test")
+ || activeProfile.contains("local");
+ if (isDev) {
+ log.warn("⚠ Neo4j is using a WEAK DEFAULT password. "
+ + "This is acceptable in dev/test but MUST be changed for production.");
+ } else {
+ throw new IllegalStateException(
+ "SECURITY: Neo4j password is set to a known default ('" + neo4jPassword + "'). "
+ + "Production environments MUST use a strong, unique password. "
+ + "Set the NEO4J_PASSWORD environment variable to a secure value.");
+ }
+ }
+ }
+
+ /**
+ * 判断异常是否仅因为 Schema 元素已存在(安全可忽略)。
+ */
+ private static boolean isAlreadyExistsError(Exception e) {
+ String msg = e.getMessage();
+ if (msg == null) {
+ return false;
+ }
+ String lowerMsg = msg.toLowerCase();
+ return ALREADY_EXISTS_KEYWORDS.stream().anyMatch(kw -> lowerMsg.contains(kw.toLowerCase()));
+ }
+
private static String truncate(String s) {
return s.length() <= 100 ? s : s.substring(0, 97) + "...";
}
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
index 48a3c90..82d7cdc 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
@@ -1,11 +1,14 @@
package com.datamate.knowledgegraph.infrastructure.neo4j;
+import jakarta.validation.constraints.Min;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
@Data
@Component
+@Validated
@ConfigurationProperties(prefix = "datamate.knowledge-graph")
public class KnowledgeGraphProperties {
@@ -15,7 +18,8 @@ public class KnowledgeGraphProperties {
/** 子图返回最大节点数 */
private int maxNodesPerQuery = 500;
- /** 批量导入批次大小 */
+ /** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */
+ @Min(value = 1, message = "importBatchSize 必须 >= 1")
private int importBatchSize = 100;
/** 同步相关配置 */
@@ -44,5 +48,13 @@ public class KnowledgeGraphProperties {
/** 是否在启动时自动初始化 Schema */
private boolean autoInitSchema = true;
+
+ /**
+ * 是否允许空快照触发 purge(默认 false)。
+ *
+ * 当上游返回空列表时,如果该开关为 false,purge 将被跳过以防误删全部同步实体。
+ * 仅在确认数据源确实为空时才应开启此开关。
+ */
+ private boolean allowPurgeOnEmptySnapshot = false;
}
}
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
index 8b788e3..bd9bb07 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
@@ -15,6 +15,11 @@ import java.util.List;
*
* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。
* 生产环境中也可通过定时任务自动触发。
+ *
+ * 安全说明:本接口仅供内部服务调用(API Gateway / 定时任务),
+ * 外部请求必须经 API Gateway 鉴权后转发。
+ * 生产环境建议通过 mTLS 或内部 JWT 进一步加固服务间认证。
+ * 当前通过 {@code X-Internal-Token} 请求头进行简单的内部调用校验。
*/
@RestController
@RequestMapping("/knowledge-graph/{graphId}/sync")
diff --git a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
index 7a59563..3dd3b01 100644
--- a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
+++ b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
@@ -39,3 +39,5 @@ datamate:
retry-interval: ${KG_SYNC_RETRY_INTERVAL:1000}
# 是否在启动时自动初始化 Schema
auto-init-schema: ${KG_AUTO_INIT_SCHEMA:true}
+ # 是否允许空快照触发 purge(默认 false,防止上游返回空列表时误删全部同步实体)
+ allow-purge-on-empty-snapshot: ${KG_ALLOW_PURGE_ON_EMPTY_SNAPSHOT:false}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java
new file mode 100644
index 0000000..624c6f4
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphEntityServiceTest.java
@@ -0,0 +1,233 @@
+package com.datamate.knowledgegraph.application;
+
+import com.datamate.common.infrastructure.exception.BusinessException;
+import com.datamate.knowledgegraph.domain.model.GraphEntity;
+import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import com.datamate.knowledgegraph.interfaces.dto.CreateEntityRequest;
+import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphEntityServiceTest {
+
+ private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
+ private static final String INVALID_GRAPH_ID = "not-a-uuid";
+
+ @Mock
+ private GraphEntityRepository entityRepository;
+
+ @Mock
+ private KnowledgeGraphProperties properties;
+
+ @InjectMocks
+ private GraphEntityService entityService;
+
+ private GraphEntity sampleEntity;
+
+ @BeforeEach
+ void setUp() {
+ sampleEntity = GraphEntity.builder()
+ .id(ENTITY_ID)
+ .name("TestDataset")
+ .type("Dataset")
+ .description("A test dataset")
+ .graphId(GRAPH_ID)
+ .confidence(1.0)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+ }
+
+ // -----------------------------------------------------------------------
+ // graphId 校验
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getEntity_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> entityService.getEntity(INVALID_GRAPH_ID, ENTITY_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void getEntity_nullGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> entityService.getEntity(null, ENTITY_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // createEntity
+ // -----------------------------------------------------------------------
+
+ @Test
+ void createEntity_success() {
+ CreateEntityRequest request = new CreateEntityRequest();
+ request.setName("NewEntity");
+ request.setType("Dataset");
+ request.setDescription("Desc");
+
+ when(entityRepository.save(any(GraphEntity.class))).thenReturn(sampleEntity);
+
+ GraphEntity result = entityService.createEntity(GRAPH_ID, request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getName()).isEqualTo("TestDataset");
+ verify(entityRepository).save(any(GraphEntity.class));
+ }
+
+ // -----------------------------------------------------------------------
+ // getEntity
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getEntity_found() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleEntity));
+
+ GraphEntity result = entityService.getEntity(GRAPH_ID, ENTITY_ID);
+
+ assertThat(result.getId()).isEqualTo(ENTITY_ID);
+ assertThat(result.getName()).isEqualTo("TestDataset");
+ }
+
+ @Test
+ void getEntity_notFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> entityService.getEntity(GRAPH_ID, ENTITY_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // listEntities
+ // -----------------------------------------------------------------------
+
+ @Test
+ void listEntities_returnsAll() {
+ when(entityRepository.findByGraphId(GRAPH_ID))
+ .thenReturn(List.of(sampleEntity));
+
+ List results = entityService.listEntities(GRAPH_ID);
+
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getName()).isEqualTo("TestDataset");
+ }
+
+ // -----------------------------------------------------------------------
+ // updateEntity
+ // -----------------------------------------------------------------------
+
+ @Test
+ void updateEntity_partialUpdate_onlyChangesProvidedFields() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleEntity));
+ when(entityRepository.save(any(GraphEntity.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UpdateEntityRequest request = new UpdateEntityRequest();
+ request.setName("UpdatedName");
+ // description not set — should remain unchanged
+
+ GraphEntity result = entityService.updateEntity(GRAPH_ID, ENTITY_ID, request);
+
+ assertThat(result.getName()).isEqualTo("UpdatedName");
+ assertThat(result.getDescription()).isEqualTo("A test dataset");
+ }
+
+ // -----------------------------------------------------------------------
+ // deleteEntity
+ // -----------------------------------------------------------------------
+
+ @Test
+ void deleteEntity_success() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleEntity));
+
+ entityService.deleteEntity(GRAPH_ID, ENTITY_ID);
+
+ verify(entityRepository).delete(sampleEntity);
+ }
+
+ @Test
+ void deleteEntity_notFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> entityService.deleteEntity(GRAPH_ID, ENTITY_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // getNeighbors — 深度/限制 clamping
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getNeighbors_clampsDepthAndLimit() {
+ when(properties.getMaxDepth()).thenReturn(3);
+ when(properties.getMaxNodesPerQuery()).thenReturn(500);
+ when(entityRepository.findNeighbors(eq(GRAPH_ID), eq(ENTITY_ID), eq(3), eq(500)))
+ .thenReturn(List.of());
+
+ List result = entityService.getNeighbors(GRAPH_ID, ENTITY_ID, 100, 99999);
+
+ assertThat(result).isEmpty();
+ // depth clamped to maxDepth=3, limit clamped to maxNodesPerQuery=500
+ verify(entityRepository).findNeighbors(GRAPH_ID, ENTITY_ID, 3, 500);
+ }
+
+ // -----------------------------------------------------------------------
+ // 分页
+ // -----------------------------------------------------------------------
+
+ @Test
+ void listEntitiesPaged_normalPage() {
+ when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20))
+ .thenReturn(List.of(sampleEntity));
+ when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(1L);
+
+ var result = entityService.listEntitiesPaged(GRAPH_ID, 0, 20);
+
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getTotalElements()).isEqualTo(1);
+ }
+
+ @Test
+ void listEntitiesPaged_negativePage_clampedToZero() {
+ when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 20))
+ .thenReturn(List.of());
+ when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L);
+
+ var result = entityService.listEntitiesPaged(GRAPH_ID, -1, 20);
+
+ assertThat(result.getPage()).isEqualTo(0);
+ }
+
+ @Test
+ void listEntitiesPaged_oversizedPage_clampedTo200() {
+ when(entityRepository.findByGraphIdPaged(GRAPH_ID, 0L, 200))
+ .thenReturn(List.of());
+ when(entityRepository.countByGraphId(GRAPH_ID)).thenReturn(0L);
+
+ entityService.listEntitiesPaged(GRAPH_ID, 0, 999);
+
+ verify(entityRepository).findByGraphIdPaged(GRAPH_ID, 0L, 200);
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java
new file mode 100644
index 0000000..3660237
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphQueryServiceTest.java
@@ -0,0 +1,177 @@
+package com.datamate.knowledgegraph.application;
+
+import com.datamate.common.infrastructure.exception.BusinessException;
+import com.datamate.knowledgegraph.domain.model.GraphEntity;
+import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.neo4j.core.Neo4jClient;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphQueryServiceTest {
+
+ private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
+ private static final String ENTITY_ID_2 = "660e8400-e29b-41d4-a716-446655440002";
+ private static final String INVALID_GRAPH_ID = "bad-id";
+
+ @Mock
+ private Neo4jClient neo4jClient;
+
+ @Mock
+ private GraphEntityRepository entityRepository;
+
+ @Mock
+ private KnowledgeGraphProperties properties;
+
+ @InjectMocks
+ private GraphQueryService queryService;
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ // -----------------------------------------------------------------------
+ // graphId 校验
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getNeighborGraph_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> queryService.getNeighborGraph(INVALID_GRAPH_ID, ENTITY_ID, 2, 50))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void getShortestPath_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> queryService.getShortestPath(INVALID_GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void getSubgraph_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> queryService.getSubgraph(INVALID_GRAPH_ID, List.of(ENTITY_ID)))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void fulltextSearch_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> queryService.fulltextSearch(INVALID_GRAPH_ID, "test", 0, 20))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // getNeighborGraph — 实体不存在
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getNeighborGraph_entityNotFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> queryService.getNeighborGraph(GRAPH_ID, ENTITY_ID, 2, 50))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // getShortestPath — 起止相同
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getShortestPath_sameSourceAndTarget_returnsSingleNode() {
+ GraphEntity entity = GraphEntity.builder()
+ .id(ENTITY_ID).name("Node").type("Dataset").graphId(GRAPH_ID).build();
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(entity));
+
+ var result = queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3);
+
+ assertThat(result.getPathLength()).isEqualTo(0);
+ assertThat(result.getNodes()).hasSize(1);
+ assertThat(result.getEdges()).isEmpty();
+ }
+
+ @Test
+ void getShortestPath_sourceNotFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> queryService.getShortestPath(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // getSubgraph — 空输入
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getSubgraph_nullEntityIds_returnsEmptySubgraph() {
+ SubgraphVO result = queryService.getSubgraph(GRAPH_ID, null);
+
+ assertThat(result.getNodes()).isEmpty();
+ assertThat(result.getEdges()).isEmpty();
+ assertThat(result.getNodeCount()).isEqualTo(0);
+ }
+
+ @Test
+ void getSubgraph_emptyEntityIds_returnsEmptySubgraph() {
+ SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of());
+
+ assertThat(result.getNodes()).isEmpty();
+ assertThat(result.getEdges()).isEmpty();
+ }
+
+ @Test
+ void getSubgraph_exceedsMaxNodes_throwsBusinessException() {
+ when(properties.getMaxNodesPerQuery()).thenReturn(5);
+
+ List tooManyIds = List.of("1", "2", "3", "4", "5", "6");
+
+ assertThatThrownBy(() -> queryService.getSubgraph(GRAPH_ID, tooManyIds))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void getSubgraph_noExistingEntities_returnsEmptySubgraph() {
+ when(properties.getMaxNodesPerQuery()).thenReturn(500);
+ when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
+ .thenReturn(List.of());
+
+ SubgraphVO result = queryService.getSubgraph(GRAPH_ID, List.of(ENTITY_ID));
+
+ assertThat(result.getNodes()).isEmpty();
+ }
+
+ // -----------------------------------------------------------------------
+ // fulltextSearch — 空查询
+ // -----------------------------------------------------------------------
+
+ @Test
+ void fulltextSearch_blankQuery_returnsEmpty() {
+ var result = queryService.fulltextSearch(GRAPH_ID, "", 0, 20);
+
+ assertThat(result.getContent()).isEmpty();
+ assertThat(result.getTotalElements()).isEqualTo(0);
+ verifyNoInteractions(neo4jClient);
+ }
+
+ @Test
+ void fulltextSearch_nullQuery_returnsEmpty() {
+ var result = queryService.fulltextSearch(GRAPH_ID, null, 0, 20);
+
+ assertThat(result.getContent()).isEmpty();
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java
new file mode 100644
index 0000000..f193298
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphRelationServiceTest.java
@@ -0,0 +1,270 @@
+package com.datamate.knowledgegraph.application;
+
+import com.datamate.common.infrastructure.exception.BusinessException;
+import com.datamate.knowledgegraph.domain.model.GraphEntity;
+import com.datamate.knowledgegraph.domain.model.RelationDetail;
+import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
+import com.datamate.knowledgegraph.domain.repository.GraphRelationRepository;
+import com.datamate.knowledgegraph.interfaces.dto.CreateRelationRequest;
+import com.datamate.knowledgegraph.interfaces.dto.RelationVO;
+import com.datamate.knowledgegraph.interfaces.dto.UpdateRelationRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphRelationServiceTest {
+
+ private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String RELATION_ID = "770e8400-e29b-41d4-a716-446655440002";
+ private static final String SOURCE_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440001";
+ private static final String TARGET_ENTITY_ID = "660e8400-e29b-41d4-a716-446655440003";
+ private static final String INVALID_GRAPH_ID = "not-a-uuid";
+
+ @Mock
+ private GraphRelationRepository relationRepository;
+
+ @Mock
+ private GraphEntityRepository entityRepository;
+
+ @InjectMocks
+ private GraphRelationService relationService;
+
+ private RelationDetail sampleDetail;
+ private GraphEntity sourceEntity;
+ private GraphEntity targetEntity;
+
+ @BeforeEach
+ void setUp() {
+ sampleDetail = RelationDetail.builder()
+ .id(RELATION_ID)
+ .sourceEntityId(SOURCE_ENTITY_ID)
+ .sourceEntityName("Source")
+ .sourceEntityType("Dataset")
+ .targetEntityId(TARGET_ENTITY_ID)
+ .targetEntityName("Target")
+ .targetEntityType("Field")
+ .relationType("HAS_FIELD")
+ .properties(Map.of())
+ .weight(1.0)
+ .confidence(1.0)
+ .graphId(GRAPH_ID)
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ sourceEntity = GraphEntity.builder()
+ .id(SOURCE_ENTITY_ID).name("Source").type("Dataset").graphId(GRAPH_ID).build();
+ targetEntity = GraphEntity.builder()
+ .id(TARGET_ENTITY_ID).name("Target").type("Field").graphId(GRAPH_ID).build();
+ }
+
+ // -----------------------------------------------------------------------
+ // graphId 校验
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getRelation_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> relationService.getRelation(INVALID_GRAPH_ID, RELATION_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // createRelation
+ // -----------------------------------------------------------------------
+
+ @Test
+ void createRelation_success() {
+ when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sourceEntity));
+ when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(targetEntity));
+ when(relationRepository.create(eq(GRAPH_ID), eq(SOURCE_ENTITY_ID), eq(TARGET_ENTITY_ID),
+ eq("HAS_FIELD"), anyMap(), isNull(), isNull(), isNull()))
+ .thenReturn(Optional.of(sampleDetail));
+
+ CreateRelationRequest request = new CreateRelationRequest();
+ request.setSourceEntityId(SOURCE_ENTITY_ID);
+ request.setTargetEntityId(TARGET_ENTITY_ID);
+ request.setRelationType("HAS_FIELD");
+
+ RelationVO result = relationService.createRelation(GRAPH_ID, request);
+
+ assertThat(result.getId()).isEqualTo(RELATION_ID);
+ assertThat(result.getRelationType()).isEqualTo("HAS_FIELD");
+ assertThat(result.getSourceEntityId()).isEqualTo(SOURCE_ENTITY_ID);
+ assertThat(result.getTargetEntityId()).isEqualTo(TARGET_ENTITY_ID);
+ }
+
+ @Test
+ void createRelation_sourceNotFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ CreateRelationRequest request = new CreateRelationRequest();
+ request.setSourceEntityId(SOURCE_ENTITY_ID);
+ request.setTargetEntityId(TARGET_ENTITY_ID);
+ request.setRelationType("HAS_FIELD");
+
+ assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void createRelation_targetNotFound_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sourceEntity));
+ when(entityRepository.findByIdAndGraphId(TARGET_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ CreateRelationRequest request = new CreateRelationRequest();
+ request.setSourceEntityId(SOURCE_ENTITY_ID);
+ request.setTargetEntityId(TARGET_ENTITY_ID);
+ request.setRelationType("HAS_FIELD");
+
+ assertThatThrownBy(() -> relationService.createRelation(GRAPH_ID, request))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // getRelation
+ // -----------------------------------------------------------------------
+
+ @Test
+ void getRelation_found() {
+ when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleDetail));
+
+ RelationVO result = relationService.getRelation(GRAPH_ID, RELATION_ID);
+
+ assertThat(result.getId()).isEqualTo(RELATION_ID);
+ }
+
+ @Test
+ void getRelation_notFound_throwsBusinessException() {
+ when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> relationService.getRelation(GRAPH_ID, RELATION_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // listRelations (分页)
+ // -----------------------------------------------------------------------
+
+ @Test
+ void listRelations_returnsPaged() {
+ when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 20))
+ .thenReturn(List.of(sampleDetail));
+ when(relationRepository.countByGraphId(GRAPH_ID, null))
+ .thenReturn(1L);
+
+ var result = relationService.listRelations(GRAPH_ID, null, 0, 20);
+
+ assertThat(result.getContent()).hasSize(1);
+ assertThat(result.getTotalElements()).isEqualTo(1);
+ }
+
+ @Test
+ void listRelations_oversizedPage_clampedTo200() {
+ when(relationRepository.findByGraphId(GRAPH_ID, null, 0L, 200))
+ .thenReturn(List.of());
+ when(relationRepository.countByGraphId(GRAPH_ID, null))
+ .thenReturn(0L);
+
+ relationService.listRelations(GRAPH_ID, null, 0, 999);
+
+ verify(relationRepository).findByGraphId(GRAPH_ID, null, 0L, 200);
+ }
+
+ // -----------------------------------------------------------------------
+ // listEntityRelations — direction 校验
+ // -----------------------------------------------------------------------
+
+ @Test
+ void listEntityRelations_invalidDirection_throwsBusinessException() {
+ when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sourceEntity));
+
+ assertThatThrownBy(() ->
+ relationService.listEntityRelations(GRAPH_ID, SOURCE_ENTITY_ID, "invalid", null, 0, 20))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void listEntityRelations_inDirection() {
+ when(entityRepository.findByIdAndGraphId(SOURCE_ENTITY_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sourceEntity));
+ when(relationRepository.findInboundByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, 0L, 20))
+ .thenReturn(List.of(sampleDetail));
+ when(relationRepository.countByEntityId(GRAPH_ID, SOURCE_ENTITY_ID, null, "in"))
+ .thenReturn(1L);
+
+ var result = relationService.listEntityRelations(
+ GRAPH_ID, SOURCE_ENTITY_ID, "in", null, 0, 20);
+
+ assertThat(result.getContent()).hasSize(1);
+ }
+
+ // -----------------------------------------------------------------------
+ // updateRelation
+ // -----------------------------------------------------------------------
+
+ @Test
+ void updateRelation_success() {
+ when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleDetail));
+ RelationDetail updated = RelationDetail.builder()
+ .id(RELATION_ID).relationType("USES").weight(0.8)
+ .sourceEntityId(SOURCE_ENTITY_ID).targetEntityId(TARGET_ENTITY_ID)
+ .graphId(GRAPH_ID).build();
+ when(relationRepository.update(eq(RELATION_ID), eq(GRAPH_ID), eq("USES"), isNull(), eq(0.8), isNull()))
+ .thenReturn(Optional.of(updated));
+
+ UpdateRelationRequest request = new UpdateRelationRequest();
+ request.setRelationType("USES");
+ request.setWeight(0.8);
+
+ RelationVO result = relationService.updateRelation(GRAPH_ID, RELATION_ID, request);
+
+ assertThat(result.getRelationType()).isEqualTo("USES");
+ }
+
+ // -----------------------------------------------------------------------
+ // deleteRelation
+ // -----------------------------------------------------------------------
+
+ @Test
+ void deleteRelation_success() {
+ when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(Optional.of(sampleDetail));
+ when(relationRepository.deleteByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(1L);
+
+ relationService.deleteRelation(GRAPH_ID, RELATION_ID);
+
+ verify(relationRepository).deleteByIdAndGraphId(RELATION_ID, GRAPH_ID);
+ }
+
+ @Test
+ void deleteRelation_notFound_throwsBusinessException() {
+ when(relationRepository.findByIdAndGraphId(RELATION_ID, GRAPH_ID))
+ .thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> relationService.deleteRelation(GRAPH_ID, RELATION_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java
new file mode 100644
index 0000000..2ff794c
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncServiceTest.java
@@ -0,0 +1,129 @@
+package com.datamate.knowledgegraph.application;
+
+import com.datamate.common.infrastructure.exception.BusinessException;
+import com.datamate.knowledgegraph.domain.model.SyncResult;
+import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient;
+import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO;
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphSyncServiceTest {
+
+ private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String INVALID_GRAPH_ID = "bad-id";
+
+ @Mock
+ private GraphSyncStepService stepService;
+
+ @Mock
+ private DataManagementClient dataManagementClient;
+
+ @Mock
+ private KnowledgeGraphProperties properties;
+
+ @InjectMocks
+ private GraphSyncService syncService;
+
+ private KnowledgeGraphProperties.Sync syncConfig;
+
+ @BeforeEach
+ void setUp() {
+ syncConfig = new KnowledgeGraphProperties.Sync();
+ syncConfig.setMaxRetries(1);
+ syncConfig.setRetryInterval(10);
+ }
+
+ // -----------------------------------------------------------------------
+ // graphId 校验
+ // -----------------------------------------------------------------------
+
+ @Test
+ void syncAll_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> syncService.syncAll(INVALID_GRAPH_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void syncAll_nullGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> syncService.syncAll(null))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void syncDatasets_invalidGraphId_throwsBusinessException() {
+ assertThatThrownBy(() -> syncService.syncDatasets(INVALID_GRAPH_ID))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // syncAll — 正常流程
+ // -----------------------------------------------------------------------
+
+ @Test
+ void syncAll_success_returnsResultList() {
+ when(properties.getSync()).thenReturn(syncConfig);
+
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setName("Test");
+ dto.setCreatedBy("admin");
+ when(dataManagementClient.listAllDatasets()).thenReturn(List.of(dto));
+
+ SyncResult entityResult = SyncResult.builder().syncType("Dataset").build();
+ SyncResult fieldResult = SyncResult.builder().syncType("Field").build();
+ SyncResult userResult = SyncResult.builder().syncType("User").build();
+ SyncResult orgResult = SyncResult.builder().syncType("Org").build();
+ SyncResult hasFieldResult = SyncResult.builder().syncType("HAS_FIELD").build();
+ SyncResult derivedFromResult = SyncResult.builder().syncType("DERIVED_FROM").build();
+ SyncResult belongsToResult = SyncResult.builder().syncType("BELONGS_TO").build();
+
+ when(stepService.upsertDatasetEntities(eq(GRAPH_ID), anyList(), anyString()))
+ .thenReturn(entityResult);
+ when(stepService.upsertFieldEntities(eq(GRAPH_ID), anyList(), anyString()))
+ .thenReturn(fieldResult);
+ when(stepService.upsertUserEntities(eq(GRAPH_ID), anySet(), anyString()))
+ .thenReturn(userResult);
+ when(stepService.upsertOrgEntities(eq(GRAPH_ID), anyString()))
+ .thenReturn(orgResult);
+ when(stepService.purgeStaleEntities(eq(GRAPH_ID), anyString(), anySet(), anyString()))
+ .thenReturn(0);
+ when(stepService.mergeHasFieldRelations(eq(GRAPH_ID), anyString()))
+ .thenReturn(hasFieldResult);
+ when(stepService.mergeDerivedFromRelations(eq(GRAPH_ID), anyString()))
+ .thenReturn(derivedFromResult);
+ when(stepService.mergeBelongsToRelations(eq(GRAPH_ID), anyString()))
+ .thenReturn(belongsToResult);
+
+ List results = syncService.syncAll(GRAPH_ID);
+
+ assertThat(results).hasSize(7);
+ assertThat(results.get(0).getSyncType()).isEqualTo("Dataset");
+ }
+
+ // -----------------------------------------------------------------------
+ // syncAll — 正常流程
+ // -----------------------------------------------------------------------
+
+ @Test
+ void syncDatasets_fetchRetryExhausted_throwsBusinessException() {
+ when(properties.getSync()).thenReturn(syncConfig);
+ when(dataManagementClient.listAllDatasets()).thenThrow(new RuntimeException("connection refused"));
+
+ assertThatThrownBy(() -> syncService.syncDatasets(GRAPH_ID))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("拉取数据集失败");
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java
new file mode 100644
index 0000000..7ea4bc1
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/GraphSyncStepServiceTest.java
@@ -0,0 +1,318 @@
+package com.datamate.knowledgegraph.application;
+
+import com.datamate.knowledgegraph.domain.model.GraphEntity;
+import com.datamate.knowledgegraph.domain.model.SyncResult;
+import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
+import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.DatasetDTO;
+import com.datamate.knowledgegraph.infrastructure.client.DataManagementClient.TagDTO;
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.neo4j.core.Neo4jClient;
+import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec;
+import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec;
+import org.springframework.data.neo4j.core.Neo4jClient.RecordFetchSpec;
+import org.springframework.data.neo4j.core.Neo4jClient.MappingSpec;
+
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphSyncStepServiceTest {
+
+ private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String SYNC_ID = "test-sync";
+
+ @Mock
+ private GraphEntityRepository entityRepository;
+
+ @Mock
+ private Neo4jClient neo4jClient;
+
+ @Mock
+ private KnowledgeGraphProperties properties;
+
+ @InjectMocks
+ private GraphSyncStepService stepService;
+
+ @Captor
+ private ArgumentCaptor cypherCaptor;
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ // -----------------------------------------------------------------------
+ // Neo4jClient mock chain helper
+ // -----------------------------------------------------------------------
+
+ /**
+ * Build a full mock chain for neo4jClient.query(...).bindAll(...).fetchAs(Long).mappedBy(...).one()
+ */
+ @SuppressWarnings("unchecked")
+ private void setupNeo4jQueryChain(Class> fetchType, Object returnValue) {
+ UnboundRunnableSpec unboundSpec = mock(UnboundRunnableSpec.class);
+ RunnableSpec runnableSpec = mock(RunnableSpec.class);
+ MappingSpec mappingSpec = mock(MappingSpec.class);
+ RecordFetchSpec fetchSpec = mock(RecordFetchSpec.class);
+
+ when(neo4jClient.query(anyString())).thenReturn(unboundSpec);
+ when(unboundSpec.bindAll(anyMap())).thenReturn(runnableSpec);
+ when(runnableSpec.fetchAs(any(Class.class))).thenReturn(mappingSpec);
+ when(mappingSpec.mappedBy(any())).thenReturn(fetchSpec);
+ when(fetchSpec.one()).thenReturn(Optional.ofNullable(returnValue));
+ }
+
+ // -----------------------------------------------------------------------
+ // purgeStaleEntities — P1-2 空快照保护
+ // -----------------------------------------------------------------------
+
+ @Nested
+ class PurgeStaleEntitiesTest {
+
+ @Test
+ void emptySnapshot_defaultConfig_blocksPurge() {
+ KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync();
+ syncConfig.setAllowPurgeOnEmptySnapshot(false);
+ when(properties.getSync()).thenReturn(syncConfig);
+
+ int deleted = stepService.purgeStaleEntities(
+ GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID);
+
+ assertThat(deleted).isEqualTo(0);
+ // Should NOT execute any Cypher query
+ verifyNoInteractions(neo4jClient);
+ }
+
+ @Test
+ void emptySnapshot_explicitAllow_executesPurge() {
+ KnowledgeGraphProperties.Sync syncConfig = new KnowledgeGraphProperties.Sync();
+ syncConfig.setAllowPurgeOnEmptySnapshot(true);
+ when(properties.getSync()).thenReturn(syncConfig);
+ setupNeo4jQueryChain(Long.class, 5L);
+
+ int deleted = stepService.purgeStaleEntities(
+ GRAPH_ID, "Dataset", Collections.emptySet(), SYNC_ID);
+
+ assertThat(deleted).isEqualTo(5);
+ verify(neo4jClient).query(anyString());
+ }
+
+ @Test
+ void nonEmptySnapshot_purgesStaleEntities() {
+ setupNeo4jQueryChain(Long.class, 2L);
+
+ Set activeIds = Set.of("ds-001", "ds-002");
+ int deleted = stepService.purgeStaleEntities(
+ GRAPH_ID, "Dataset", activeIds, SYNC_ID);
+
+ assertThat(deleted).isEqualTo(2);
+ verify(neo4jClient).query(contains("NOT e.source_id IN $activeSourceIds"));
+ }
+
+ @Test
+ void nonEmptySnapshot_nothingToDelete_returnsZero() {
+ setupNeo4jQueryChain(Long.class, 0L);
+
+ Set activeIds = Set.of("ds-001");
+ int deleted = stepService.purgeStaleEntities(
+ GRAPH_ID, "Dataset", activeIds, SYNC_ID);
+
+ assertThat(deleted).isEqualTo(0);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // upsertDatasetEntities — P2-5 单条 Cypher 优化
+ // -----------------------------------------------------------------------
+
+ @Nested
+ class UpsertDatasetEntitiesTest {
+
+ @Test
+ void upsert_newEntity_incrementsCreated() {
+ when(properties.getImportBatchSize()).thenReturn(100);
+ setupNeo4jQueryChain(Boolean.class, true);
+
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setName("Test Dataset");
+ dto.setDescription("Desc");
+ dto.setDatasetType("TEXT");
+ dto.setStatus("ACTIVE");
+
+ SyncResult result = stepService.upsertDatasetEntities(
+ GRAPH_ID, List.of(dto), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(1);
+ assertThat(result.getUpdated()).isEqualTo(0);
+ assertThat(result.getSyncType()).isEqualTo("Dataset");
+ }
+
+ @Test
+ void upsert_existingEntity_incrementsUpdated() {
+ when(properties.getImportBatchSize()).thenReturn(100);
+ setupNeo4jQueryChain(Boolean.class, false);
+
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setName("Updated");
+
+ SyncResult result = stepService.upsertDatasetEntities(
+ GRAPH_ID, List.of(dto), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(0);
+ assertThat(result.getUpdated()).isEqualTo(1);
+ }
+
+ @Test
+ void upsert_emptyList_returnsZeroCounts() {
+ when(properties.getImportBatchSize()).thenReturn(100);
+
+ SyncResult result = stepService.upsertDatasetEntities(
+ GRAPH_ID, List.of(), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(0);
+ assertThat(result.getUpdated()).isEqualTo(0);
+ verifyNoInteractions(neo4jClient);
+ }
+
+ @Test
+ void upsert_cypher_containsPropertiesSetClauses() {
+ when(properties.getImportBatchSize()).thenReturn(100);
+ setupNeo4jQueryChain(Boolean.class, true);
+
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setName("Dataset");
+ dto.setDatasetType("TEXT");
+ dto.setStatus("ACTIVE");
+
+ stepService.upsertDatasetEntities(GRAPH_ID, List.of(dto), SYNC_ID);
+
+ // Verify the MERGE Cypher includes property SET clauses (N+1 fix)
+ verify(neo4jClient).query(cypherCaptor.capture());
+ String cypher = cypherCaptor.getValue();
+ assertThat(cypher).contains("MERGE");
+ assertThat(cypher).contains("properties.");
+ // Verify NO separate find+save (N+1 eliminated)
+ verifyNoInteractions(entityRepository);
+ }
+
+ @Test
+ void upsert_multipleEntities_eachGetsSeparateMerge() {
+ when(properties.getImportBatchSize()).thenReturn(100);
+ setupNeo4jQueryChain(Boolean.class, true);
+
+ DatasetDTO dto1 = new DatasetDTO();
+ dto1.setId("ds-001");
+ dto1.setName("DS1");
+ DatasetDTO dto2 = new DatasetDTO();
+ dto2.setId("ds-002");
+ dto2.setName("DS2");
+
+ SyncResult result = stepService.upsertDatasetEntities(
+ GRAPH_ID, List.of(dto1, dto2), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(2);
+ verify(neo4jClient, times(2)).query(anyString());
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // upsertFieldEntities
+ // -----------------------------------------------------------------------
+
+ @Nested
+ class UpsertFieldEntitiesTest {
+
+ @Test
+ void upsertFields_datasetsWithNoTags_returnsZero() {
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setTags(null);
+
+ SyncResult result = stepService.upsertFieldEntities(
+ GRAPH_ID, List.of(dto), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(0);
+ assertThat(result.getUpdated()).isEqualTo(0);
+ }
+
+ @Test
+ void upsertFields_datasetsWithTags_createsFieldPerTag() {
+ setupNeo4jQueryChain(Boolean.class, true);
+
+ DatasetDTO dto = new DatasetDTO();
+ dto.setId("ds-001");
+ dto.setName("Dataset1");
+ TagDTO tag1 = new TagDTO();
+ tag1.setName("tag_a");
+ TagDTO tag2 = new TagDTO();
+ tag2.setName("tag_b");
+ dto.setTags(List.of(tag1, tag2));
+
+ SyncResult result = stepService.upsertFieldEntities(
+ GRAPH_ID, List.of(dto), SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(2);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // 关系构建
+ // -----------------------------------------------------------------------
+
+ @Nested
+ class MergeRelationsTest {
+
+ @Test
+ void mergeHasField_noFields_returnsEmptyResult() {
+ when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Field"))
+ .thenReturn(List.of());
+
+ SyncResult result = stepService.mergeHasFieldRelations(GRAPH_ID, SYNC_ID);
+
+ assertThat(result.getSyncType()).isEqualTo("HAS_FIELD");
+ assertThat(result.getCreated()).isEqualTo(0);
+ }
+
+ @Test
+ void mergeDerivedFrom_noParent_skipsRelation() {
+ GraphEntity dataset = GraphEntity.builder()
+ .id("entity-1")
+ .type("Dataset")
+ .graphId(GRAPH_ID)
+ .properties(new HashMap<>()) // no parent_dataset_id
+ .build();
+
+ when(entityRepository.findByGraphIdAndType(GRAPH_ID, "Dataset"))
+ .thenReturn(List.of(dataset));
+
+ SyncResult result = stepService.mergeDerivedFromRelations(GRAPH_ID, SYNC_ID);
+
+ assertThat(result.getCreated()).isEqualTo(0);
+ }
+
+ @Test
+ void mergeBelongsTo_noDefaultOrg_returnsError() {
+ when(entityRepository.findByGraphIdAndSourceIdAndType(GRAPH_ID, "org:default", "Org"))
+ .thenReturn(Optional.empty());
+
+ SyncResult result = stepService.mergeBelongsToRelations(GRAPH_ID, SYNC_ID);
+
+ assertThat(result.getFailed()).isGreaterThan(0);
+ assertThat(result.getErrors()).contains("belongs_to:org_missing");
+ }
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java
new file mode 100644
index 0000000..2c9a450
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/neo4j/GraphInitializerTest.java
@@ -0,0 +1,157 @@
+package com.datamate.knowledgegraph.infrastructure.neo4j;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.boot.DefaultApplicationArguments;
+import org.springframework.data.neo4j.core.Neo4jClient;
+import org.springframework.data.neo4j.core.Neo4jClient.UnboundRunnableSpec;
+import org.springframework.data.neo4j.core.Neo4jClient.RunnableSpec;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GraphInitializerTest {
+
+ @Mock
+ private Neo4jClient neo4jClient;
+
+ private GraphInitializer createInitializer(String password, String profile, boolean autoInit) {
+ KnowledgeGraphProperties properties = new KnowledgeGraphProperties();
+ properties.getSync().setAutoInitSchema(autoInit);
+
+ GraphInitializer initializer = new GraphInitializer(neo4jClient, properties);
+ ReflectionTestUtils.setField(initializer, "neo4jPassword", password);
+ ReflectionTestUtils.setField(initializer, "activeProfile", profile);
+ return initializer;
+ }
+
+ // -----------------------------------------------------------------------
+ // P1-3: 默认凭据检测
+ // -----------------------------------------------------------------------
+
+ @Test
+ void run_defaultPassword_prodProfile_throwsException() {
+ GraphInitializer initializer = createInitializer("datamate123", "prod", false);
+
+ assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("SECURITY")
+ .hasMessageContaining("default");
+ }
+
+ @Test
+ void run_defaultPassword_stagingProfile_throwsException() {
+ GraphInitializer initializer = createInitializer("neo4j", "staging", false);
+
+ assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("SECURITY");
+ }
+
+ @Test
+ void run_defaultPassword_devProfile_warnsButContinues() {
+ GraphInitializer initializer = createInitializer("datamate123", "dev", false);
+
+ // Should not throw — just warn
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void run_defaultPassword_testProfile_warnsButContinues() {
+ GraphInitializer initializer = createInitializer("datamate123", "test", false);
+
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void run_defaultPassword_localProfile_warnsButContinues() {
+ GraphInitializer initializer = createInitializer("password", "local", false);
+
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void run_securePassword_prodProfile_succeeds() {
+ GraphInitializer initializer = createInitializer("s3cure!P@ssw0rd", "prod", false);
+
+ // Schema init disabled, so no queries. Should succeed.
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void run_blankPassword_skipsValidation() {
+ GraphInitializer initializer = createInitializer("", "prod", false);
+
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ // -----------------------------------------------------------------------
+ // Schema 初始化 — 成功
+ // -----------------------------------------------------------------------
+
+ @Test
+ void run_autoInitEnabled_executesAllStatements() {
+ GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
+
+ UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
+ when(neo4jClient.query(anyString())).thenReturn(spec);
+
+ initializer.run(new DefaultApplicationArguments());
+
+ // Should execute all schema statements (constraints + indexes + fulltext)
+ verify(neo4jClient, atLeast(10)).query(anyString());
+ }
+
+ @Test
+ void run_autoInitDisabled_skipsSchemaInit() {
+ GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", false);
+
+ initializer.run(new DefaultApplicationArguments());
+
+ verifyNoInteractions(neo4jClient);
+ }
+
+ // -----------------------------------------------------------------------
+ // P2-7: Schema 初始化错误处理
+ // -----------------------------------------------------------------------
+
+ @Test
+ void run_alreadyExistsError_safelyIgnored() {
+ GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
+
+ UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
+ when(neo4jClient.query(anyString())).thenReturn(spec);
+ doThrow(new RuntimeException("Constraint already exists"))
+ .when(spec).run();
+
+ // Should not throw — "already exists" errors are safely ignored
+ assertThatCode(() -> initializer.run(new DefaultApplicationArguments()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void run_nonExistenceError_throwsException() {
+ GraphInitializer initializer = createInitializer("s3cure!P@ss", "dev", true);
+
+ UnboundRunnableSpec spec = mock(UnboundRunnableSpec.class);
+ when(neo4jClient.query(anyString())).thenReturn(spec);
+ doThrow(new RuntimeException("Connection refused to Neo4j"))
+ .when(spec).run();
+
+ // Non-"already exists" errors should propagate
+ assertThatThrownBy(() -> initializer.run(new DefaultApplicationArguments()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("schema initialization failed");
+ }
+}
diff --git a/runtime/datamate-python/app/core/config.py b/runtime/datamate-python/app/core/config.py
index 3730e0f..82bd65a 100644
--- a/runtime/datamate-python/app/core/config.py
+++ b/runtime/datamate-python/app/core/config.py
@@ -1,6 +1,15 @@
from pydantic_settings import BaseSettings
from pydantic import SecretStr, model_validator
from typing import Optional
+import logging
+import os
+
+_logger = logging.getLogger(__name__)
+
+# 已知的弱默认凭据,生产环境禁止使用
+_BLOCKED_DEFAULT_PASSWORDS = {"password", "123456", "admin", "root", "datamate123"}
+_BLOCKED_DEFAULT_TOKENS = {"abc123abc123", "EMPTY"}
+
class Settings(BaseSettings):
"""应用程序配置"""
@@ -76,5 +85,37 @@ class Settings(BaseSettings):
# 标注编辑器(Label Studio Editor)相关
editor_max_text_bytes: int = 0 # <=0 表示不限制,正数为最大字节数
+ @model_validator(mode='after')
+ def check_default_credentials(self):
+ """生产环境下检测弱默认凭据,拒绝启动。
+
+ 通过环境变量 DATAMATE_ENV 判断环境:
+ - dev/test/local: 仅发出警告
+ - 其他(prod/staging 等): 抛出异常阻止启动
+ """
+ env = os.environ.get("DATAMATE_ENV", "dev").lower()
+ is_dev = env in ("dev", "test", "local", "development")
+ issues: list[str] = []
+
+ if self.mysql_password in _BLOCKED_DEFAULT_PASSWORDS:
+ issues.append(f"mysql_password is set to a weak default ('{self.mysql_password}')")
+
+ if self.label_studio_password and self.label_studio_password in _BLOCKED_DEFAULT_PASSWORDS:
+ issues.append("label_studio_password is set to a weak default")
+
+ if self.label_studio_user_token and self.label_studio_user_token in _BLOCKED_DEFAULT_TOKENS:
+ issues.append("label_studio_user_token is set to a weak default")
+
+ if issues:
+ msg = "SECURITY: Weak default credentials detected: " + "; ".join(issues)
+ if is_dev:
+ _logger.warning(msg + " (acceptable in dev/test, MUST change for production)")
+ else:
+ raise ValueError(
+ msg + ". Set proper credentials via environment variables "
+ "before deploying to production."
+ )
+ return self
+
# 全局设置实例
settings = Settings()