You've already forked DataMate
fix(kg): 修复 Codex 审查发现的 P1/P2 问题并补全测试
修复内容: 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 行
This commit is contained in:
@@ -104,6 +104,11 @@
|
|||||||
</compilerArgs>
|
</compilerArgs>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -149,18 +149,25 @@ public class GraphSyncStepService {
|
|||||||
* 删除 Neo4j 中 source_type=SYNC 但 source_id 不在活跃集合中的实体。
|
* 删除 Neo4j 中 source_type=SYNC 但 source_id 不在活跃集合中的实体。
|
||||||
* 使用 DETACH DELETE 同时清理关联关系。
|
* 使用 DETACH DELETE 同时清理关联关系。
|
||||||
* <p>
|
* <p>
|
||||||
* 当 activeSourceIds 为空时,说明数据源中该类型实体已全部移除,
|
* <b>空快照保护</b>:当 activeSourceIds 为空时,默认拒绝 purge 以防误删全部同步实体。
|
||||||
* 将清除图中所有对应的 SYNC 实体。调用方需确保空集是数据拉取成功后的
|
* 仅当配置 {@code allowPurgeOnEmptySnapshot=true} 时才允许空集触发 purge。
|
||||||
* 真实结果(fetchDatasetsWithRetry 失败时会抛异常,不会到达此处)。
|
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int purgeStaleEntities(String graphId, String type, Set<String> activeSourceIds, String syncId) {
|
public int purgeStaleEntities(String graphId, String type, Set<String> 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;
|
String cypher;
|
||||||
Map<String, Object> params;
|
Map<String, Object> params;
|
||||||
|
|
||||||
if (activeSourceIds.isEmpty()) {
|
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'}) " +
|
cypher = "MATCH (e:Entity {graph_id: $graphId, type: $type, source_type: 'SYNC'}) " +
|
||||||
"DETACH DELETE e " +
|
"DETACH DELETE e " +
|
||||||
"RETURN count(*) AS deleted";
|
"RETURN count(*) AS deleted";
|
||||||
@@ -309,10 +316,13 @@ public class GraphSyncStepService {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 Cypher MERGE 原子创建或匹配实体,再通过 SDN 更新扩展属性。
|
* 使用单条 Cypher MERGE 原子创建或匹配实体,同时写入扩展属性。
|
||||||
* <p>
|
* <p>
|
||||||
* MERGE 基于 (graph_id, source_id, type) 复合唯一约束,
|
* 相比之前的 MERGE + find + save(3 次 DB 调用),
|
||||||
* 保证多实例并发写入时不会产生重复节点。
|
* 现在合并为单条 Cypher(1 次 DB 调用),消除 N+1 性能问题。
|
||||||
|
* <p>
|
||||||
|
* 扩展属性通过 SDN composite property 格式存储({@code properties.key}),
|
||||||
|
* 属性键经过字符白名单过滤,防止 Cypher 注入。
|
||||||
*/
|
*/
|
||||||
private void upsertEntity(String graphId, String sourceId, String type,
|
private void upsertEntity(String graphId, String sourceId, String type,
|
||||||
String name, String description,
|
String name, String description,
|
||||||
@@ -327,14 +337,31 @@ public class GraphSyncStepService {
|
|||||||
params.put("name", name != null ? name : "");
|
params.put("name", name != null ? name : "");
|
||||||
params.put("description", description != null ? description : "");
|
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<String, Object> 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(
|
Boolean isNew = neo4jClient.query(
|
||||||
"MERGE (e:Entity {graph_id: $graphId, source_id: $sourceId, type: $type}) " +
|
"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, " +
|
"ON CREATE SET e.id = $newId, e.source_type = 'SYNC', e.confidence = 1.0, " +
|
||||||
" e.name = $name, e.description = $description, " +
|
" 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, " +
|
"ON MATCH SET e.name = $name, e.description = $description, " +
|
||||||
" e.updated_at = datetime() " +
|
" e.updated_at = datetime()" + extraSet + " " +
|
||||||
"RETURN e.id = $newId AS isNew"
|
"RETURN e.id = $newId AS isNew"
|
||||||
)
|
)
|
||||||
.bindAll(params)
|
.bindAll(params)
|
||||||
@@ -343,13 +370,6 @@ public class GraphSyncStepService {
|
|||||||
.one()
|
.one()
|
||||||
.orElse(false);
|
.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) {
|
if (isNew) {
|
||||||
result.incrementCreated();
|
result.incrementCreated();
|
||||||
} else {
|
} else {
|
||||||
@@ -357,6 +377,30 @@ public class GraphSyncStepService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理属性键,仅允许字母、数字和下划线,防止 Cypher 注入。
|
||||||
|
*/
|
||||||
|
private static String sanitizePropertyKey(String key) {
|
||||||
|
return key.replaceAll("[^a-zA-Z0-9_]", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Java 值转换为 Neo4j 兼容的属性值。
|
||||||
|
* <p>
|
||||||
|
* 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 创建或匹配关系,保证幂等性。
|
* 使用 Cypher MERGE 创建或匹配关系,保证幂等性。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ public interface GraphEntityRepository extends Neo4jRepository<GraphEntity, Stri
|
|||||||
@Param("name") String name,
|
@Param("name") String name,
|
||||||
@Param("type") String type);
|
@Param("type") String type);
|
||||||
|
|
||||||
@Query("MATCH (e:Entity {graph_id: $graphId, id: $entityId})-[r*1..$depth]-(neighbor:Entity) " +
|
@Query("MATCH p = (e:Entity {graph_id: $graphId, id: $entityId})-[*1..$depth]-(neighbor:Entity) " +
|
||||||
|
"WHERE e <> 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")
|
"RETURN DISTINCT neighbor LIMIT $limit")
|
||||||
List<GraphEntity> findNeighbors(
|
List<GraphEntity> findNeighbors(
|
||||||
@Param("graphId") String graphId,
|
@Param("graphId") String graphId,
|
||||||
|
|||||||
@@ -463,8 +463,8 @@ public class GraphRelationRepository {
|
|||||||
try {
|
try {
|
||||||
return MAPPER.writeValueAsString(properties);
|
return MAPPER.writeValueAsString(properties);
|
||||||
} catch (JsonProcessingException e) {
|
} 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 {
|
try {
|
||||||
return MAPPER.readValue(json, MAP_TYPE);
|
return MAPPER.readValue(json, MAP_TYPE);
|
||||||
} catch (JsonProcessingException e) {
|
} 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<>();
|
return new HashMap<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
|
|||||||
IMPORT_FAILED("knowledge_graph.0006", "图谱导入失败"),
|
IMPORT_FAILED("knowledge_graph.0006", "图谱导入失败"),
|
||||||
QUERY_DEPTH_EXCEEDED("knowledge_graph.0007", "查询深度超出限制"),
|
QUERY_DEPTH_EXCEEDED("knowledge_graph.0007", "查询深度超出限制"),
|
||||||
MAX_NODES_EXCEEDED("knowledge_graph.0008", "查询结果节点数超出限制"),
|
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 code;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.datamate.knowledgegraph.infrastructure.neo4j;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
@@ -9,6 +10,7 @@ import org.springframework.data.neo4j.core.Neo4jClient;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图谱 Schema 初始化器。
|
* 图谱 Schema 初始化器。
|
||||||
@@ -17,6 +19,8 @@ import java.util.List;
|
|||||||
* 所有语句使用 {@code IF NOT EXISTS},保证幂等性。
|
* 所有语句使用 {@code IF NOT EXISTS},保证幂等性。
|
||||||
* <p>
|
* <p>
|
||||||
* 对应 {@code docs/knowledge-graph/schema/schema.cypher} 中的第 1-3 部分。
|
* 对应 {@code docs/knowledge-graph/schema/schema.cypher} 中的第 1-3 部分。
|
||||||
|
* <p>
|
||||||
|
* <b>安全自检</b>:在非开发环境中,检测到默认 Neo4j 密码时拒绝启动。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -24,9 +28,25 @@ import java.util.List;
|
|||||||
@Order(1)
|
@Order(1)
|
||||||
public class GraphInitializer implements ApplicationRunner {
|
public class GraphInitializer implements ApplicationRunner {
|
||||||
|
|
||||||
|
/** 已知的弱默认密码,启动时拒绝。 */
|
||||||
|
private static final Set<String> BLOCKED_DEFAULT_PASSWORDS = Set.of(
|
||||||
|
"datamate123", "neo4j", "password", "123456", "admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 仅识别「已存在」类错误消息的关键词,其余错误不应吞掉。 */
|
||||||
|
private static final Set<String> ALREADY_EXISTS_KEYWORDS = Set.of(
|
||||||
|
"already exists", "already exist", "EquivalentSchemaRuleAlreadyExists"
|
||||||
|
);
|
||||||
|
|
||||||
private final Neo4jClient neo4jClient;
|
private final Neo4jClient neo4jClient;
|
||||||
private final KnowledgeGraphProperties properties;
|
private final KnowledgeGraphProperties properties;
|
||||||
|
|
||||||
|
@Value("${spring.neo4j.authentication.password:}")
|
||||||
|
private String neo4jPassword;
|
||||||
|
|
||||||
|
@Value("${spring.profiles.active:default}")
|
||||||
|
private String activeProfile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需要在启动时执行的 Cypher 语句。
|
* 需要在启动时执行的 Cypher 语句。
|
||||||
* 每条语句必须独立执行(Neo4j 不支持多条 DDL 在同一事务中)。
|
* 每条语句必须独立执行(Neo4j 不支持多条 DDL 在同一事务中)。
|
||||||
@@ -57,6 +77,9 @@ public class GraphInitializer implements ApplicationRunner {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
|
// ── 安全自检:默认凭据检测 ──
|
||||||
|
validateCredentials();
|
||||||
|
|
||||||
if (!properties.getSync().isAutoInitSchema()) {
|
if (!properties.getSync().isAutoInitSchema()) {
|
||||||
log.info("Schema auto-init is disabled, skipping");
|
log.info("Schema auto-init is disabled, skipping");
|
||||||
return;
|
return;
|
||||||
@@ -73,17 +96,59 @@ public class GraphInitializer implements ApplicationRunner {
|
|||||||
succeeded++;
|
succeeded++;
|
||||||
log.debug("Schema statement executed: {}", truncate(statement));
|
log.debug("Schema statement executed: {}", truncate(statement));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (isAlreadyExistsError(e)) {
|
||||||
|
// 约束/索引已存在,安全跳过
|
||||||
|
succeeded++;
|
||||||
|
log.debug("Schema element already exists (safe to skip): {}", truncate(statement));
|
||||||
|
} else {
|
||||||
|
// 非「已存在」错误:记录并抛出,阻止启动
|
||||||
failed++;
|
failed++;
|
||||||
// 约束/索引可能已存在(不同于 IF NOT EXISTS 的实现版本),
|
log.error("Schema statement FAILED: {} — {}", truncate(statement), e.getMessage());
|
||||||
// 记录警告但不中断启动。
|
throw new IllegalStateException(
|
||||||
log.warn("Schema statement failed (may already exist): {} — {}",
|
"Neo4j schema initialization failed: " + truncate(statement), e);
|
||||||
truncate(statement), e.getMessage());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Neo4j schema initialization completed: succeeded={}, failed={}", succeeded, failed);
|
log.info("Neo4j schema initialization completed: succeeded={}, failed={}", succeeded, failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否使用了默认凭据。
|
||||||
|
* <p>
|
||||||
|
* 在 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) {
|
private static String truncate(String s) {
|
||||||
return s.length() <= 100 ? s : s.substring(0, 97) + "...";
|
return s.length() <= 100 ? s : s.substring(0, 97) + "...";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.datamate.knowledgegraph.infrastructure.neo4j;
|
package com.datamate.knowledgegraph.infrastructure.neo4j;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
|
@Validated
|
||||||
@ConfigurationProperties(prefix = "datamate.knowledge-graph")
|
@ConfigurationProperties(prefix = "datamate.knowledge-graph")
|
||||||
public class KnowledgeGraphProperties {
|
public class KnowledgeGraphProperties {
|
||||||
|
|
||||||
@@ -15,7 +18,8 @@ public class KnowledgeGraphProperties {
|
|||||||
/** 子图返回最大节点数 */
|
/** 子图返回最大节点数 */
|
||||||
private int maxNodesPerQuery = 500;
|
private int maxNodesPerQuery = 500;
|
||||||
|
|
||||||
/** 批量导入批次大小 */
|
/** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */
|
||||||
|
@Min(value = 1, message = "importBatchSize 必须 >= 1")
|
||||||
private int importBatchSize = 100;
|
private int importBatchSize = 100;
|
||||||
|
|
||||||
/** 同步相关配置 */
|
/** 同步相关配置 */
|
||||||
@@ -44,5 +48,13 @@ public class KnowledgeGraphProperties {
|
|||||||
|
|
||||||
/** 是否在启动时自动初始化 Schema */
|
/** 是否在启动时自动初始化 Schema */
|
||||||
private boolean autoInitSchema = true;
|
private boolean autoInitSchema = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许空快照触发 purge(默认 false)。
|
||||||
|
* <p>
|
||||||
|
* 当上游返回空列表时,如果该开关为 false,purge 将被跳过以防误删全部同步实体。
|
||||||
|
* 仅在确认数据源确实为空时才应开启此开关。
|
||||||
|
*/
|
||||||
|
private boolean allowPurgeOnEmptySnapshot = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。
|
* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。
|
||||||
* 生产环境中也可通过定时任务自动触发。
|
* 生产环境中也可通过定时任务自动触发。
|
||||||
|
* <p>
|
||||||
|
* <b>安全说明</b>:本接口仅供内部服务调用(API Gateway / 定时任务),
|
||||||
|
* 外部请求必须经 API Gateway 鉴权后转发。
|
||||||
|
* 生产环境建议通过 mTLS 或内部 JWT 进一步加固服务间认证。
|
||||||
|
* 当前通过 {@code X-Internal-Token} 请求头进行简单的内部调用校验。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/knowledge-graph/{graphId}/sync")
|
@RequestMapping("/knowledge-graph/{graphId}/sync")
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ datamate:
|
|||||||
retry-interval: ${KG_SYNC_RETRY_INTERVAL:1000}
|
retry-interval: ${KG_SYNC_RETRY_INTERVAL:1000}
|
||||||
# 是否在启动时自动初始化 Schema
|
# 是否在启动时自动初始化 Schema
|
||||||
auto-init-schema: ${KG_AUTO_INIT_SCHEMA:true}
|
auto-init-schema: ${KG_AUTO_INIT_SCHEMA:true}
|
||||||
|
# 是否允许空快照触发 purge(默认 false,防止上游返回空列表时误删全部同步实体)
|
||||||
|
allow-purge-on-empty-snapshot: ${KG_ALLOW_PURGE_ON_EMPTY_SNAPSHOT:false}
|
||||||
|
|||||||
@@ -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<GraphEntity> 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<GraphEntity> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncResult> 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("拉取数据集失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import SecretStr, model_validator
|
from pydantic import SecretStr, model_validator
|
||||||
from typing import Optional
|
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):
|
class Settings(BaseSettings):
|
||||||
"""应用程序配置"""
|
"""应用程序配置"""
|
||||||
@@ -76,5 +85,37 @@ class Settings(BaseSettings):
|
|||||||
# 标注编辑器(Label Studio Editor)相关
|
# 标注编辑器(Label Studio Editor)相关
|
||||||
editor_max_text_bytes: int = 0 # <=0 表示不限制,正数为最大字节数
|
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()
|
settings = Settings()
|
||||||
|
|||||||
Reference in New Issue
Block a user