You've already forked DataMate
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:
@@ -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<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
|
||||
public GraphEntity updateEntity(String graphId, String entityId, UpdateEntityRequest request) {
|
||||
validateGraphId(graphId);
|
||||
|
||||
@@ -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 格式无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<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
|
||||
public RelationVO updateRelation(String graphId, String relationId, UpdateRelationRequest request) {
|
||||
validateGraphId(graphId);
|
||||
|
||||
@@ -49,4 +49,52 @@ public interface GraphEntityRepository extends Neo4jRepository<GraphEntity, Stri
|
||||
@Param("graphId") String graphId,
|
||||
@Param("sourceId") String sourceId,
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,179 @@ public class GraphRelationRepository {
|
||||
.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) {
|
||||
return neo4jClient
|
||||
.query(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
/**
|
||||
* 查询实体列表(非分页,向后兼容)。
|
||||
* <p>
|
||||
* 当请求不包含 {@code page} 参数时匹配此端点,返回 {@code List}。
|
||||
* 需要分页时请传入 {@code page} 参数,将路由到分页端点。
|
||||
*/
|
||||
@GetMapping(params = "!page")
|
||||
public List<GraphEntity> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询实体列表(分页)。
|
||||
* <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}")
|
||||
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<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")
|
||||
public List<GraphEntity> getNeighbors(
|
||||
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user