feat(kg): 实现所有路径查询和子图导出功能

- 新增 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
This commit is contained in:
2026-02-19 15:46:01 +08:00
parent 20446bf57d
commit cca463e7d1
9 changed files with 843 additions and 7 deletions

View File

@@ -11,18 +11,24 @@ import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties
import com.datamate.knowledgegraph.interfaces.dto.*; import com.datamate.knowledgegraph.interfaces.dto.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.Value;
import org.neo4j.driver.types.MapAccessor; import org.neo4j.driver.types.MapAccessor;
import org.springframework.data.neo4j.core.Neo4jClient; import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.*; import java.util.*;
import java.util.function.Function;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 知识图谱查询服务。 * 知识图谱查询服务。
* <p> * <p>
* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。 * 提供图遍历(N 跳邻居、最短路径、所有路径、子图提取、子图导出)和全文搜索功能。
* 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。 * 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。
* <p> * <p>
* 查询结果根据用户权限进行过滤: * 查询结果根据用户权限进行过滤:
@@ -48,6 +54,7 @@ public class GraphQueryService {
); );
private final Neo4jClient neo4jClient; private final Neo4jClient neo4jClient;
private final Driver neo4jDriver;
private final GraphEntityRepository entityRepository; private final GraphEntityRepository entityRepository;
private final KnowledgeGraphProperties properties; private final KnowledgeGraphProperties properties;
private final ResourceAccessService resourceAccessService; private final ResourceAccessService resourceAccessService;
@@ -225,6 +232,7 @@ public class GraphQueryService {
" (t:Entity {graph_id: $graphId, id: $targetId}), " + " (t:Entity {graph_id: $graphId, id: $targetId}), " +
" path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " + " path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " +
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " + "WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
" AND ALL(r IN relationships(path) WHERE r.graph_id = $graphId) " +
permFilter + permFilter +
"RETURN " + "RETURN " +
" [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " + " [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()); .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<String, Object> 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<PathVO> paths = queryWithTimeout(cypher, params, record -> mapPathRecord(record));
return AllPathsVO.builder()
.paths(paths)
.pathCount(paths.size())
.build();
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 子图提取 // 子图提取
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -313,6 +421,140 @@ public class GraphQueryService {
.build(); .build();
} }
// -----------------------------------------------------------------------
// 子图导出
// -----------------------------------------------------------------------
/**
* 导出指定实体集合的子图,支持深度扩展。
*
* @param entityIds 种子实体 ID 列表
* @param depth 扩展深度(0=仅种子实体,1=含 1 跳邻居,以此类推)
* @return 包含完整属性的子图导出结果
*/
public SubgraphExportVO exportSubgraph(String graphId, List<String> 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<GraphEntity> entities;
if (clampedDepth == 0) {
// 仅种子实体
entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds);
} else {
// 扩展邻居:先查询扩展后的节点 ID 集合
Set<String> 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<ExportNodeVO> 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<String> nodeIds = entities.stream().map(GraphEntity::getId).toList();
List<ExportEdgeVO> 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<graphml xmlns=\"http://graphml.graphstruct.org/graphml\"\n");
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.append(" xsi:schemaLocation=\"http://graphml.graphstruct.org/graphml ");
xml.append("http://graphml.graphstruct.org/xmlns/1.0/graphml.xsd\">\n");
// Key 定义
xml.append(" <key id=\"name\" for=\"node\" attr.name=\"name\" attr.type=\"string\"/>\n");
xml.append(" <key id=\"type\" for=\"node\" attr.name=\"type\" attr.type=\"string\"/>\n");
xml.append(" <key id=\"description\" for=\"node\" attr.name=\"description\" attr.type=\"string\"/>\n");
xml.append(" <key id=\"relationType\" for=\"edge\" attr.name=\"relationType\" attr.type=\"string\"/>\n");
xml.append(" <key id=\"weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>\n");
xml.append(" <graph id=\"G\" edgedefault=\"directed\">\n");
// 节点
if (exportVO.getNodes() != null) {
for (ExportNodeVO node : exportVO.getNodes()) {
xml.append(" <node id=\"").append(escapeXml(node.getId())).append("\">\n");
appendGraphMLData(xml, "name", node.getName());
appendGraphMLData(xml, "type", node.getType());
appendGraphMLData(xml, "description", node.getDescription());
xml.append(" </node>\n");
}
}
// 边
if (exportVO.getEdges() != null) {
for (ExportEdgeVO edge : exportVO.getEdges()) {
xml.append(" <edge id=\"").append(escapeXml(edge.getId()))
.append("\" source=\"").append(escapeXml(edge.getSourceEntityId()))
.append("\" target=\"").append(escapeXml(edge.getTargetEntityId()))
.append("\">\n");
appendGraphMLData(xml, "relationType", edge.getRelationType());
if (edge.getWeight() != null) {
appendGraphMLData(xml, "weight", String.valueOf(edge.getWeight()));
}
xml.append(" </edge>\n");
}
}
xml.append(" </graph>\n");
xml.append("</graphml>\n");
return xml.toString();
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 全文搜索 // 全文搜索
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -581,9 +823,159 @@ public class GraphQueryService {
return (v == null || v.isNull()) ? null : v.asDouble(); return (v == null || v.isNull()) ? null : v.asDouble();
} }
/**
* 查询指定节点集合之间的所有边(导出用,包含完整属性)。
*/
private List<ExportEdgeVO> queryExportEdgesBetween(String graphId, List<String> 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(含种子)。
* <p>
* 使用事务超时保护,防止深度扩展导致组合爆炸。
* 结果总数严格不超过 maxNodes(含种子节点)。
*/
private Set<String> expandNeighborIds(String graphId, List<String> 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<String, Object> 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<String> 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(" <data key=\"").append(key).append("\">")
.append(escapeXml(value))
.append("</data>\n");
}
}
private static String escapeXml(String text) {
if (text == null) {
return "";
}
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
private void validateGraphId(String graphId) { private void validateGraphId(String graphId) {
if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) { if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) {
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效"); throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效");
} }
} }
/**
* 使用 Neo4j Driver 直接执行查询,附带事务级超时保护。
* <p>
* 用于路径枚举等可能触发组合爆炸的高开销查询,
* 超时后 Neo4j 服务端会主动终止事务,避免资源耗尽。
*/
private <T> List<T> queryWithTimeout(String cypher, Map<String, Object> params,
Function<Record, T> 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<T> 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;
}
} }

View File

@@ -23,7 +23,8 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"), EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"), SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"), 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 code;
private final String message; private final String message;

View File

@@ -18,6 +18,10 @@ public class KnowledgeGraphProperties {
/** 子图返回最大节点数 */ /** 子图返回最大节点数 */
private int maxNodesPerQuery = 500; private int maxNodesPerQuery = 500;
/** 复杂图查询超时(秒),防止路径枚举等高开销查询失控 */
@Min(value = 1, message = "queryTimeoutSeconds 必须 >= 1")
private int queryTimeoutSeconds = 10;
/** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */ /** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */
@Min(value = 1, message = "importBatchSize 必须 >= 1") @Min(value = 1, message = "importBatchSize 必须 >= 1")
private int importBatchSize = 100; private int importBatchSize = 100;

View File

@@ -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<PathVO> paths;
/** 路径总数 */
private int pathCount;
}

View File

@@ -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;
}

View File

@@ -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<String, Object> properties;
}

View File

@@ -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<ExportNodeVO> nodes;
/** 子图中的边列表 */
private List<ExportEdgeVO> edges;
/** 节点数量 */
private int nodeCount;
/** 边数量 */
private int edgeCount;
}

View File

@@ -2,20 +2,19 @@ package com.datamate.knowledgegraph.interfaces.rest;
import com.datamate.common.interfaces.PagedResponse; import com.datamate.common.interfaces.PagedResponse;
import com.datamate.knowledgegraph.application.GraphQueryService; import com.datamate.knowledgegraph.application.GraphQueryService;
import com.datamate.knowledgegraph.interfaces.dto.PathVO; import com.datamate.knowledgegraph.interfaces.dto.*;
import com.datamate.knowledgegraph.interfaces.dto.SearchHitVO;
import com.datamate.knowledgegraph.interfaces.dto.SubgraphRequest;
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/** /**
* 知识图谱查询接口。 * 知识图谱查询接口。
* <p> * <p>
* 提供图遍历(邻居、最短路径、子图)和全文搜索功能。 * 提供图遍历(邻居、最短路径、所有路径、子图、子图导出)和全文搜索功能。
*/ */
@RestController @RestController
@RequestMapping("/knowledge-graph/{graphId}/query") @RequestMapping("/knowledge-graph/{graphId}/query")
@@ -56,6 +55,21 @@ public class GraphQueryController {
return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth); return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth);
} }
/**
* 查询两个实体之间的所有路径。
* <p>
* 返回按路径长度升序排列的所有路径,支持最大深度和最大路径数限制。
*/
@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()); return queryService.getSubgraph(graphId, request.getEntityIds());
} }
/**
* 导出指定实体集合的子图。
* <p>
* 支持深度扩展和多种输出格式(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);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 全文搜索 // 全文搜索
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -5,6 +5,8 @@ import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.knowledgegraph.domain.model.GraphEntity; import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties; 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 com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@@ -13,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.neo4j.driver.Driver;
import org.springframework.data.neo4j.core.Neo4jClient; import org.springframework.data.neo4j.core.Neo4jClient;
import java.util.HashMap; import java.util.HashMap;
@@ -36,6 +39,9 @@ class GraphQueryServiceTest {
@Mock @Mock
private Neo4jClient neo4jClient; private Neo4jClient neo4jClient;
@Mock
private Driver neo4jDriver;
@Mock @Mock
private GraphEntityRepository entityRepository; private GraphEntityRepository entityRepository;
@@ -594,4 +600,295 @@ class GraphQueryServiceTest {
assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS"); 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<String> 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
assertThat(graphml).contains("<graphml");
assertThat(graphml).contains("<graph id=\"G\" edgedefault=\"directed\">");
assertThat(graphml).contains("</graphml>");
}
@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("<node id=\"node-1\">");
assertThat(graphml).contains("<data key=\"name\">Dataset A</data>");
assertThat(graphml).contains("<data key=\"type\">Dataset</data>");
assertThat(graphml).contains("<data key=\"description\">Test dataset</data>");
assertThat(graphml).contains("<node id=\"node-2\">");
assertThat(graphml).contains("<data key=\"type\">Workflow</data>");
// null description 不输出
assertThat(graphml).doesNotContain("<data key=\"description\">null</data>");
assertThat(graphml).contains("<edge id=\"edge-1\" source=\"node-1\" target=\"node-2\">");
assertThat(graphml).contains("<data key=\"relationType\">DERIVED_FROM</data>");
assertThat(graphml).contains("<data key=\"weight\">0.8</data>");
}
@Test
void convertToGraphML_specialCharactersEscaped() {
SubgraphExportVO export = SubgraphExportVO.builder()
.nodes(List.of(
com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder()
.id("node-1").name("A & B <Corp>").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 &amp; B &lt;Corp&gt;");
assertThat(graphml).contains("&quot;Test&quot; org");
}
}
} }