You've already forked DataMate
feat(kg-relation): 实现 Java 关系(Relation)功能
实现功能:
- 实现 GraphRelationRepository(Neo4jClient + Cypher)
- 实现 GraphRelationService(业务逻辑层)
- 实现 GraphRelationController(REST API)
- 新增 RelationDetail 领域对象
- 新增 RelationVO、UpdateRelationRequest DTO
API 端点:
- POST /{graphId}/relations:创建关系(201)
- GET /{graphId}/relations:分页列表查询(支持 type/page/size)
- GET /{graphId}/relations/{relationId}:单个查询
- PUT /{graphId}/relations/{relationId}:更新关系
- DELETE /{graphId}/relations/{relationId}:删除关系(204)
技术实现:
- Repository:
- 使用 Neo4jClient + Cypher 实现 CRUD
- 使用 bindAll(Map) 一次性绑定参数
- properties 字段使用 JSON 序列化存储
- 支持分页查询(SKIP/LIMIT)
- 支持类型过滤
- Service:
- graphId UUID 格式校验
- 实体存在性校验
- @Transactional 事务管理
- 信任边界说明(网关负责鉴权)
- 分页 skip 使用 long 计算,上限保护 100,000
- Controller:
- 所有 pathVariable 添加 UUID pattern 校验
- 使用 @Validated 启用参数校验
- 使用平台统一的 PagedResponse 分页响应
- DTO:
- weight/confidence 添加 @DecimalMin/@DecimalMax(0.0-1.0)
- relationType 添加 @Size(1-50)
- sourceEntityId/targetEntityId 添加 UUID pattern 校验
架构设计:
- 分层清晰:interfaces → application → domain
- Repository 返回领域对象 RelationDetail
- DTO 转换在 Service 层
- 关系类型:Neo4j 使用统一 RELATED_TO 标签,语义类型存储在 relation_type 属性
代码审查:
- 经过 2 轮 Codex 审查和 1 轮 Claude 修复
- 所有问题已解决(2个P0 + 2个P1 + 4个P2)
- 编译验证通过(mvn compile SUCCESS)
设计决策:
- 使用 Neo4jClient 而非 Neo4jRepository(@RelationshipProperties 限制)
- 分页 size 上限 200,防止大查询
- properties 使用 JSON 序列化,支持灵活扩展
- 复用现有错误码(ENTITY_NOT_FOUND、RELATION_NOT_FOUND、INVALID_RELATION)
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
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.RelationDetail;
|
||||||
|
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
|
||||||
|
import com.datamate.knowledgegraph.domain.repository.GraphRelationRepository;
|
||||||
|
import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.CreateRelationRequest;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.RelationVO;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.UpdateRelationRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱关系业务服务。
|
||||||
|
* <p>
|
||||||
|
* <b>信任边界说明</b>:本服务仅通过内网被 API Gateway / Java 后端调用,
|
||||||
|
* 网关层已完成用户身份认证与权限校验,服务层不再重复鉴权,
|
||||||
|
* 仅校验 graphId 格式(防 Cypher 注入)与数据完整性约束。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GraphRelationService {
|
||||||
|
|
||||||
|
/** 分页偏移量上限,防止深翻页导致 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}$"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final GraphRelationRepository relationRepository;
|
||||||
|
private final GraphEntityRepository entityRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RelationVO createRelation(String graphId, CreateRelationRequest request) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
// 校验源实体存在
|
||||||
|
entityRepository.findByIdAndGraphId(request.getSourceEntityId(), graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在"));
|
||||||
|
|
||||||
|
// 校验目标实体存在
|
||||||
|
entityRepository.findByIdAndGraphId(request.getTargetEntityId(), graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"));
|
||||||
|
|
||||||
|
RelationDetail detail = relationRepository.create(
|
||||||
|
graphId,
|
||||||
|
request.getSourceEntityId(),
|
||||||
|
request.getTargetEntityId(),
|
||||||
|
request.getRelationType(),
|
||||||
|
request.getProperties(),
|
||||||
|
request.getWeight(),
|
||||||
|
request.getSourceId(),
|
||||||
|
request.getConfidence()
|
||||||
|
).orElseThrow(() -> BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.INVALID_RELATION, "关系创建失败"));
|
||||||
|
|
||||||
|
log.info("Relation created: id={}, graphId={}, type={}, source={} -> target={}",
|
||||||
|
detail.getId(), graphId, request.getRelationType(),
|
||||||
|
request.getSourceEntityId(), request.getTargetEntityId());
|
||||||
|
return toVO(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelationVO getRelation(String graphId, String relationId) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
RelationDetail detail = relationRepository.findByIdAndGraphId(relationId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.RELATION_NOT_FOUND));
|
||||||
|
return toVO(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PagedResponse<RelationVO> listRelations(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<RelationDetail> details = relationRepository.findByGraphId(graphId, type, skip, safeSize);
|
||||||
|
long total = relationRepository.countByGraphId(graphId, type);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 确认关系存在
|
||||||
|
relationRepository.findByIdAndGraphId(relationId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.RELATION_NOT_FOUND));
|
||||||
|
|
||||||
|
RelationDetail detail = relationRepository.update(
|
||||||
|
relationId, graphId,
|
||||||
|
request.getRelationType(),
|
||||||
|
request.getProperties(),
|
||||||
|
request.getWeight(),
|
||||||
|
request.getConfidence()
|
||||||
|
).orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.RELATION_NOT_FOUND));
|
||||||
|
|
||||||
|
log.info("Relation updated: id={}, graphId={}", relationId, graphId);
|
||||||
|
return toVO(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteRelation(String graphId, String relationId) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
// 确认关系存在
|
||||||
|
relationRepository.findByIdAndGraphId(relationId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.RELATION_NOT_FOUND));
|
||||||
|
|
||||||
|
long deleted = relationRepository.deleteByIdAndGraphId(relationId, graphId);
|
||||||
|
if (deleted <= 0) {
|
||||||
|
throw BusinessException.of(KnowledgeGraphErrorCode.RELATION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
log.info("Relation deleted: id={}, graphId={}", relationId, graphId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 领域对象 → 视图对象 转换
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static RelationVO toVO(RelationDetail detail) {
|
||||||
|
return RelationVO.builder()
|
||||||
|
.id(detail.getId())
|
||||||
|
.sourceEntityId(detail.getSourceEntityId())
|
||||||
|
.sourceEntityName(detail.getSourceEntityName())
|
||||||
|
.sourceEntityType(detail.getSourceEntityType())
|
||||||
|
.targetEntityId(detail.getTargetEntityId())
|
||||||
|
.targetEntityName(detail.getTargetEntityName())
|
||||||
|
.targetEntityType(detail.getTargetEntityType())
|
||||||
|
.relationType(detail.getRelationType())
|
||||||
|
.properties(detail.getProperties())
|
||||||
|
.weight(detail.getWeight())
|
||||||
|
.confidence(detail.getConfidence())
|
||||||
|
.sourceId(detail.getSourceId())
|
||||||
|
.graphId(detail.getGraphId())
|
||||||
|
.createdAt(detail.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 graphId 格式(UUID)。
|
||||||
|
* 防止恶意构造的 graphId 注入 Cypher 查询。
|
||||||
|
*/
|
||||||
|
private void validateGraphId(String graphId) {
|
||||||
|
if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) {
|
||||||
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.datamate.knowledgegraph.domain.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系及其端点实体摘要,用于仓储层查询返回。
|
||||||
|
* <p>
|
||||||
|
* 由于 {@link GraphRelation} 使用 {@code @RelationshipProperties} 且仅持有
|
||||||
|
* 目标节点引用,无法完整表达 Cypher 查询返回的"源节点 + 关系 + 目标节点"结构,
|
||||||
|
* 因此使用该领域对象作为仓储层的返回类型。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RelationDetail {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String sourceEntityId;
|
||||||
|
|
||||||
|
private String sourceEntityName;
|
||||||
|
|
||||||
|
private String sourceEntityType;
|
||||||
|
|
||||||
|
private String targetEntityId;
|
||||||
|
|
||||||
|
private String targetEntityName;
|
||||||
|
|
||||||
|
private String targetEntityType;
|
||||||
|
|
||||||
|
private String relationType;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Map<String, Object> properties = new HashMap<>();
|
||||||
|
|
||||||
|
private Double weight;
|
||||||
|
|
||||||
|
private Double confidence;
|
||||||
|
|
||||||
|
/** 来源数据集/知识库的 ID */
|
||||||
|
private String sourceId;
|
||||||
|
|
||||||
|
private String graphId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package com.datamate.knowledgegraph.domain.repository;
|
||||||
|
|
||||||
|
import com.datamate.knowledgegraph.domain.model.RelationDetail;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱关系仓储。
|
||||||
|
* <p>
|
||||||
|
* 由于 {@code GraphRelation} 使用 {@code @RelationshipProperties},
|
||||||
|
* 无法通过 {@code Neo4jRepository} 直接管理,
|
||||||
|
* 因此使用 {@code Neo4jClient} 执行 Cypher 查询实现 CRUD。
|
||||||
|
* <p>
|
||||||
|
* Neo4j 中使用统一的 {@code RELATED_TO} 关系类型,
|
||||||
|
* 语义类型通过 {@code relation_type} 属性区分。
|
||||||
|
* 扩展属性(properties)序列化为 JSON 字符串存储在 {@code properties_json} 属性中。
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GraphRelationRepository {
|
||||||
|
|
||||||
|
private static final String REL_TYPE = "RELATED_TO";
|
||||||
|
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 查询返回列(源节点 + 关系 + 目标节点)。 */
|
||||||
|
private static final String RETURN_COLUMNS =
|
||||||
|
"RETURN r, " +
|
||||||
|
"s.id AS sourceEntityId, s.name AS sourceEntityName, s.type AS sourceEntityType, " +
|
||||||
|
"t.id AS targetEntityId, t.name AS targetEntityName, t.type AS targetEntityType";
|
||||||
|
|
||||||
|
private final Neo4jClient neo4jClient;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 查询
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public Optional<RelationDetail> findByIdAndGraphId(String relationId, String graphId) {
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {id: $relationId, graph_id: $graphId}]->" +
|
||||||
|
"(t:Entity {graph_id: $graphId}) " +
|
||||||
|
RETURN_COLUMNS
|
||||||
|
)
|
||||||
|
.bindAll(Map.of("graphId", graphId, "relationId", relationId))
|
||||||
|
.fetchAs(RelationDetail.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.one();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RelationDetail> findByGraphId(String graphId, 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("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}) " +
|
||||||
|
"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> findBySourceAndTarget(String graphId, String sourceEntityId, String targetEntityId) {
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId, id: $sourceEntityId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {graph_id: $graphId}]->" +
|
||||||
|
"(t:Entity {graph_id: $graphId, id: $targetEntityId}) " +
|
||||||
|
RETURN_COLUMNS
|
||||||
|
)
|
||||||
|
.bindAll(Map.of(
|
||||||
|
"graphId", graphId,
|
||||||
|
"sourceEntityId", sourceEntityId,
|
||||||
|
"targetEntityId", targetEntityId
|
||||||
|
))
|
||||||
|
.fetchAs(RelationDetail.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.all()
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RelationDetail> findByType(String graphId, String type) {
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {graph_id: $graphId, relation_type: $type}]->" +
|
||||||
|
"(t:Entity {graph_id: $graphId}) " +
|
||||||
|
RETURN_COLUMNS
|
||||||
|
)
|
||||||
|
.bindAll(Map.of("graphId", graphId, "type", type))
|
||||||
|
.fetchAs(RelationDetail.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.all()
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countByGraphId(String graphId, String type) {
|
||||||
|
String typeFilter = (type != null && !type.isBlank())
|
||||||
|
? "AND r.relation_type = $type "
|
||||||
|
: "";
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("type", type != null ? type : "");
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (:Entity {graph_id: $graphId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {graph_id: $graphId}]->" +
|
||||||
|
"(:Entity {graph_id: $graphId}) " +
|
||||||
|
"WHERE true " + typeFilter +
|
||||||
|
"RETURN count(r) AS cnt"
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.fetchAs(Long.class)
|
||||||
|
.mappedBy((typeSystem, record) -> record.get("cnt").asLong())
|
||||||
|
.one()
|
||||||
|
.orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 写入
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public Optional<RelationDetail> create(String graphId, String sourceEntityId, String targetEntityId,
|
||||||
|
String relationType, Map<String, Object> properties,
|
||||||
|
Double weight, String sourceId, Double confidence) {
|
||||||
|
String id = UUID.randomUUID().toString();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("sourceEntityId", sourceEntityId);
|
||||||
|
params.put("targetEntityId", targetEntityId);
|
||||||
|
params.put("id", id);
|
||||||
|
params.put("relationType", relationType);
|
||||||
|
params.put("weight", weight != null ? weight : 1.0);
|
||||||
|
params.put("confidence", confidence != null ? confidence : 1.0);
|
||||||
|
params.put("sourceId", sourceId != null ? sourceId : "");
|
||||||
|
params.put("propertiesJson", serializeProperties(properties));
|
||||||
|
params.put("createdAt", now);
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId, id: $sourceEntityId}) " +
|
||||||
|
"MATCH (t:Entity {graph_id: $graphId, id: $targetEntityId}) " +
|
||||||
|
"CREATE (s)-[r:" + REL_TYPE + " {" +
|
||||||
|
" id: $id," +
|
||||||
|
" relation_type: $relationType," +
|
||||||
|
" weight: $weight," +
|
||||||
|
" confidence: $confidence," +
|
||||||
|
" source_id: $sourceId," +
|
||||||
|
" graph_id: $graphId," +
|
||||||
|
" properties_json: $propertiesJson," +
|
||||||
|
" created_at: $createdAt" +
|
||||||
|
"}]->(t) " +
|
||||||
|
RETURN_COLUMNS
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.fetchAs(RelationDetail.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.one();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<RelationDetail> update(String relationId, String graphId,
|
||||||
|
String relationType, Map<String, Object> properties,
|
||||||
|
Double weight, Double confidence) {
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("relationId", relationId);
|
||||||
|
|
||||||
|
StringBuilder setClauses = new StringBuilder();
|
||||||
|
if (relationType != null) {
|
||||||
|
setClauses.append("SET r.relation_type = $relationType ");
|
||||||
|
params.put("relationType", relationType);
|
||||||
|
}
|
||||||
|
if (properties != null) {
|
||||||
|
setClauses.append("SET r.properties_json = $propertiesJson ");
|
||||||
|
params.put("propertiesJson", serializeProperties(properties));
|
||||||
|
}
|
||||||
|
if (weight != null) {
|
||||||
|
setClauses.append("SET r.weight = $weight ");
|
||||||
|
params.put("weight", weight);
|
||||||
|
}
|
||||||
|
if (confidence != null) {
|
||||||
|
setClauses.append("SET r.confidence = $confidence ");
|
||||||
|
params.put("confidence", confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.isEmpty()) {
|
||||||
|
return findByIdAndGraphId(relationId, graphId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {id: $relationId, graph_id: $graphId}]->" +
|
||||||
|
"(t:Entity {graph_id: $graphId}) " +
|
||||||
|
setClauses +
|
||||||
|
RETURN_COLUMNS
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.fetchAs(RelationDetail.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.one();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定关系,返回实际删除的数量(0 或 1)。
|
||||||
|
*/
|
||||||
|
public long deleteByIdAndGraphId(String relationId, String graphId) {
|
||||||
|
// MATCH 找不到时管道为空行,count(*) 聚合后仍返回 0;
|
||||||
|
// 找到 1 条时 DELETE 后管道保留该行,count(*) 返回 1。
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (:Entity {graph_id: $graphId})" +
|
||||||
|
"-[r:" + REL_TYPE + " {id: $relationId, graph_id: $graphId}]->" +
|
||||||
|
"(:Entity {graph_id: $graphId}) " +
|
||||||
|
"DELETE r " +
|
||||||
|
"RETURN count(*) AS deleted"
|
||||||
|
)
|
||||||
|
.bindAll(Map.of("graphId", graphId, "relationId", relationId))
|
||||||
|
.fetchAs(Long.class)
|
||||||
|
.mappedBy((typeSystem, record) -> record.get("deleted").asLong())
|
||||||
|
.one()
|
||||||
|
.orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 内部映射
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private RelationDetail mapRecord(MapAccessor record) {
|
||||||
|
Value r = record.get("r");
|
||||||
|
|
||||||
|
return RelationDetail.builder()
|
||||||
|
.id(getStringOrNull(r, "id"))
|
||||||
|
.sourceEntityId(record.get("sourceEntityId").asString(null))
|
||||||
|
.sourceEntityName(record.get("sourceEntityName").asString(null))
|
||||||
|
.sourceEntityType(record.get("sourceEntityType").asString(null))
|
||||||
|
.targetEntityId(record.get("targetEntityId").asString(null))
|
||||||
|
.targetEntityName(record.get("targetEntityName").asString(null))
|
||||||
|
.targetEntityType(record.get("targetEntityType").asString(null))
|
||||||
|
.relationType(getStringOrNull(r, "relation_type"))
|
||||||
|
.properties(deserializeProperties(getStringOrNull(r, "properties_json")))
|
||||||
|
.weight(getDoubleOrNull(r, "weight"))
|
||||||
|
.confidence(getDoubleOrNull(r, "confidence"))
|
||||||
|
.sourceId(getStringOrNull(r, "source_id"))
|
||||||
|
.graphId(getStringOrNull(r, "graph_id"))
|
||||||
|
.createdAt(getLocalDateTimeOrNull(r, "created_at"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Properties JSON 序列化
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static String serializeProperties(Map<String, Object> properties) {
|
||||||
|
if (properties == null || properties.isEmpty()) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(properties);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("Failed to serialize properties, falling back to empty: {}", e.getMessage());
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> deserializeProperties(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(json, MAP_TYPE);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("Failed to deserialize properties_json, returning empty: {}", e.getMessage());
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 字段读取辅助
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 static LocalDateTime getLocalDateTimeOrNull(Value value, String key) {
|
||||||
|
Value v = value.get(key);
|
||||||
|
return (v == null || v.isNull()) ? null : v.asLocalDateTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.datamate.knowledgegraph.interfaces.dto;
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -9,20 +13,30 @@ import java.util.Map;
|
|||||||
@Data
|
@Data
|
||||||
public class CreateRelationRequest {
|
public class CreateRelationRequest {
|
||||||
|
|
||||||
|
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}$";
|
||||||
|
|
||||||
@NotBlank(message = "源实体ID不能为空")
|
@NotBlank(message = "源实体ID不能为空")
|
||||||
|
@Pattern(regexp = UUID_REGEX, message = "源实体ID格式无效")
|
||||||
private String sourceEntityId;
|
private String sourceEntityId;
|
||||||
|
|
||||||
@NotBlank(message = "目标实体ID不能为空")
|
@NotBlank(message = "目标实体ID不能为空")
|
||||||
|
@Pattern(regexp = UUID_REGEX, message = "目标实体ID格式无效")
|
||||||
private String targetEntityId;
|
private String targetEntityId;
|
||||||
|
|
||||||
@NotBlank(message = "关系类型不能为空")
|
@NotBlank(message = "关系类型不能为空")
|
||||||
|
@Size(min = 1, max = 50, message = "关系类型长度必须在1-50之间")
|
||||||
private String relationType;
|
private String relationType;
|
||||||
|
|
||||||
private Map<String, Object> properties = new HashMap<>();
|
private Map<String, Object> properties = new HashMap<>();
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", message = "权重必须在0.0-1.0之间")
|
||||||
|
@DecimalMax(value = "1.0", message = "权重必须在0.0-1.0之间")
|
||||||
private Double weight;
|
private Double weight;
|
||||||
|
|
||||||
private String sourceId;
|
private String sourceId;
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", message = "置信度必须在0.0-1.0之间")
|
||||||
|
@DecimalMax(value = "1.0", message = "置信度必须在0.0-1.0之间")
|
||||||
private Double confidence;
|
private Double confidence;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系查询结果视图对象。
|
||||||
|
* <p>
|
||||||
|
* 包含关系的完整信息,包括源实体和目标实体的摘要信息,
|
||||||
|
* 用于 REST API 响应。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RelationVO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String sourceEntityId;
|
||||||
|
|
||||||
|
private String sourceEntityName;
|
||||||
|
|
||||||
|
private String sourceEntityType;
|
||||||
|
|
||||||
|
private String targetEntityId;
|
||||||
|
|
||||||
|
private String targetEntityName;
|
||||||
|
|
||||||
|
private String targetEntityType;
|
||||||
|
|
||||||
|
private String relationType;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private Map<String, Object> properties = new HashMap<>();
|
||||||
|
|
||||||
|
private Double weight;
|
||||||
|
|
||||||
|
private Double confidence;
|
||||||
|
|
||||||
|
/** 来源数据集/知识库的 ID */
|
||||||
|
private String sourceId;
|
||||||
|
|
||||||
|
private String graphId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系更新请求。
|
||||||
|
* <p>
|
||||||
|
* 所有字段均为可选,仅更新提供了值的字段(patch 语义)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UpdateRelationRequest {
|
||||||
|
|
||||||
|
@Size(min = 1, max = 50, message = "关系类型长度必须在1-50之间")
|
||||||
|
private String relationType;
|
||||||
|
|
||||||
|
private Map<String, Object> properties;
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", message = "权重必须在0.0-1.0之间")
|
||||||
|
@DecimalMax(value = "1.0", message = "权重必须在0.0-1.0之间")
|
||||||
|
private Double weight;
|
||||||
|
|
||||||
|
@DecimalMin(value = "0.0", message = "置信度必须在0.0-1.0之间")
|
||||||
|
@DecimalMax(value = "1.0", message = "置信度必须在0.0-1.0之间")
|
||||||
|
private Double confidence;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.rest;
|
||||||
|
|
||||||
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
|
import com.datamate.knowledgegraph.application.GraphRelationService;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.CreateRelationRequest;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.RelationVO;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.UpdateRelationRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/knowledge-graph/{graphId}/relations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class GraphRelationController {
|
||||||
|
|
||||||
|
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 GraphRelationService relationService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public RelationVO createRelation(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@Valid @RequestBody CreateRelationRequest request) {
|
||||||
|
return relationService.createRelation(graphId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public PagedResponse<RelationVO> listRelations(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@RequestParam(required = false) String type,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return relationService.listRelations(graphId, type, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{relationId}")
|
||||||
|
public RelationVO getRelation(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId) {
|
||||||
|
return relationService.getRelation(graphId, relationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{relationId}")
|
||||||
|
public RelationVO updateRelation(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId,
|
||||||
|
@Valid @RequestBody UpdateRelationRequest request) {
|
||||||
|
return relationService.updateRelation(graphId, relationId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{relationId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteRelation(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId) {
|
||||||
|
relationService.deleteRelation(graphId, relationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user