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:
2026-02-17 22:40:27 +08:00
parent 0e0782a452
commit 910251e898
7 changed files with 711 additions and 0 deletions

View File

@@ -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 格式无效");
}
}
}

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
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.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.HashMap;
@@ -9,20 +13,30 @@ import java.util.Map;
@Data
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不能为空")
@Pattern(regexp = UUID_REGEX, message = "源实体ID格式无效")
private String sourceEntityId;
@NotBlank(message = "目标实体ID不能为空")
@Pattern(regexp = UUID_REGEX, message = "目标实体ID格式无效")
private String targetEntityId;
@NotBlank(message = "关系类型不能为空")
@Size(min = 1, max = 50, message = "关系类型长度必须在1-50之间")
private String relationType;
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 String sourceId;
@DecimalMin(value = "0.0", message = "置信度必须在0.0-1.0之间")
@DecimalMax(value = "1.0", message = "置信度必须在0.0-1.0之间")
private Double confidence;
}

View File

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

View File

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

View File

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