diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java new file mode 100644 index 0000000..891665b --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphRelationService.java @@ -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; + +/** + * 知识图谱关系业务服务。 + *

+ * 信任边界说明:本服务仅通过内网被 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 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 details = relationRepository.findByGraphId(graphId, type, skip, safeSize); + long total = relationRepository.countByGraphId(graphId, type); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + + List content = details.stream().map(GraphRelationService::toVO).toList(); + return PagedResponse.of(content, safePage, total, totalPages); + } + + @Transactional + public RelationVO updateRelation(String graphId, String relationId, UpdateRelationRequest request) { + validateGraphId(graphId); + + // 确认关系存在 + 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 格式无效"); + } + } +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/RelationDetail.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/RelationDetail.java new file mode 100644 index 0000000..71abff1 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/RelationDetail.java @@ -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; + +/** + * 关系及其端点实体摘要,用于仓储层查询返回。 + *

+ * 由于 {@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 properties = new HashMap<>(); + + private Double weight; + + private Double confidence; + + /** 来源数据集/知识库的 ID */ + private String sourceId; + + private String graphId; + + private LocalDateTime createdAt; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java new file mode 100644 index 0000000..7e894d3 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/GraphRelationRepository.java @@ -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.*; + +/** + * 知识图谱关系仓储。 + *

+ * 由于 {@code GraphRelation} 使用 {@code @RelationshipProperties}, + * 无法通过 {@code Neo4jRepository} 直接管理, + * 因此使用 {@code Neo4jClient} 执行 Cypher 查询实现 CRUD。 + *

+ * 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_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 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 findByGraphId(String graphId, String type, long skip, int size) { + String typeFilter = (type != null && !type.isBlank()) + ? "AND r.relation_type = $type " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("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 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 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 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 create(String graphId, String sourceEntityId, String targetEntityId, + String relationType, Map properties, + Double weight, String sourceId, Double confidence) { + String id = UUID.randomUUID().toString(); + LocalDateTime now = LocalDateTime.now(); + + Map 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 update(String relationId, String graphId, + String relationType, Map properties, + Double weight, Double confidence) { + Map 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 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 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(); + } +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/CreateRelationRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/CreateRelationRequest.java index 7a6c311..8fbdb24 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/CreateRelationRequest.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/CreateRelationRequest.java @@ -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 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; } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/RelationVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/RelationVO.java new file mode 100644 index 0000000..a8578fe --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/RelationVO.java @@ -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; + +/** + * 关系查询结果视图对象。 + *

+ * 包含关系的完整信息,包括源实体和目标实体的摘要信息, + * 用于 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 properties = new HashMap<>(); + + private Double weight; + + private Double confidence; + + /** 来源数据集/知识库的 ID */ + private String sourceId; + + private String graphId; + + private LocalDateTime createdAt; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateRelationRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateRelationRequest.java new file mode 100644 index 0000000..6e2021b --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateRelationRequest.java @@ -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; + +/** + * 关系更新请求。 + *

+ * 所有字段均为可选,仅更新提供了值的字段(patch 语义)。 + */ +@Data +public class UpdateRelationRequest { + + @Size(min = 1, max = 50, message = "关系类型长度必须在1-50之间") + private String relationType; + + private Map 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; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphRelationController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphRelationController.java new file mode 100644 index 0000000..7c9a8cc --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphRelationController.java @@ -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 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); + } +}