fix(knowledge-graph): 修复 Codex 审查发现的 5 个问题并新增查询功能

本次提交包含两部分内容:
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<GraphEntity> 变为 PagedResponse<GraphEntity>
**修复**: 使用 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<String>,无 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)
This commit is contained in:
2026-02-18 07:49:16 +08:00
parent 8b1ab8ff36
commit a260134d7c
13 changed files with 1009 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package com.datamate.knowledgegraph.application;
import com.datamate.common.infrastructure.exception.BusinessException; import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.common.infrastructure.exception.SystemErrorCode; 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.model.GraphEntity;
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository; import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode; import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
@@ -22,6 +23,9 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor @RequiredArgsConstructor
public class GraphEntityService { public class GraphEntityService {
/** 分页偏移量上限,防止深翻页导致 Neo4j 性能退化。 */
private static final long MAX_SKIP = 100_000L;
private static final Pattern UUID_PATTERN = Pattern.compile( 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}$" "^[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); return entityRepository.findByGraphIdAndType(graphId, type);
} }
// -----------------------------------------------------------------------
// 分页查询
// -----------------------------------------------------------------------
public PagedResponse<GraphEntity> 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<GraphEntity> 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<GraphEntity> 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<GraphEntity> 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<GraphEntity> 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<GraphEntity> 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 @Transactional
public GraphEntity updateEntity(String graphId, String entityId, UpdateEntityRequest request) { public GraphEntity updateEntity(String graphId, String entityId, UpdateEntityRequest request) {
validateGraphId(graphId); validateGraphId(graphId);

View File

@@ -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;
/**
* 知识图谱查询服务。
* <p>
* 提供图遍历(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<EntitySummaryVO> 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<String> nodeIds = new LinkedHashSet<>();
nodeIds.add(entityId);
nodes.forEach(n -> nodeIds.add(n.getId()));
// 查询这些节点之间的边
List<EdgeSummaryVO> edges = queryEdgesBetween(graphId, new ArrayList<>(nodeIds));
// 将起始节点加入节点列表
GraphEntity startEntity = entityRepository.findByIdAndGraphId(entityId, graphId).orElse(null);
List<EntitySummaryVO> 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<String> 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<GraphEntity> entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds);
List<EntitySummaryVO> 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<String> existingIds = entities.stream().map(GraphEntity::getId).toList();
List<EdgeSummaryVO> edges = queryEdgesBetween(graphId, existingIds);
return SubgraphVO.builder()
.nodes(nodes)
.edges(edges)
.nodeCount(nodes.size())
.edgeCount(edges.size())
.build();
}
// -----------------------------------------------------------------------
// 全文搜索
// -----------------------------------------------------------------------
/**
* 基于 Neo4j 全文索引搜索实体(name + description)。
* <p>
* 使用 GraphInitializer 创建的 {@code entity_fulltext} 索引,
* 返回按相关度排序的结果。
*
* @param query 搜索关键词(支持 Lucene 查询语法)
*/
public PagedResponse<SearchHitVO> 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<SearchHitVO> 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<EdgeSummaryVO> queryEdgesBetween(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"
)
.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<EntitySummaryVO> 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<EdgeSummaryVO> 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 格式无效");
}
}
}

View File

@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -33,6 +34,9 @@ public class GraphRelationService {
/** 分页偏移量上限,防止深翻页导致 Neo4j 性能退化。 */ /** 分页偏移量上限,防止深翻页导致 Neo4j 性能退化。 */
private static final long MAX_SKIP = 100_000L; private static final long MAX_SKIP = 100_000L;
/** 合法的关系查询方向。 */
private static final Set<String> VALID_DIRECTIONS = Set.of("all", "in", "out");
private static final Pattern UUID_PATTERN = Pattern.compile( 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}$" "^[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); return PagedResponse.of(content, safePage, total, totalPages);
} }
/**
* 查询实体的关系列表。
*
* @param direction "all"、"in" 或 "out"
*/
public PagedResponse<RelationVO> 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<RelationDetail> 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<RelationVO> content = details.stream().map(GraphRelationService::toVO).toList();
return PagedResponse.of(content, safePage, total, totalPages);
}
@Transactional @Transactional
public RelationVO updateRelation(String graphId, String relationId, UpdateRelationRequest request) { public RelationVO updateRelation(String graphId, String relationId, UpdateRelationRequest request) {
validateGraphId(graphId); validateGraphId(graphId);

View File

@@ -49,4 +49,52 @@ public interface GraphEntityRepository extends Neo4jRepository<GraphEntity, Stri
@Param("graphId") String graphId, @Param("graphId") String graphId,
@Param("sourceId") String sourceId, @Param("sourceId") String sourceId,
@Param("type") String type); @Param("type") String type);
// -----------------------------------------------------------------------
// 分页查询
// -----------------------------------------------------------------------
@Query("MATCH (e:Entity {graph_id: $graphId}) " +
"RETURN e ORDER BY e.created_at DESC SKIP $skip LIMIT $limit")
List<GraphEntity> 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<GraphEntity> 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<GraphEntity> 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<GraphEntity> findByGraphIdAndIdIn(
@Param("graphId") String graphId,
@Param("entityIds") List<String> entityIds);
} }

View File

@@ -88,6 +88,179 @@ public class GraphRelationRepository {
.stream().toList(); .stream().toList();
} }
/**
* 查询实体的所有关系(出边 + 入边)。
* <p>
* 使用 {@code CALL{UNION ALL}} 分别锚定出边和入边查询,
* 避免全图扫描后再过滤的性能瓶颈。
* {@code WITH DISTINCT} 处理自环关系的去重。
*/
public List<RelationDetail> findByEntityId(String graphId, String entityId, String type,
long skip, int size) {
String typeFilter = (type != null && !type.isBlank())
? "WHERE r.relation_type = $type "
: "";
Map<String, Object> 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<RelationDetail> findInboundByEntityId(String graphId, String entityId, String type,
long skip, int size) {
String typeFilter = (type != null && !type.isBlank())
? "AND r.relation_type = $type "
: "";
Map<String, Object> 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<RelationDetail> findOutboundByEntityId(String graphId, String entityId, String type,
long skip, int size) {
String typeFilter = (type != null && !type.isBlank())
? "AND r.relation_type = $type "
: "";
Map<String, Object> 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();
}
/**
* 统计实体的关系数量。
* <p>
* 各方向均以实体锚定 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<String, Object> 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<RelationDetail> findBySourceAndTarget(String graphId, String sourceEntityId, String targetEntityId) { public List<RelationDetail> findBySourceAndTarget(String graphId, String sourceEntityId, String targetEntityId) {
return neo4jClient return neo4jClient
.query( .query(

View File

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

View File

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

View File

@@ -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<EntitySummaryVO> nodes;
/** 路径上的边列表(按顺序) */
private List<EdgeSummaryVO> edges;
/** 路径长度(跳数) */
private int pathLength;
}

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 SearchHitVO {
private String id;
private String name;
private String type;
private String description;
/** 全文搜索相关度分数(越高越相关) */
private double score;
}

View File

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

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

View File

@@ -1,8 +1,11 @@
package com.datamate.knowledgegraph.interfaces.rest; package com.datamate.knowledgegraph.interfaces.rest;
import com.datamate.common.interfaces.PagedResponse;
import com.datamate.knowledgegraph.application.GraphEntityService; import com.datamate.knowledgegraph.application.GraphEntityService;
import com.datamate.knowledgegraph.application.GraphRelationService;
import com.datamate.knowledgegraph.domain.model.GraphEntity; import com.datamate.knowledgegraph.domain.model.GraphEntity;
import com.datamate.knowledgegraph.interfaces.dto.CreateEntityRequest; import com.datamate.knowledgegraph.interfaces.dto.CreateEntityRequest;
import com.datamate.knowledgegraph.interfaces.dto.RelationVO;
import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest; import com.datamate.knowledgegraph.interfaces.dto.UpdateEntityRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern; 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}$"; "^[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 GraphEntityService entityService;
private final GraphRelationService relationService;
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@@ -39,7 +43,13 @@ public class GraphEntityController {
return entityService.getEntity(graphId, entityId); return entityService.getEntity(graphId, entityId);
} }
@GetMapping /**
* 查询实体列表(非分页,向后兼容)。
* <p>
* 当请求不包含 {@code page} 参数时匹配此端点,返回 {@code List}。
* 需要分页时请传入 {@code page} 参数,将路由到分页端点。
*/
@GetMapping(params = "!page")
public List<GraphEntity> listEntities( public List<GraphEntity> listEntities(
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
@RequestParam(required = false) String type, @RequestParam(required = false) String type,
@@ -53,6 +63,27 @@ public class GraphEntityController {
return entityService.listEntities(graphId); return entityService.listEntities(graphId);
} }
/**
* 查询实体列表(分页)。
* <p>
* 当请求包含 {@code page} 参数时匹配此端点,返回 {@code PagedResponse}。
*/
@GetMapping(params = "page")
public PagedResponse<GraphEntity> 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}") @PutMapping("/{entityId}")
public GraphEntity updateEntity( public GraphEntity updateEntity(
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
@@ -69,6 +100,17 @@ public class GraphEntityController {
entityService.deleteEntity(graphId, entityId); entityService.deleteEntity(graphId, entityId);
} }
@GetMapping("/{entityId}/relations")
public PagedResponse<RelationVO> 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") @GetMapping("/{entityId}/neighbors")
public List<GraphEntity> getNeighbors( public List<GraphEntity> getNeighbors(
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,

View File

@@ -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.*;
/**
* 知识图谱查询接口。
* <p>
* 提供图遍历(邻居、最短路径、子图)和全文搜索功能。
*/
@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());
}
// -----------------------------------------------------------------------
// 全文搜索
// -----------------------------------------------------------------------
/**
* 基于全文索引搜索实体。
* <p>
* 搜索 name 和 description 字段,按相关度排序。
*/
@GetMapping("/search")
public PagedResponse<SearchHitVO> 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);
}
}