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