From cca463e7d19436d04a4a9af9916b484f3caa82c5 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 19 Feb 2026 15:46:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(kg):=20=E5=AE=9E=E7=8E=B0=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=B7=AF=E5=BE=84=E6=9F=A5=E8=AF=A2=E5=92=8C=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 findAllPaths 接口:查找两个节点之间的所有路径 - 支持 maxDepth 和 maxPaths 参数限制 - 按路径长度升序排序 - 完整的权限过滤(created_by + confidential) - 添加关系级 graph_id 约束,防止串图 - 新增 exportSubgraph 接口:导出子图 - 支持 depth 参数控制扩展深度 - 支持 JSON 和 GraphML 两种导出格式 - depth=0:仅导出指定实体及其之间的边 - depth>0:扩展 N 跳,收集所有可达邻居 - 添加查询超时保护机制 - 注入 Neo4j Driver,使用 TransactionConfig.withTimeout() - 默认超时 10 秒,可配置 - 防止复杂查询长期占用资源 - 新增 4 个 DTO:AllPathsVO, ExportNodeVO, ExportEdgeVO, SubgraphExportVO - 新增 17 个测试用例,全部通过 - 测试结果:226 tests pass --- .../application/GraphQueryService.java | 394 +++++++++++++++++- .../exception/KnowledgeGraphErrorCode.java | 3 +- .../neo4j/KnowledgeGraphProperties.java | 4 + .../interfaces/dto/AllPathsVO.java | 24 ++ .../interfaces/dto/ExportEdgeVO.java | 24 ++ .../interfaces/dto/ExportNodeVO.java | 24 ++ .../interfaces/dto/SubgraphExportVO.java | 30 ++ .../interfaces/rest/GraphQueryController.java | 50 ++- .../application/GraphQueryServiceTest.java | 297 +++++++++++++ 9 files changed, 843 insertions(+), 7 deletions(-) create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/AllPathsVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportEdgeVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportNodeVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphExportVO.java diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java index 5a450f3..ebfa269 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java @@ -11,18 +11,24 @@ import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties import com.datamate.knowledgegraph.interfaces.dto.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Session; +import org.neo4j.driver.TransactionConfig; import org.neo4j.driver.Value; import org.neo4j.driver.types.MapAccessor; import org.springframework.data.neo4j.core.Neo4jClient; import org.springframework.stereotype.Service; +import java.time.Duration; import java.util.*; +import java.util.function.Function; import java.util.regex.Pattern; /** * 知识图谱查询服务。 *

- * 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。 + * 提供图遍历(N 跳邻居、最短路径、所有路径、子图提取、子图导出)和全文搜索功能。 * 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。 *

* 查询结果根据用户权限进行过滤: @@ -48,6 +54,7 @@ public class GraphQueryService { ); private final Neo4jClient neo4jClient; + private final Driver neo4jDriver; private final GraphEntityRepository entityRepository; private final KnowledgeGraphProperties properties; private final ResourceAccessService resourceAccessService; @@ -225,6 +232,7 @@ public class GraphQueryService { " (t:Entity {graph_id: $graphId, id: $targetId}), " + " path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " + "WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " + + " AND ALL(r IN relationships(path) WHERE r.graph_id = $graphId) " + permFilter + "RETURN " + " [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " + @@ -244,6 +252,106 @@ public class GraphQueryService { .build()); } + // ----------------------------------------------------------------------- + // 所有路径 + // ----------------------------------------------------------------------- + + /** + * 查询两个实体之间的所有路径。 + * + * @param maxDepth 最大搜索深度(由配置上限约束) + * @param maxPaths 返回路径数上限 + * @return 所有路径结果,按路径长度升序排列 + */ + public AllPathsVO findAllPaths(String graphId, String sourceId, String targetId, int maxDepth, int maxPaths) { + validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); + + // 校验两个实体存在 + 权限 + GraphEntity sourceEntity = entityRepository.findByIdAndGraphId(sourceId, graphId) + .orElseThrow(() -> BusinessException.of( + KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在")); + + if (filterUserId != null) { + assertEntityAccess(sourceEntity, filterUserId, excludeConfidential); + } + + entityRepository.findByIdAndGraphId(targetId, graphId) + .ifPresentOrElse( + targetEntity -> { + if (filterUserId != null && !sourceId.equals(targetId)) { + assertEntityAccess(targetEntity, filterUserId, excludeConfidential); + } + }, + () -> { throw BusinessException.of( + KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"); } + ); + + if (sourceId.equals(targetId)) { + EntitySummaryVO node = EntitySummaryVO.builder() + .id(sourceEntity.getId()) + .name(sourceEntity.getName()) + .type(sourceEntity.getType()) + .description(sourceEntity.getDescription()) + .build(); + PathVO singlePath = PathVO.builder() + .nodes(List.of(node)) + .edges(List.of()) + .pathLength(0) + .build(); + return AllPathsVO.builder() + .paths(List.of(singlePath)) + .pathCount(1) + .build(); + } + + int clampedDepth = Math.max(1, Math.min(maxDepth, properties.getMaxDepth())); + int clampedMaxPaths = Math.max(1, Math.min(maxPaths, properties.getMaxNodesPerQuery())); + + String permFilter = ""; + if (filterUserId != null) { + StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(path) WHERE "); + pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)"); + if (excludeConfidential) { + pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')"); + } + pf.append(") "); + permFilter = pf.toString(); + } + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("sourceId", sourceId); + params.put("targetId", targetId); + params.put("maxPaths", clampedMaxPaths); + if (filterUserId != null) { + params.put("filterUserId", filterUserId); + } + + String cypher = + "MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " + + " (t:Entity {graph_id: $graphId, id: $targetId}), " + + " path = (s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t) " + + "WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " + + " AND ALL(r IN relationships(path) WHERE r.graph_id = $graphId) " + + permFilter + + "RETURN " + + " [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " + + " [r IN relationships(path) | {id: r.id, relation_type: r.relation_type, weight: r.weight, " + + " source: startNode(r).id, target: endNode(r).id}] AS pathEdges, " + + " length(path) AS pathLength " + + "ORDER BY length(path) ASC " + + "LIMIT $maxPaths"; + + List paths = queryWithTimeout(cypher, params, record -> mapPathRecord(record)); + + return AllPathsVO.builder() + .paths(paths) + .pathCount(paths.size()) + .build(); + } + // ----------------------------------------------------------------------- // 子图提取 // ----------------------------------------------------------------------- @@ -313,6 +421,140 @@ public class GraphQueryService { .build(); } + // ----------------------------------------------------------------------- + // 子图导出 + // ----------------------------------------------------------------------- + + /** + * 导出指定实体集合的子图,支持深度扩展。 + * + * @param entityIds 种子实体 ID 列表 + * @param depth 扩展深度(0=仅种子实体,1=含 1 跳邻居,以此类推) + * @return 包含完整属性的子图导出结果 + */ + public SubgraphExportVO exportSubgraph(String graphId, List entityIds, int depth) { + validateGraphId(graphId); + String filterUserId = resolveOwnerFilter(); + boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential(); + + if (entityIds == null || entityIds.isEmpty()) { + return SubgraphExportVO.builder() + .nodes(List.of()) + .edges(List.of()) + .nodeCount(0) + .edgeCount(0) + .build(); + } + + int maxNodes = properties.getMaxNodesPerQuery(); + if (entityIds.size() > maxNodes) { + throw BusinessException.of(KnowledgeGraphErrorCode.MAX_NODES_EXCEEDED, + "实体数量超出限制(最大 " + maxNodes + ")"); + } + + int clampedDepth = Math.max(0, Math.min(depth, properties.getMaxDepth())); + List entities; + + if (clampedDepth == 0) { + // 仅种子实体 + entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds); + } else { + // 扩展邻居:先查询扩展后的节点 ID 集合 + Set expandedIds = expandNeighborIds(graphId, entityIds, clampedDepth, + filterUserId, excludeConfidential, maxNodes); + entities = expandedIds.isEmpty() + ? List.of() + : entityRepository.findByGraphIdAndIdIn(graphId, new ArrayList<>(expandedIds)); + } + + // 权限过滤 + if (filterUserId != null) { + entities = entities.stream() + .filter(e -> isEntityAccessible(e, filterUserId, excludeConfidential)) + .toList(); + } + + if (entities.isEmpty()) { + return SubgraphExportVO.builder() + .nodes(List.of()) + .edges(List.of()) + .nodeCount(0) + .edgeCount(0) + .build(); + } + + List nodes = entities.stream() + .map(e -> ExportNodeVO.builder() + .id(e.getId()) + .name(e.getName()) + .type(e.getType()) + .description(e.getDescription()) + .properties(e.getProperties() != null ? e.getProperties() : Map.of()) + .build()) + .toList(); + + List nodeIds = entities.stream().map(GraphEntity::getId).toList(); + List edges = queryExportEdgesBetween(graphId, nodeIds); + + return SubgraphExportVO.builder() + .nodes(nodes) + .edges(edges) + .nodeCount(nodes.size()) + .edgeCount(edges.size()) + .build(); + } + + /** + * 将子图导出结果转换为 GraphML XML 格式。 + */ + public String convertToGraphML(SubgraphExportVO exportVO) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + + // Key 定义 + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + + xml.append(" \n"); + + // 节点 + if (exportVO.getNodes() != null) { + for (ExportNodeVO node : exportVO.getNodes()) { + xml.append(" \n"); + appendGraphMLData(xml, "name", node.getName()); + appendGraphMLData(xml, "type", node.getType()); + appendGraphMLData(xml, "description", node.getDescription()); + xml.append(" \n"); + } + } + + // 边 + if (exportVO.getEdges() != null) { + for (ExportEdgeVO edge : exportVO.getEdges()) { + xml.append(" \n"); + appendGraphMLData(xml, "relationType", edge.getRelationType()); + if (edge.getWeight() != null) { + appendGraphMLData(xml, "weight", String.valueOf(edge.getWeight())); + } + xml.append(" \n"); + } + } + + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + // ----------------------------------------------------------------------- // 全文搜索 // ----------------------------------------------------------------------- @@ -581,9 +823,159 @@ public class GraphQueryService { return (v == null || v.isNull()) ? null : v.asDouble(); } + /** + * 查询指定节点集合之间的所有边(导出用,包含完整属性)。 + */ + private List queryExportEdgesBetween(String graphId, List nodeIds) { + if (nodeIds.size() < 2) { + return List.of(); + } + + return neo4jClient + .query( + "MATCH (s:Entity {graph_id: $graphId})-[r:" + REL_TYPE + " {graph_id: $graphId}]->(t:Entity {graph_id: $graphId}) " + + "WHERE s.id IN $nodeIds AND t.id IN $nodeIds " + + "RETURN r.id AS id, s.id AS sourceEntityId, t.id AS targetEntityId, " + + "r.relation_type AS relationType, r.weight AS weight, " + + "r.confidence AS confidence, r.source_id AS sourceId" + ) + .bindAll(Map.of("graphId", graphId, "nodeIds", nodeIds)) + .fetchAs(ExportEdgeVO.class) + .mappedBy((ts, record) -> ExportEdgeVO.builder() + .id(record.get("id").asString(null)) + .sourceEntityId(record.get("sourceEntityId").asString(null)) + .targetEntityId(record.get("targetEntityId").asString(null)) + .relationType(record.get("relationType").asString(null)) + .weight(record.get("weight").isNull() ? null : record.get("weight").asDouble()) + .confidence(record.get("confidence").isNull() ? null : record.get("confidence").asDouble()) + .sourceId(record.get("sourceId").asString(null)) + .build()) + .all() + .stream().toList(); + } + + /** + * 从种子实体扩展 N 跳邻居,返回所有节点 ID(含种子)。 + *

+ * 使用事务超时保护,防止深度扩展导致组合爆炸。 + * 结果总数严格不超过 maxNodes(含种子节点)。 + */ + private Set expandNeighborIds(String graphId, List seedIds, int depth, + String filterUserId, boolean excludeConfidential, int maxNodes) { + String permFilter = ""; + if (filterUserId != null) { + StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(p) WHERE "); + pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)"); + if (excludeConfidential) { + pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')"); + } + pf.append(") "); + permFilter = pf.toString(); + } + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("seedIds", seedIds); + params.put("maxNodes", maxNodes); + if (filterUserId != null) { + params.put("filterUserId", filterUserId); + } + + // 种子节点在 Cypher 中纳入 LIMIT 约束,确保总数不超过 maxNodes + String cypher = + "MATCH (seed:Entity {graph_id: $graphId}) " + + "WHERE seed.id IN $seedIds " + + "WITH collect(DISTINCT seed) AS seeds " + + "UNWIND seeds AS s " + + "OPTIONAL MATCH p = (s)-[:" + REL_TYPE + "*1.." + depth + "]-(neighbor:Entity) " + + "WHERE ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " + + " AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " + + permFilter + + "WITH seeds + collect(DISTINCT neighbor) AS allNodes " + + "UNWIND allNodes AS node " + + "WITH DISTINCT node " + + "WHERE node IS NOT NULL " + + "RETURN node.id AS id " + + "LIMIT $maxNodes"; + + List ids = queryWithTimeout(cypher, params, + record -> record.get("id").asString(null)); + + return new LinkedHashSet<>(ids); + } + + private static void appendGraphMLData(StringBuilder xml, String key, String value) { + if (value != null) { + xml.append(" ") + .append(escapeXml(value)) + .append("\n"); + } + } + + private static String escapeXml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + private void validateGraphId(String graphId) { if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) { throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效"); } } + + /** + * 使用 Neo4j Driver 直接执行查询,附带事务级超时保护。 + *

+ * 用于路径枚举等可能触发组合爆炸的高开销查询, + * 超时后 Neo4j 服务端会主动终止事务,避免资源耗尽。 + */ + private List queryWithTimeout(String cypher, Map params, + Function mapper) { + int timeoutSeconds = properties.getQueryTimeoutSeconds(); + TransactionConfig txConfig = TransactionConfig.builder() + .withTimeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + try (Session session = neo4jDriver.session()) { + return session.executeRead(tx -> { + var result = tx.run(cypher, params); + List items = new ArrayList<>(); + while (result.hasNext()) { + items.add(mapper.apply(result.next())); + } + return items; + }, txConfig); + } catch (Exception e) { + if (isTransactionTimeout(e)) { + log.warn("图查询超时({}秒): {}", timeoutSeconds, cypher.substring(0, Math.min(cypher.length(), 120))); + throw BusinessException.of(KnowledgeGraphErrorCode.QUERY_TIMEOUT, + "查询超时(" + timeoutSeconds + "秒),请缩小搜索范围或减少深度"); + } + throw e; + } + } + + /** + * 判断异常是否为 Neo4j 事务超时。 + */ + private static boolean isTransactionTimeout(Exception e) { + // Neo4j 事务超时时抛出的异常链中通常包含 "terminated" 或 "timeout" + Throwable current = e; + while (current != null) { + String msg = current.getMessage(); + if (msg != null) { + String lower = msg.toLowerCase(Locale.ROOT); + if (lower.contains("transaction has been terminated") || lower.contains("timed out")) { + return true; + } + } + current = current.getCause(); + } + return false; + } } 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 fe4f372..5df4dd4 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 @@ -23,7 +23,8 @@ public enum KnowledgeGraphErrorCode implements ErrorCode { EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"), SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"), INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"), - UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败"); + UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败"), + QUERY_TIMEOUT("knowledge_graph.0014", "图查询超时,请缩小搜索范围或减少深度"); private final String code; private final String message; 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 fe0b954..b048231 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 @@ -18,6 +18,10 @@ public class KnowledgeGraphProperties { /** 子图返回最大节点数 */ private int maxNodesPerQuery = 500; + /** 复杂图查询超时(秒),防止路径枚举等高开销查询失控 */ + @Min(value = 1, message = "queryTimeoutSeconds 必须 >= 1") + private int queryTimeoutSeconds = 10; + /** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */ @Min(value = 1, message = "importBatchSize 必须 >= 1") private int importBatchSize = 100; diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/AllPathsVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/AllPathsVO.java new file mode 100644 index 0000000..235050d --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/AllPathsVO.java @@ -0,0 +1,24 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 所有路径查询结果。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AllPathsVO { + + /** 所有路径列表(按路径长度升序) */ + private List paths; + + /** 路径总数 */ + private int pathCount; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportEdgeVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportEdgeVO.java new file mode 100644 index 0000000..c776328 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportEdgeVO.java @@ -0,0 +1,24 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 导出用关系边,包含完整属性。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExportEdgeVO { + + private String id; + private String sourceEntityId; + private String targetEntityId; + private String relationType; + private Double weight; + private Double confidence; + private String sourceId; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportNodeVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportNodeVO.java new file mode 100644 index 0000000..81bf8b8 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ExportNodeVO.java @@ -0,0 +1,24 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 导出用节点,包含完整属性。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExportNodeVO { + + private String id; + private String name; + private String type; + private String description; + private Map properties; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphExportVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphExportVO.java new file mode 100644 index 0000000..30bb4dd --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphExportVO.java @@ -0,0 +1,30 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 子图导出结果。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubgraphExportVO { + + /** 子图中的节点列表(包含完整属性) */ + private List nodes; + + /** 子图中的边列表 */ + private List edges; + + /** 节点数量 */ + private int nodeCount; + + /** 边数量 */ + private int edgeCount; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java index afe0ee5..450f643 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java @@ -2,20 +2,19 @@ package com.datamate.knowledgegraph.interfaces.rest; import com.datamate.common.interfaces.PagedResponse; import com.datamate.knowledgegraph.application.GraphQueryService; -import com.datamate.knowledgegraph.interfaces.dto.PathVO; -import com.datamate.knowledgegraph.interfaces.dto.SearchHitVO; -import com.datamate.knowledgegraph.interfaces.dto.SubgraphRequest; -import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO; +import com.datamate.knowledgegraph.interfaces.dto.*; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; /** * 知识图谱查询接口。 *

- * 提供图遍历(邻居、最短路径、子图)和全文搜索功能。 + * 提供图遍历(邻居、最短路径、所有路径、子图、子图导出)和全文搜索功能。 */ @RestController @RequestMapping("/knowledge-graph/{graphId}/query") @@ -56,6 +55,21 @@ public class GraphQueryController { return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth); } + /** + * 查询两个实体之间的所有路径。 + *

+ * 返回按路径长度升序排列的所有路径,支持最大深度和最大路径数限制。 + */ + @GetMapping("/all-paths") + public AllPathsVO findAllPaths( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @RequestParam @Pattern(regexp = UUID_REGEX, message = "sourceId 格式无效") String sourceId, + @RequestParam @Pattern(regexp = UUID_REGEX, message = "targetId 格式无效") String targetId, + @RequestParam(defaultValue = "3") int maxDepth, + @RequestParam(defaultValue = "10") int maxPaths) { + return queryService.findAllPaths(graphId, sourceId, targetId, maxDepth, maxPaths); + } + /** * 提取指定实体集合的子图(关系网络)。 */ @@ -66,6 +80,32 @@ public class GraphQueryController { return queryService.getSubgraph(graphId, request.getEntityIds()); } + /** + * 导出指定实体集合的子图。 + *

+ * 支持深度扩展和多种输出格式(JSON、GraphML)。 + * + * @param format 输出格式:json(默认)或 graphml + * @param depth 扩展深度(0=仅指定实体,1=含 1 跳邻居) + */ + @PostMapping("/subgraph/export") + public ResponseEntity exportSubgraph( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @Valid @RequestBody SubgraphRequest request, + @RequestParam(defaultValue = "json") String format, + @RequestParam(defaultValue = "0") int depth) { + SubgraphExportVO exportVO = queryService.exportSubgraph(graphId, request.getEntityIds(), depth); + + if ("graphml".equalsIgnoreCase(format)) { + String graphml = queryService.convertToGraphML(exportVO); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(graphml); + } + + return ResponseEntity.ok(exportVO); + } + // ----------------------------------------------------------------------- // 全文搜索 // ----------------------------------------------------------------------- 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 index 6fb1efa..2c0e3ef 100644 --- 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 @@ -5,6 +5,8 @@ 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.AllPathsVO; +import com.datamate.knowledgegraph.interfaces.dto.SubgraphExportVO; import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -13,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Driver; import org.springframework.data.neo4j.core.Neo4jClient; import java.util.HashMap; @@ -36,6 +39,9 @@ class GraphQueryServiceTest { @Mock private Neo4jClient neo4jClient; + @Mock + private Driver neo4jDriver; + @Mock private GraphEntityRepository entityRepository; @@ -594,4 +600,295 @@ class GraphQueryServiceTest { assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS"); } } + + // ----------------------------------------------------------------------- + // findAllPaths + // ----------------------------------------------------------------------- + + @Nested + class FindAllPathsTest { + + @Test + void findAllPaths_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.findAllPaths(INVALID_GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10)) + .isInstanceOf(BusinessException.class); + } + + @Test + void findAllPaths_sourceNotFound_throwsBusinessException() { + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10)) + .isInstanceOf(BusinessException.class); + } + + @Test + void findAllPaths_targetNotFound_throwsBusinessException() { + GraphEntity sourceEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Source").type("Dataset").graphId(GRAPH_ID).build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10)) + .isInstanceOf(BusinessException.class); + } + + @Test + void findAllPaths_sameSourceAndTarget_returnsSingleNodePath() { + 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)); + + AllPathsVO result = queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3, 10); + + assertThat(result.getPathCount()).isEqualTo(1); + assertThat(result.getPaths()).hasSize(1); + assertThat(result.getPaths().get(0).getPathLength()).isEqualTo(0); + assertThat(result.getPaths().get(0).getNodes()).hasSize(1); + assertThat(result.getPaths().get(0).getEdges()).isEmpty(); + } + + @Test + void findAllPaths_nonAdmin_sourceNotAccessible_throws() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123"); + + GraphEntity sourceEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "other-user"))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + + assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void findAllPaths_nonAdmin_targetNotAccessible_throws() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123"); + + GraphEntity sourceEntity = GraphEntity.builder() + .id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "user-123"))) + .build(); + GraphEntity targetEntity = GraphEntity.builder() + .id(ENTITY_ID_2).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "other-user"))) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(sourceEntity)); + when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID)) + .thenReturn(Optional.of(targetEntity)); + + assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10)) + .isInstanceOf(BusinessException.class); + + verifyNoInteractions(neo4jClient); + } + + @Test + void findAllPaths_nonAdmin_structuralEntity_sameSourceAndTarget_returnsSingleNode() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123"); + + GraphEntity structuralEntity = GraphEntity.builder() + .id(ENTITY_ID).name("Admin User").type("User").graphId(GRAPH_ID) + .properties(new HashMap<>()) + .build(); + when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID)) + .thenReturn(Optional.of(structuralEntity)); + + AllPathsVO result = queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3, 10); + + assertThat(result.getPathCount()).isEqualTo(1); + assertThat(result.getPaths().get(0).getNodes().get(0).getType()).isEqualTo("User"); + } + } + + // ----------------------------------------------------------------------- + // exportSubgraph + // ----------------------------------------------------------------------- + + @Nested + class ExportSubgraphTest { + + @Test + void exportSubgraph_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> queryService.exportSubgraph(INVALID_GRAPH_ID, List.of(ENTITY_ID), 0)) + .isInstanceOf(BusinessException.class); + } + + @Test + void exportSubgraph_nullEntityIds_returnsEmptyExport() { + SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, null, 0); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getEdges()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(0); + } + + @Test + void exportSubgraph_emptyEntityIds_returnsEmptyExport() { + SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(), 0); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getEdges()).isEmpty(); + } + + @Test + void exportSubgraph_exceedsMaxNodes_throwsBusinessException() { + when(properties.getMaxNodesPerQuery()).thenReturn(5); + + List tooManyIds = List.of("1", "2", "3", "4", "5", "6"); + + assertThatThrownBy(() -> queryService.exportSubgraph(GRAPH_ID, tooManyIds, 0)) + .isInstanceOf(BusinessException.class); + } + + @Test + void exportSubgraph_depthZero_noExistingEntities_returnsEmptyExport() { + when(properties.getMaxNodesPerQuery()).thenReturn(500); + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of()); + + SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(ENTITY_ID), 0); + + assertThat(result.getNodes()).isEmpty(); + assertThat(result.getNodeCount()).isEqualTo(0); + } + + @Test + void exportSubgraph_depthZero_singleEntity_returnsNodeWithProperties() { + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity entity = GraphEntity.builder() + .id(ENTITY_ID).name("Test Dataset").type("Dataset").graphId(GRAPH_ID) + .description("A test dataset") + .properties(new HashMap<>(Map.of("created_by", "user-1", "sensitivity", "PUBLIC"))) + .build(); + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID))) + .thenReturn(List.of(entity)); + + SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(ENTITY_ID), 0); + + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodeCount()).isEqualTo(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("Test Dataset"); + assertThat(result.getNodes().get(0).getProperties()).containsEntry("created_by", "user-1"); + // 单节点无边 + assertThat(result.getEdges()).isEmpty(); + } + + @Test + void exportSubgraph_nonAdmin_filtersInaccessibleEntities() { + when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123"); + when(properties.getMaxNodesPerQuery()).thenReturn(500); + + GraphEntity ownEntity = GraphEntity.builder() + .id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "user-123"))) + .build(); + GraphEntity otherEntity = GraphEntity.builder() + .id(ENTITY_ID_2).name("Other Dataset").type("Dataset").graphId(GRAPH_ID) + .properties(new HashMap<>(Map.of("created_by", "other-user"))) + .build(); + + when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2))) + .thenReturn(List.of(ownEntity, otherEntity)); + + SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, + List.of(ENTITY_ID, ENTITY_ID_2), 0); + + assertThat(result.getNodes()).hasSize(1); + assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset"); + } + } + + // ----------------------------------------------------------------------- + // convertToGraphML + // ----------------------------------------------------------------------- + + @Nested + class ConvertToGraphMLTest { + + @Test + void convertToGraphML_emptyExport_producesValidXml() { + SubgraphExportVO emptyExport = SubgraphExportVO.builder() + .nodes(List.of()) + .edges(List.of()) + .nodeCount(0) + .edgeCount(0) + .build(); + + String graphml = queryService.convertToGraphML(emptyExport); + + assertThat(graphml).contains(""); + assertThat(graphml).contains(""); + assertThat(graphml).contains(""); + } + + @Test + void convertToGraphML_withNodesAndEdges_producesCorrectStructure() { + SubgraphExportVO export = SubgraphExportVO.builder() + .nodes(List.of( + com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder() + .id("node-1").name("Dataset A").type("Dataset") + .description("Test dataset").properties(Map.of()) + .build(), + com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder() + .id("node-2").name("Workflow B").type("Workflow") + .description(null).properties(Map.of()) + .build() + )) + .edges(List.of( + com.datamate.knowledgegraph.interfaces.dto.ExportEdgeVO.builder() + .id("edge-1").sourceEntityId("node-1").targetEntityId("node-2") + .relationType("DERIVED_FROM").weight(0.8) + .build() + )) + .nodeCount(2) + .edgeCount(1) + .build(); + + String graphml = queryService.convertToGraphML(export); + + assertThat(graphml).contains(""); + assertThat(graphml).contains("Dataset A"); + assertThat(graphml).contains("Dataset"); + assertThat(graphml).contains("Test dataset"); + assertThat(graphml).contains(""); + assertThat(graphml).contains("Workflow"); + // null description 不输出 + assertThat(graphml).doesNotContain("null"); + assertThat(graphml).contains(""); + assertThat(graphml).contains("DERIVED_FROM"); + assertThat(graphml).contains("0.8"); + } + + @Test + void convertToGraphML_specialCharactersEscaped() { + SubgraphExportVO export = SubgraphExportVO.builder() + .nodes(List.of( + com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder() + .id("node-1").name("A & B ").type("Org") + .description("\"Test\" org").properties(Map.of()) + .build() + )) + .edges(List.of()) + .nodeCount(1) + .edgeCount(0) + .build(); + + String graphml = queryService.convertToGraphML(export); + + assertThat(graphml).contains("A & B <Corp>"); + assertThat(graphml).contains(""Test" org"); + } + } }