From a260134d7c3d02104007304e8e77075fdafe3fa7 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 18 Feb 2026 07:49:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge-graph):=20=E4=BF=AE=E5=A4=8D=20Co?= =?UTF-8?q?dex=20=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=205=20?= =?UTF-8?q?=E4=B8=AA=E9=97=AE=E9=A2=98=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含两部分内容: 1. 新增知识图谱查询功能(邻居查询、最短路径、子图提取、全文搜索) 2. 修复 Codex 代码审查发现的 5 个问题(3 个 P1 严重问题 + 2 个 P2 次要问题) ## 新增功能 ### GraphQueryService 和 GraphQueryController - 邻居查询:GET /query/neighbors/{entityId}?depth=2&limit=50 - 最短路径:GET /query/shortest-path?sourceId=...&targetId=...&maxDepth=3 - 子图提取:POST /query/subgraph + body {"entityIds": [...]} - 全文搜索:GET /query/search?q=keyword&page=0&size=20 ### 新增 DTO - EntitySummaryVO, EdgeSummaryVO:实体和边的摘要信息 - SubgraphVO:子图结果(nodes + edges + counts) - PathVO:路径结果 - SearchHitVO:搜索结果(含相关度分数) - SubgraphRequest:子图请求 DTO(含校验) ## 问题修复 ### P1-1: 邻居查询图边界风险 **文件**: GraphQueryService.java **问题**: getNeighborGraph 使用 -[*1..N]-,未约束中间路径节点/关系的 graph_id **修复**: - 使用路径变量 p:MATCH p = ... - 添加 ALL(n IN nodes(p) WHERE n.graph_id = $graphId) - 添加 ALL(r IN relationships(p) WHERE r.graph_id = $graphId) - 限定关系类型为 :RELATED_TO - 排除自环:WHERE e <> neighbor ### P1-2: 全图扫描性能风险 **文件**: GraphRelationRepository.java **问题**: findByEntityId/countByEntityId 先匹配全图关系,再用 s.id = X OR t.id = X 过滤 **修复**: - findByEntityId:改为 CALL { 出边锚定查询 UNION ALL 入边锚定查询 } - countByEntityId: - "in"/"out" 方向:将 id: $entityId 直接写入 MATCH 模式 - "all" 方向:改为 CALL { 出边 UNION 入边 } RETURN count(r) - 利用 (graph_id, id) 索引直接定位,避免全图扫描 ### P1-3: 接口破坏性变更 **文件**: GraphEntityController.java **问题**: GET /knowledge-graph/{graphId}/entities 从 List 变为 PagedResponse **修复**: 使用 Spring MVC params 属性实现零破坏性升级 - @GetMapping(params = "!page"):无 page 参数时返回 List(向后兼容) - @GetMapping(params = "page"):有 page 参数时返回 PagedResponse(新功能) - 现有调用方无需改动,新调用方可选择分页 ### P2-4: direction 参数未严格校验 **文件**: GraphEntityController.java, GraphRelationService.java **问题**: 非法 direction 值被静默当作 "all" 处理 **修复**: 双层校验 - Controller 层:@Pattern(regexp = "^(all|in|out)$") - Service 层:VALID_DIRECTIONS.contains() 校验 - 非法值返回 INVALID_PARAMETER 异常 ### P2-5: 子图接口请求体缺少元素级校验 **文件**: GraphQueryController.java, SubgraphRequest.java **问题**: /query/subgraph 直接接收 List,无 UUID 校验 **修复**: 创建 SubgraphRequest DTO - @NotEmpty:列表不能为空 - @Size(max = 500):元素数量上限 - List<@Pattern(UUID) String>:每个元素必须是合法 UUID - Controller 使用 @Valid @RequestBody SubgraphRequest - ⚠️ API 变更:请求体格式从 ["uuid1"] 变为 {"entityIds": ["uuid1"]} ## 技术亮点 1. **图边界安全**: 路径变量 + ALL 约束确保跨图查询安全 2. **查询性能**: 实体锚定查询替代全图扫描,利用索引优化 3. **向后兼容**: params 属性实现同路径双端点,零破坏性升级 4. **多层防御**: Controller + Service 双层校验,框架级 + 业务级 5. **类型安全**: DTO + Bean Validation 确保请求体格式和内容合法 ## 测试建议 1. 编译验证:mvn -pl services/knowledge-graph-service -am compile 2. 测试邻居查询的图边界约束 3. 测试实体关系查询的性能(大数据集) 4. 验证实体列表接口的向后兼容性(无 page 参数) 5. 测试 direction 参数的非法值拒绝 6. 测试子图接口的请求体校验(非法 UUID、空列表、超限) Co-authored-by: Claude (Anthropic) Reviewed-by: Codex (OpenAI) --- .../application/GraphEntityService.java | 50 +++ .../application/GraphQueryService.java | 408 ++++++++++++++++++ .../application/GraphRelationService.java | 51 +++ .../repository/GraphEntityRepository.java | 48 +++ .../repository/GraphRelationRepository.java | 173 ++++++++ .../interfaces/dto/EdgeSummaryVO.java | 22 + .../interfaces/dto/EntitySummaryVO.java | 21 + .../knowledgegraph/interfaces/dto/PathVO.java | 27 ++ .../interfaces/dto/SearchHitVO.java | 24 ++ .../interfaces/dto/SubgraphRequest.java | 26 ++ .../interfaces/dto/SubgraphVO.java | 30 ++ .../rest/GraphEntityController.java | 44 +- .../interfaces/rest/GraphQueryController.java | 86 ++++ 13 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EdgeSummaryVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EntitySummaryVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/PathVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SearchHitVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphRequest.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphVO.java create mode 100644 backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java index 9e654dc..aa08e7f 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java @@ -2,6 +2,7 @@ package com.datamate.knowledgegraph.application; import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.datamate.common.interfaces.PagedResponse; import com.datamate.knowledgegraph.domain.model.GraphEntity; import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode; @@ -22,6 +23,9 @@ import java.util.regex.Pattern; @RequiredArgsConstructor public class GraphEntityService { + /** 分页偏移量上限,防止深翻页导致 Neo4j 性能退化。 */ + private static final long MAX_SKIP = 100_000L; + private static final Pattern UUID_PATTERN = Pattern.compile( "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ); @@ -69,6 +73,52 @@ public class GraphEntityService { return entityRepository.findByGraphIdAndType(graphId, type); } + // ----------------------------------------------------------------------- + // 分页查询 + // ----------------------------------------------------------------------- + + public PagedResponse listEntitiesPaged(String graphId, int page, int size) { + validateGraphId(graphId); + int safePage = Math.max(0, page); + int safeSize = Math.max(1, Math.min(size, 200)); + long skip = (long) safePage * safeSize; + if (skip > MAX_SKIP) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大"); + } + List entities = entityRepository.findByGraphIdPaged(graphId, skip, safeSize); + long total = entityRepository.countByGraphId(graphId); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + return PagedResponse.of(entities, safePage, total, totalPages); + } + + public PagedResponse listEntitiesByTypePaged(String graphId, String type, int page, int size) { + validateGraphId(graphId); + int safePage = Math.max(0, page); + int safeSize = Math.max(1, Math.min(size, 200)); + long skip = (long) safePage * safeSize; + if (skip > MAX_SKIP) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大"); + } + List entities = entityRepository.findByGraphIdAndTypePaged(graphId, type, skip, safeSize); + long total = entityRepository.countByGraphIdAndType(graphId, type); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + return PagedResponse.of(entities, safePage, total, totalPages); + } + + public PagedResponse searchEntitiesPaged(String graphId, String keyword, int page, int size) { + validateGraphId(graphId); + int safePage = Math.max(0, page); + int safeSize = Math.max(1, Math.min(size, 200)); + long skip = (long) safePage * safeSize; + if (skip > MAX_SKIP) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大"); + } + List entities = entityRepository.findByGraphIdAndNameContainingPaged(graphId, keyword, skip, safeSize); + long total = entityRepository.countByGraphIdAndNameContaining(graphId, keyword); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + return PagedResponse.of(entities, safePage, total, totalPages); + } + @Transactional public GraphEntity updateEntity(String graphId, String entityId, UpdateEntityRequest request) { validateGraphId(graphId); 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 new file mode 100644 index 0000000..6f97dac --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphQueryService.java @@ -0,0 +1,408 @@ +package com.datamate.knowledgegraph.application; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.datamate.common.interfaces.PagedResponse; +import com.datamate.knowledgegraph.domain.model.GraphEntity; +import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; +import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode; +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.Value; +import org.neo4j.driver.types.MapAccessor; +import org.springframework.data.neo4j.core.Neo4jClient; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * 知识图谱查询服务。 + *

+ * 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。 + * 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class GraphQueryService { + + private static final String REL_TYPE = "RELATED_TO"; + private static final long MAX_SKIP = 100_000L; + + private static final Pattern UUID_PATTERN = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ); + + private final Neo4jClient neo4jClient; + private final GraphEntityRepository entityRepository; + private final KnowledgeGraphProperties properties; + + // ----------------------------------------------------------------------- + // N 跳邻居 + // ----------------------------------------------------------------------- + + /** + * 查询实体的 N 跳邻居,返回邻居节点和连接边。 + * + * @param depth 跳数(1-3,由配置上限约束) + * @param limit 返回节点数上限 + */ + public SubgraphVO getNeighborGraph(String graphId, String entityId, int depth, int limit) { + validateGraphId(graphId); + + // 校验实体存在 + entityRepository.findByIdAndGraphId(entityId, graphId) + .orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.ENTITY_NOT_FOUND)); + + int clampedDepth = Math.max(1, Math.min(depth, properties.getMaxDepth())); + int clampedLimit = Math.max(1, Math.min(limit, properties.getMaxNodesPerQuery())); + + // 查询邻居节点(路径变量约束中间节点与关系均属于同一图谱) + List nodes = neo4jClient + .query( + "MATCH p = (e:Entity {graph_id: $graphId, id: $entityId})" + + "-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(neighbor:Entity) " + + "WHERE e <> neighbor " + + " AND ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " + + " AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " + + "WITH DISTINCT neighbor LIMIT $limit " + + "RETURN neighbor.id AS id, neighbor.name AS name, neighbor.type AS type, " + + "neighbor.description AS description" + ) + .bindAll(Map.of("graphId", graphId, "entityId", entityId, "limit", clampedLimit)) + .fetchAs(EntitySummaryVO.class) + .mappedBy((ts, record) -> EntitySummaryVO.builder() + .id(record.get("id").asString(null)) + .name(record.get("name").asString(null)) + .type(record.get("type").asString(null)) + .description(record.get("description").asString(null)) + .build()) + .all() + .stream().toList(); + + // 收集所有节点 ID(包括起始节点) + Set nodeIds = new LinkedHashSet<>(); + nodeIds.add(entityId); + nodes.forEach(n -> nodeIds.add(n.getId())); + + // 查询这些节点之间的边 + List edges = queryEdgesBetween(graphId, new ArrayList<>(nodeIds)); + + // 将起始节点加入节点列表 + GraphEntity startEntity = entityRepository.findByIdAndGraphId(entityId, graphId).orElse(null); + List allNodes = new ArrayList<>(); + if (startEntity != null) { + allNodes.add(EntitySummaryVO.builder() + .id(startEntity.getId()) + .name(startEntity.getName()) + .type(startEntity.getType()) + .description(startEntity.getDescription()) + .build()); + } + allNodes.addAll(nodes); + + return SubgraphVO.builder() + .nodes(allNodes) + .edges(edges) + .nodeCount(allNodes.size()) + .edgeCount(edges.size()) + .build(); + } + + // ----------------------------------------------------------------------- + // 最短路径 + // ----------------------------------------------------------------------- + + /** + * 查询两个实体之间的最短路径。 + * + * @param maxDepth 最大搜索深度(由配置上限约束) + * @return 路径结果,如果不存在路径则返回空路径 + */ + public PathVO getShortestPath(String graphId, String sourceId, String targetId, int maxDepth) { + validateGraphId(graphId); + + // 校验两个实体存在 + entityRepository.findByIdAndGraphId(sourceId, graphId) + .orElseThrow(() -> BusinessException.of( + KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在")); + entityRepository.findByIdAndGraphId(targetId, graphId) + .orElseThrow(() -> BusinessException.of( + KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在")); + + if (sourceId.equals(targetId)) { + // 起止相同,返回单节点路径 + GraphEntity entity = entityRepository.findByIdAndGraphId(sourceId, graphId).orElse(null); + EntitySummaryVO node = entity != null + ? EntitySummaryVO.builder().id(entity.getId()).name(entity.getName()) + .type(entity.getType()).description(entity.getDescription()).build() + : EntitySummaryVO.builder().id(sourceId).build(); + return PathVO.builder() + .nodes(List.of(node)) + .edges(List.of()) + .pathLength(0) + .build(); + } + + int clampedDepth = Math.max(1, Math.min(maxDepth, properties.getMaxDepth())); + + // 使用 Neo4j shortestPath 函数 + // 返回路径上的节点和关系信息 + String cypher = + "MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " + + " (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) " + + "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"; + + return neo4jClient.query(cypher) + .bindAll(Map.of("graphId", graphId, "sourceId", sourceId, "targetId", targetId)) + .fetchAs(PathVO.class) + .mappedBy((ts, record) -> mapPathRecord(record)) + .one() + .orElse(PathVO.builder() + .nodes(List.of()) + .edges(List.of()) + .pathLength(-1) + .build()); + } + + // ----------------------------------------------------------------------- + // 子图提取 + // ----------------------------------------------------------------------- + + /** + * 提取指定实体集合之间的关系网络(子图)。 + * + * @param entityIds 实体 ID 集合 + */ + public SubgraphVO getSubgraph(String graphId, List entityIds) { + validateGraphId(graphId); + + if (entityIds == null || entityIds.isEmpty()) { + return SubgraphVO.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 + ")"); + } + + // 查询存在的实体 + List entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds); + List nodes = entities.stream() + .map(e -> EntitySummaryVO.builder() + .id(e.getId()) + .name(e.getName()) + .type(e.getType()) + .description(e.getDescription()) + .build()) + .toList(); + + if (nodes.isEmpty()) { + return SubgraphVO.builder() + .nodes(List.of()) + .edges(List.of()) + .nodeCount(0) + .edgeCount(0) + .build(); + } + + // 查询这些节点之间的边 + List existingIds = entities.stream().map(GraphEntity::getId).toList(); + List edges = queryEdgesBetween(graphId, existingIds); + + return SubgraphVO.builder() + .nodes(nodes) + .edges(edges) + .nodeCount(nodes.size()) + .edgeCount(edges.size()) + .build(); + } + + // ----------------------------------------------------------------------- + // 全文搜索 + // ----------------------------------------------------------------------- + + /** + * 基于 Neo4j 全文索引搜索实体(name + description)。 + *

+ * 使用 GraphInitializer 创建的 {@code entity_fulltext} 索引, + * 返回按相关度排序的结果。 + * + * @param query 搜索关键词(支持 Lucene 查询语法) + */ + public PagedResponse fulltextSearch(String graphId, String query, int page, int size) { + validateGraphId(graphId); + + if (query == null || query.isBlank()) { + return PagedResponse.of(List.of(), 0, 0, 0); + } + + int safePage = Math.max(0, page); + int safeSize = Math.max(1, Math.min(size, 200)); + long skip = (long) safePage * safeSize; + if (skip > MAX_SKIP) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大"); + } + + // 对搜索关键词进行安全处理:转义 Lucene 特殊字符 + String safeQuery = escapeLuceneQuery(query); + + List results = neo4jClient + .query( + "CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " + + "WHERE node.graph_id = $graphId " + + "RETURN node.id AS id, node.name AS name, node.type AS type, " + + "node.description AS description, score " + + "ORDER BY score DESC " + + "SKIP $skip LIMIT $size" + ) + .bindAll(Map.of( + "graphId", graphId, + "query", safeQuery, + "skip", skip, + "size", safeSize + )) + .fetchAs(SearchHitVO.class) + .mappedBy((ts, record) -> SearchHitVO.builder() + .id(record.get("id").asString(null)) + .name(record.get("name").asString(null)) + .type(record.get("type").asString(null)) + .description(record.get("description").asString(null)) + .score(record.get("score").asDouble()) + .build()) + .all() + .stream().toList(); + + long total = neo4jClient + .query( + "CALL db.index.fulltext.queryNodes('entity_fulltext', $query) YIELD node, score " + + "WHERE node.graph_id = $graphId " + + "RETURN count(*) AS total" + ) + .bindAll(Map.of("graphId", graphId, "query", safeQuery)) + .fetchAs(Long.class) + .mappedBy((ts, record) -> record.get("total").asLong()) + .one() + .orElse(0L); + + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + return PagedResponse.of(results, safePage, total, totalPages); + } + + // ----------------------------------------------------------------------- + // 内部方法 + // ----------------------------------------------------------------------- + + /** + * 查询指定节点集合之间的所有边。 + */ + private List queryEdgesBetween(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" + ) + .bindAll(Map.of("graphId", graphId, "nodeIds", nodeIds)) + .fetchAs(EdgeSummaryVO.class) + .mappedBy((ts, record) -> EdgeSummaryVO.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()) + .build()) + .all() + .stream().toList(); + } + + private PathVO mapPathRecord(MapAccessor record) { + // 解析路径节点 + List nodes = new ArrayList<>(); + Value pathNodes = record.get("pathNodes"); + if (pathNodes != null && !pathNodes.isNull()) { + for (Value nodeVal : pathNodes.asList(v -> v)) { + nodes.add(EntitySummaryVO.builder() + .id(getStringOrNull(nodeVal, "id")) + .name(getStringOrNull(nodeVal, "name")) + .type(getStringOrNull(nodeVal, "type")) + .description(getStringOrNull(nodeVal, "description")) + .build()); + } + } + + // 解析路径边 + List edges = new ArrayList<>(); + Value pathEdges = record.get("pathEdges"); + if (pathEdges != null && !pathEdges.isNull()) { + for (Value edgeVal : pathEdges.asList(v -> v)) { + edges.add(EdgeSummaryVO.builder() + .id(getStringOrNull(edgeVal, "id")) + .sourceEntityId(getStringOrNull(edgeVal, "source")) + .targetEntityId(getStringOrNull(edgeVal, "target")) + .relationType(getStringOrNull(edgeVal, "relation_type")) + .weight(getDoubleOrNull(edgeVal, "weight")) + .build()); + } + } + + int pathLength = record.get("pathLength").asInt(0); + + return PathVO.builder() + .nodes(nodes) + .edges(edges) + .pathLength(pathLength) + .build(); + } + + /** + * 转义 Lucene 查询中的特殊字符,防止查询注入。 + */ + private static String escapeLuceneQuery(String query) { + // Lucene 特殊字符: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / + StringBuilder sb = new StringBuilder(); + for (char c : query.toCharArray()) { + if ("+-&|!(){}[]^\"~*?:\\/".indexOf(c) >= 0) { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } + + private static String getStringOrNull(Value value, String key) { + Value v = value.get(key); + return (v == null || v.isNull()) ? null : v.asString(); + } + + private static Double getDoubleOrNull(Value value, String key) { + Value v = value.get(key); + return (v == null || v.isNull()) ? null : v.asDouble(); + } + + private void validateGraphId(String graphId) { + if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效"); + } + } +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java index 891665b..b175fe7 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; /** @@ -33,6 +34,9 @@ public class GraphRelationService { /** 分页偏移量上限,防止深翻页导致 Neo4j 性能退化。 */ private static final long MAX_SKIP = 100_000L; + /** 合法的关系查询方向。 */ + private static final Set VALID_DIRECTIONS = Set.of("all", "in", "out"); + private static final Pattern UUID_PATTERN = Pattern.compile( "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ); @@ -97,6 +101,53 @@ public class GraphRelationService { return PagedResponse.of(content, safePage, total, totalPages); } + /** + * 查询实体的关系列表。 + * + * @param direction "all"、"in" 或 "out" + */ + public PagedResponse listEntityRelations(String graphId, String entityId, + String direction, String type, + int page, int size) { + validateGraphId(graphId); + + // 校验实体存在 + entityRepository.findByIdAndGraphId(entityId, graphId) + .orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.ENTITY_NOT_FOUND)); + + int safePage = Math.max(0, page); + int safeSize = Math.max(1, Math.min(size, 200)); + long skip = (long) safePage * safeSize; + if (skip > MAX_SKIP) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大"); + } + + String safeDirection = (direction != null) ? direction : "all"; + if (!VALID_DIRECTIONS.contains(safeDirection)) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, + "direction 参数无效,允许值:all, in, out"); + } + + List details; + switch (safeDirection) { + case "in": + details = relationRepository.findInboundByEntityId(graphId, entityId, type, skip, safeSize); + break; + case "out": + details = relationRepository.findOutboundByEntityId(graphId, entityId, type, skip, safeSize); + break; + default: + details = relationRepository.findByEntityId(graphId, entityId, type, skip, safeSize); + break; + } + + long total = relationRepository.countByEntityId(graphId, entityId, type, safeDirection); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + + List content = details.stream().map(GraphRelationService::toVO).toList(); + return PagedResponse.of(content, safePage, total, totalPages); + } + @Transactional public RelationVO updateRelation(String graphId, String relationId, UpdateRelationRequest request) { validateGraphId(graphId); diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java index 6e326d3..52b88b9 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphEntityRepository.java @@ -49,4 +49,52 @@ public interface GraphEntityRepository extends Neo4jRepository findByGraphIdPaged( + @Param("graphId") String graphId, + @Param("skip") long skip, + @Param("limit") int limit); + + @Query("MATCH (e:Entity {graph_id: $graphId}) WHERE e.type = $type " + + "RETURN e ORDER BY e.created_at DESC SKIP $skip LIMIT $limit") + List findByGraphIdAndTypePaged( + @Param("graphId") String graphId, + @Param("type") String type, + @Param("skip") long skip, + @Param("limit") int limit); + + @Query("MATCH (e:Entity {graph_id: $graphId}) WHERE e.type = $type " + + "RETURN count(e)") + long countByGraphIdAndType( + @Param("graphId") String graphId, + @Param("type") String type); + + @Query("MATCH (e:Entity {graph_id: $graphId}) WHERE e.name CONTAINS $name " + + "RETURN e ORDER BY e.created_at DESC SKIP $skip LIMIT $limit") + List findByGraphIdAndNameContainingPaged( + @Param("graphId") String graphId, + @Param("name") String name, + @Param("skip") long skip, + @Param("limit") int limit); + + @Query("MATCH (e:Entity {graph_id: $graphId}) WHERE e.name CONTAINS $name " + + "RETURN count(e)") + long countByGraphIdAndNameContaining( + @Param("graphId") String graphId, + @Param("name") String name); + + // ----------------------------------------------------------------------- + // 图查询 + // ----------------------------------------------------------------------- + + @Query("MATCH (e:Entity {graph_id: $graphId}) WHERE e.id IN $entityIds RETURN e") + List findByGraphIdAndIdIn( + @Param("graphId") String graphId, + @Param("entityIds") List entityIds); } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java index 7e894d3..0c29dd8 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java @@ -88,6 +88,179 @@ public class GraphRelationRepository { .stream().toList(); } + /** + * 查询实体的所有关系(出边 + 入边)。 + *

+ * 使用 {@code CALL{UNION ALL}} 分别锚定出边和入边查询, + * 避免全图扫描后再过滤的性能瓶颈。 + * {@code WITH DISTINCT} 处理自环关系的去重。 + */ + public List findByEntityId(String graphId, String entityId, String type, + long skip, int size) { + String typeFilter = (type != null && !type.isBlank()) + ? "WHERE r.relation_type = $type " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("entityId", entityId); + params.put("type", type != null ? type : ""); + params.put("skip", skip); + params.put("size", size); + + return neo4jClient + .query( + "CALL { " + + "MATCH (s:Entity {graph_id: $graphId, id: $entityId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(t:Entity {graph_id: $graphId}) " + + typeFilter + + "RETURN r, s, t " + + "UNION ALL " + + "MATCH (s:Entity {graph_id: $graphId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(t:Entity {graph_id: $graphId, id: $entityId}) " + + typeFilter + + "RETURN r, s, t " + + "} " + + "WITH DISTINCT r, s, t " + + "ORDER BY r.created_at DESC SKIP $skip LIMIT $size " + + RETURN_COLUMNS + ) + .bindAll(params) + .fetchAs(RelationDetail.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .all() + .stream().toList(); + } + + /** + * 查询实体的入边关系(该实体为目标节点)。 + */ + public List findInboundByEntityId(String graphId, String entityId, String type, + long skip, int size) { + String typeFilter = (type != null && !type.isBlank()) + ? "AND r.relation_type = $type " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("entityId", entityId); + params.put("type", type != null ? type : ""); + params.put("skip", skip); + params.put("size", size); + + return neo4jClient + .query( + "MATCH (s:Entity {graph_id: $graphId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(t:Entity {graph_id: $graphId, id: $entityId}) " + + "WHERE true " + typeFilter + + RETURN_COLUMNS + " " + + "ORDER BY r.created_at DESC " + + "SKIP $skip LIMIT $size" + ) + .bindAll(params) + .fetchAs(RelationDetail.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .all() + .stream().toList(); + } + + /** + * 查询实体的出边关系(该实体为源节点)。 + */ + public List findOutboundByEntityId(String graphId, String entityId, String type, + long skip, int size) { + String typeFilter = (type != null && !type.isBlank()) + ? "AND r.relation_type = $type " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("entityId", entityId); + params.put("type", type != null ? type : ""); + params.put("skip", skip); + params.put("size", size); + + return neo4jClient + .query( + "MATCH (s:Entity {graph_id: $graphId, id: $entityId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(t:Entity {graph_id: $graphId}) " + + "WHERE true " + typeFilter + + RETURN_COLUMNS + " " + + "ORDER BY r.created_at DESC " + + "SKIP $skip LIMIT $size" + ) + .bindAll(params) + .fetchAs(RelationDetail.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .all() + .stream().toList(); + } + + /** + * 统计实体的关系数量。 + *

+ * 各方向均以实体锚定 MATCH 模式,避免全图扫描。 + * "all" 方向使用 {@code CALL{UNION}} 自动去重自环关系。 + * + * @param direction "all"、"in" 或 "out" + */ + public long countByEntityId(String graphId, String entityId, String type, String direction) { + String typeFilter = (type != null && !type.isBlank()) + ? "WHERE r.relation_type = $type " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("entityId", entityId); + params.put("type", type != null ? type : ""); + + String cypher; + switch (direction) { + case "in": + cypher = "MATCH (:Entity {graph_id: $graphId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(:Entity {graph_id: $graphId, id: $entityId}) " + + typeFilter + + "RETURN count(r) AS cnt"; + break; + case "out": + cypher = "MATCH (:Entity {graph_id: $graphId, id: $entityId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(:Entity {graph_id: $graphId}) " + + typeFilter + + "RETURN count(r) AS cnt"; + break; + default: + cypher = "CALL { " + + "MATCH (:Entity {graph_id: $graphId, id: $entityId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(:Entity {graph_id: $graphId}) " + + typeFilter + + "RETURN r " + + "UNION " + + "MATCH (:Entity {graph_id: $graphId})" + + "-[r:" + REL_TYPE + " {graph_id: $graphId}]->" + + "(:Entity {graph_id: $graphId, id: $entityId}) " + + typeFilter + + "RETURN r " + + "} " + + "RETURN count(r) AS cnt"; + break; + } + + return neo4jClient + .query(cypher) + .bindAll(params) + .fetchAs(Long.class) + .mappedBy((typeSystem, record) -> record.get("cnt").asLong()) + .one() + .orElse(0L); + } + public List findBySourceAndTarget(String graphId, String sourceEntityId, String targetEntityId) { return neo4jClient .query( diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EdgeSummaryVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EdgeSummaryVO.java new file mode 100644 index 0000000..cdcb211 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EdgeSummaryVO.java @@ -0,0 +1,22 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 关系摘要,用于图遍历结果中的边表示。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EdgeSummaryVO { + + private String id; + private String sourceEntityId; + private String targetEntityId; + private String relationType; + private Double weight; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EntitySummaryVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EntitySummaryVO.java new file mode 100644 index 0000000..c8bc9c4 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EntitySummaryVO.java @@ -0,0 +1,21 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 实体摘要,用于图遍历结果中的节点表示。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EntitySummaryVO { + + private String id; + private String name; + private String type; + private String description; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/PathVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/PathVO.java new file mode 100644 index 0000000..a2fec81 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/PathVO.java @@ -0,0 +1,27 @@ +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 PathVO { + + /** 路径上的节点列表(按顺序) */ + private List nodes; + + /** 路径上的边列表(按顺序) */ + private List edges; + + /** 路径长度(跳数) */ + private int pathLength; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SearchHitVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SearchHitVO.java new file mode 100644 index 0000000..8424c92 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SearchHitVO.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 SearchHitVO { + + private String id; + private String name; + private String type; + private String description; + + /** 全文搜索相关度分数(越高越相关) */ + private double score; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphRequest.java new file mode 100644 index 0000000..f480ebe --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphRequest.java @@ -0,0 +1,26 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 子图查询请求。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SubgraphRequest { + + private static final String UUID_REGEX = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + + @NotEmpty(message = "实体 ID 列表不能为空") + @Size(max = 500, message = "实体数量超出限制(最大 500)") + private List<@Pattern(regexp = UUID_REGEX, message = "entityId 格式无效") String> entityIds; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphVO.java new file mode 100644 index 0000000..4c2e278 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubgraphVO.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 SubgraphVO { + + /** 子图中的节点列表 */ + 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/GraphEntityController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java index 30a7f4c..ddd39f2 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java @@ -1,8 +1,11 @@ package com.datamate.knowledgegraph.interfaces.rest; +import com.datamate.common.interfaces.PagedResponse; import com.datamate.knowledgegraph.application.GraphEntityService; +import com.datamate.knowledgegraph.application.GraphRelationService; import com.datamate.knowledgegraph.domain.model.GraphEntity; import com.datamate.knowledgegraph.interfaces.dto.CreateEntityRequest; +import com.datamate.knowledgegraph.interfaces.dto.RelationVO; import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; @@ -23,6 +26,7 @@ public class GraphEntityController { "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; private final GraphEntityService entityService; + private final GraphRelationService relationService; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -39,7 +43,13 @@ public class GraphEntityController { return entityService.getEntity(graphId, entityId); } - @GetMapping + /** + * 查询实体列表(非分页,向后兼容)。 + *

+ * 当请求不包含 {@code page} 参数时匹配此端点,返回 {@code List}。 + * 需要分页时请传入 {@code page} 参数,将路由到分页端点。 + */ + @GetMapping(params = "!page") public List listEntities( @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, @RequestParam(required = false) String type, @@ -53,6 +63,27 @@ public class GraphEntityController { return entityService.listEntities(graphId); } + /** + * 查询实体列表(分页)。 + *

+ * 当请求包含 {@code page} 参数时匹配此端点,返回 {@code PagedResponse}。 + */ + @GetMapping(params = "page") + public PagedResponse listEntitiesPaged( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @RequestParam(required = false) String type, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + if (keyword != null && !keyword.isBlank()) { + return entityService.searchEntitiesPaged(graphId, keyword, page, size); + } + if (type != null && !type.isBlank()) { + return entityService.listEntitiesByTypePaged(graphId, type, page, size); + } + return entityService.listEntitiesPaged(graphId, page, size); + } + @PutMapping("/{entityId}") public GraphEntity updateEntity( @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, @@ -69,6 +100,17 @@ public class GraphEntityController { entityService.deleteEntity(graphId, entityId); } + @GetMapping("/{entityId}/relations") + public PagedResponse listEntityRelations( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @PathVariable @Pattern(regexp = UUID_REGEX, message = "entityId 格式无效") String entityId, + @RequestParam(defaultValue = "all") @Pattern(regexp = "^(all|in|out)$", message = "direction 参数无效,允许值:all, in, out") String direction, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return relationService.listEntityRelations(graphId, entityId, direction, type, page, size); + } + @GetMapping("/{entityId}/neighbors") public List getNeighbors( @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, 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 new file mode 100644 index 0000000..afe0ee5 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphQueryController.java @@ -0,0 +1,86 @@ +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 jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 知识图谱查询接口。 + *

+ * 提供图遍历(邻居、最短路径、子图)和全文搜索功能。 + */ +@RestController +@RequestMapping("/knowledge-graph/{graphId}/query") +@RequiredArgsConstructor +@Validated +public class GraphQueryController { + + private static final String UUID_REGEX = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + + private final GraphQueryService queryService; + + // ----------------------------------------------------------------------- + // 图遍历 + // ----------------------------------------------------------------------- + + /** + * 查询实体的 N 跳邻居子图。 + */ + @GetMapping("/neighbors/{entityId}") + public SubgraphVO getNeighborGraph( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @PathVariable @Pattern(regexp = UUID_REGEX, message = "entityId 格式无效") String entityId, + @RequestParam(defaultValue = "2") int depth, + @RequestParam(defaultValue = "50") int limit) { + return queryService.getNeighborGraph(graphId, entityId, depth, limit); + } + + /** + * 查询两个实体之间的最短路径。 + */ + @GetMapping("/shortest-path") + public PathVO getShortestPath( + @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) { + return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth); + } + + /** + * 提取指定实体集合的子图(关系网络)。 + */ + @PostMapping("/subgraph") + public SubgraphVO getSubgraph( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @Valid @RequestBody SubgraphRequest request) { + return queryService.getSubgraph(graphId, request.getEntityIds()); + } + + // ----------------------------------------------------------------------- + // 全文搜索 + // ----------------------------------------------------------------------- + + /** + * 基于全文索引搜索实体。 + *

+ * 搜索 name 和 description 字段,按相关度排序。 + */ + @GetMapping("/search") + public PagedResponse fulltextSearch( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return queryService.fulltextSearch(graphId, q, page, size); + } +}