diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java index 0897300..02b32fc 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java @@ -49,6 +49,7 @@ public class PermissionRuleMatcher { addModuleRules(permissionRules, "/api/orchestration/**", "module:orchestration:read", "module:orchestration:write"); addModuleRules(permissionRules, "/api/content-generation/**", "module:content-generation:use", "module:content-generation:use"); addModuleRules(permissionRules, "/api/task-meta/**", "module:task-coordination:read", "module:task-coordination:write"); + addModuleRules(permissionRules, "/api/knowledge-graph/**", "module:knowledge-graph:read", "module:knowledge-graph:write"); permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/users/**", "system:user:manage")); permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/users/**", "system:user:manage")); diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/EditReviewService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/EditReviewService.java new file mode 100644 index 0000000..1a0110c --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/EditReviewService.java @@ -0,0 +1,219 @@ +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.EditReview; +import com.datamate.knowledgegraph.domain.repository.EditReviewRepository; +import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode; +import com.datamate.knowledgegraph.interfaces.dto.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 编辑审核业务服务。 + *

+ * 提供编辑审核的提交、审批、拒绝和查询功能。 + * 审批通过后自动调用对应的实体/关系 CRUD 服务执行变更。 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class EditReviewService { + + 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 static final ObjectMapper MAPPER = new ObjectMapper(); + + private final EditReviewRepository reviewRepository; + private final GraphEntityService entityService; + private final GraphRelationService relationService; + + @Transactional + public EditReviewVO submitReview(String graphId, SubmitReviewRequest request, String submittedBy) { + validateGraphId(graphId); + + EditReview review = EditReview.builder() + .graphId(graphId) + .operationType(request.getOperationType()) + .entityId(request.getEntityId()) + .relationId(request.getRelationId()) + .payload(request.getPayload()) + .status("PENDING") + .submittedBy(submittedBy) + .build(); + + EditReview saved = reviewRepository.save(review); + log.info("Review submitted: id={}, graphId={}, type={}, by={}", + saved.getId(), graphId, request.getOperationType(), submittedBy); + return toVO(saved); + } + + @Transactional + public EditReviewVO approveReview(String graphId, String reviewId, String reviewedBy, String comment) { + validateGraphId(graphId); + + EditReview review = reviewRepository.findById(reviewId, graphId) + .orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.REVIEW_NOT_FOUND)); + + if (!"PENDING".equals(review.getStatus())) { + throw BusinessException.of(KnowledgeGraphErrorCode.REVIEW_ALREADY_PROCESSED); + } + + // Apply the change + applyChange(review); + + // Update review status + review.setStatus("APPROVED"); + review.setReviewedBy(reviewedBy); + review.setReviewComment(comment); + review.setReviewedAt(LocalDateTime.now()); + reviewRepository.save(review); + + log.info("Review approved: id={}, graphId={}, type={}, by={}", + reviewId, graphId, review.getOperationType(), reviewedBy); + return toVO(review); + } + + @Transactional + public EditReviewVO rejectReview(String graphId, String reviewId, String reviewedBy, String comment) { + validateGraphId(graphId); + + EditReview review = reviewRepository.findById(reviewId, graphId) + .orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.REVIEW_NOT_FOUND)); + + if (!"PENDING".equals(review.getStatus())) { + throw BusinessException.of(KnowledgeGraphErrorCode.REVIEW_ALREADY_PROCESSED); + } + + review.setStatus("REJECTED"); + review.setReviewedBy(reviewedBy); + review.setReviewComment(comment); + review.setReviewedAt(LocalDateTime.now()); + reviewRepository.save(review); + + log.info("Review rejected: id={}, graphId={}, type={}, by={}", + reviewId, graphId, review.getOperationType(), reviewedBy); + return toVO(review); + } + + public PagedResponse listPendingReviews(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 reviews = reviewRepository.findPendingByGraphId(graphId, skip, safeSize); + long total = reviewRepository.countPendingByGraphId(graphId); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + + List content = reviews.stream().map(EditReviewService::toVO).toList(); + return PagedResponse.of(content, safePage, total, totalPages); + } + + public PagedResponse listReviews(String graphId, String status, 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 reviews = reviewRepository.findByGraphId(graphId, status, skip, safeSize); + long total = reviewRepository.countByGraphId(graphId, status); + long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0; + + List content = reviews.stream().map(EditReviewService::toVO).toList(); + return PagedResponse.of(content, safePage, total, totalPages); + } + + // ----------------------------------------------------------------------- + // 执行变更 + // ----------------------------------------------------------------------- + + private void applyChange(EditReview review) { + String graphId = review.getGraphId(); + String type = review.getOperationType(); + + try { + switch (type) { + case "CREATE_ENTITY" -> { + CreateEntityRequest req = MAPPER.readValue(review.getPayload(), CreateEntityRequest.class); + entityService.createEntity(graphId, req); + } + case "UPDATE_ENTITY" -> { + UpdateEntityRequest req = MAPPER.readValue(review.getPayload(), UpdateEntityRequest.class); + entityService.updateEntity(graphId, review.getEntityId(), req); + } + case "DELETE_ENTITY" -> { + entityService.deleteEntity(graphId, review.getEntityId()); + } + case "BATCH_DELETE_ENTITY" -> { + BatchDeleteRequest req = MAPPER.readValue(review.getPayload(), BatchDeleteRequest.class); + entityService.batchDeleteEntities(graphId, req.getIds()); + } + case "CREATE_RELATION" -> { + CreateRelationRequest req = MAPPER.readValue(review.getPayload(), CreateRelationRequest.class); + relationService.createRelation(graphId, req); + } + case "UPDATE_RELATION" -> { + UpdateRelationRequest req = MAPPER.readValue(review.getPayload(), UpdateRelationRequest.class); + relationService.updateRelation(graphId, review.getRelationId(), req); + } + case "DELETE_RELATION" -> { + relationService.deleteRelation(graphId, review.getRelationId()); + } + case "BATCH_DELETE_RELATION" -> { + BatchDeleteRequest req = MAPPER.readValue(review.getPayload(), BatchDeleteRequest.class); + relationService.batchDeleteRelations(graphId, req.getIds()); + } + default -> throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "未知操作类型: " + type); + } + } catch (JsonProcessingException e) { + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "变更载荷解析失败: " + e.getMessage()); + } + } + + // ----------------------------------------------------------------------- + // 转换 + // ----------------------------------------------------------------------- + + private static EditReviewVO toVO(EditReview review) { + return EditReviewVO.builder() + .id(review.getId()) + .graphId(review.getGraphId()) + .operationType(review.getOperationType()) + .entityId(review.getEntityId()) + .relationId(review.getRelationId()) + .payload(review.getPayload()) + .status(review.getStatus()) + .submittedBy(review.getSubmittedBy()) + .reviewedBy(review.getReviewedBy()) + .reviewComment(review.getReviewComment()) + .createdAt(review.getCreatedAt()) + .reviewedAt(review.getReviewedAt()) + .build(); + } + + 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/application/GraphEntityService.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java index 4b101b1..6e6615c 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/application/GraphEntityService.java @@ -18,7 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; @Service @@ -147,6 +149,9 @@ public class GraphEntityService { if (request.getProperties() != null) { entity.setProperties(request.getProperties()); } + if (request.getConfidence() != null) { + entity.setConfidence(request.getConfidence()); + } entity.setUpdatedAt(LocalDateTime.now()); GraphEntity saved = entityRepository.save(entity); cacheService.evictEntityCaches(graphId, entityId); @@ -170,6 +175,28 @@ public class GraphEntityService { return entityRepository.findNeighbors(graphId, entityId, clampedDepth, clampedLimit); } + @Transactional + public Map batchDeleteEntities(String graphId, List entityIds) { + validateGraphId(graphId); + int deleted = 0; + List failedIds = new ArrayList<>(); + for (String entityId : entityIds) { + try { + deleteEntity(graphId, entityId); + deleted++; + } catch (Exception e) { + log.warn("Batch delete: failed to delete entity {}: {}", entityId, e.getMessage()); + failedIds.add(entityId); + } + } + Map result = Map.of( + "deleted", deleted, + "total", entityIds.size(), + "failedIds", failedIds + ); + return result; + } + public long countEntities(String graphId) { validateGraphId(graphId); return entityRepository.countByGraphId(graphId); 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 index 733697e..04d73d3 100644 --- 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 @@ -16,7 +16,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -188,6 +190,28 @@ public class GraphRelationService { cacheService.evictEntityCaches(graphId, relationId); } + @Transactional + public Map batchDeleteRelations(String graphId, List relationIds) { + validateGraphId(graphId); + int deleted = 0; + List failedIds = new ArrayList<>(); + for (String relationId : relationIds) { + try { + deleteRelation(graphId, relationId); + deleted++; + } catch (Exception e) { + log.warn("Batch delete: failed to delete relation {}: {}", relationId, e.getMessage()); + failedIds.add(relationId); + } + } + Map result = Map.of( + "deleted", deleted, + "total", relationIds.size(), + "failedIds", failedIds + ); + return result; + } + // ----------------------------------------------------------------------- // 领域对象 → 视图对象 转换 // ----------------------------------------------------------------------- diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/EditReview.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/EditReview.java new file mode 100644 index 0000000..fb9ee66 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/model/EditReview.java @@ -0,0 +1,55 @@ +package com.datamate.knowledgegraph.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 知识图谱编辑审核记录。 + *

+ * 在 Neo4j 中作为 {@code EditReview} 节点存储, + * 记录实体/关系的增删改请求及审核状态。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EditReview { + + private String id; + + /** 所属图谱 ID */ + private String graphId; + + /** 操作类型:CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY, BATCH_DELETE_ENTITY, CREATE_RELATION, UPDATE_RELATION, DELETE_RELATION, BATCH_DELETE_RELATION */ + private String operationType; + + /** 目标实体 ID(实体操作时非空) */ + private String entityId; + + /** 目标关系 ID(关系操作时非空) */ + private String relationId; + + /** 变更载荷(JSON 序列化的请求体) */ + private String payload; + + /** 审核状态:PENDING, APPROVED, REJECTED */ + @Builder.Default + private String status = "PENDING"; + + /** 提交人 ID */ + private String submittedBy; + + /** 审核人 ID */ + private String reviewedBy; + + /** 审核意见 */ + private String reviewComment; + + private LocalDateTime createdAt; + + private LocalDateTime reviewedAt; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/EditReviewRepository.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/EditReviewRepository.java new file mode 100644 index 0000000..6bfc3fa --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/domain/repository/EditReviewRepository.java @@ -0,0 +1,187 @@ +package com.datamate.knowledgegraph.domain.repository; + +import com.datamate.knowledgegraph.domain.model.EditReview; +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 Neo4jClient} 管理 {@code EditReview} 节点。 + */ +@Repository +@Slf4j +@RequiredArgsConstructor +public class EditReviewRepository { + + private final Neo4jClient neo4jClient; + + public EditReview save(EditReview review) { + if (review.getId() == null) { + review.setId(UUID.randomUUID().toString()); + } + if (review.getCreatedAt() == null) { + review.setCreatedAt(LocalDateTime.now()); + } + + Map params = new HashMap<>(); + params.put("id", review.getId()); + params.put("graphId", review.getGraphId()); + params.put("operationType", review.getOperationType()); + params.put("entityId", review.getEntityId() != null ? review.getEntityId() : ""); + params.put("relationId", review.getRelationId() != null ? review.getRelationId() : ""); + params.put("payload", review.getPayload() != null ? review.getPayload() : ""); + params.put("status", review.getStatus()); + params.put("submittedBy", review.getSubmittedBy() != null ? review.getSubmittedBy() : ""); + params.put("reviewedBy", review.getReviewedBy() != null ? review.getReviewedBy() : ""); + params.put("reviewComment", review.getReviewComment() != null ? review.getReviewComment() : ""); + params.put("createdAt", review.getCreatedAt()); + params.put("reviewedAt", review.getReviewedAt()); + + neo4jClient + .query( + "MERGE (r:EditReview {id: $id}) " + + "SET r.graph_id = $graphId, " + + " r.operation_type = $operationType, " + + " r.entity_id = $entityId, " + + " r.relation_id = $relationId, " + + " r.payload = $payload, " + + " r.status = $status, " + + " r.submitted_by = $submittedBy, " + + " r.reviewed_by = $reviewedBy, " + + " r.review_comment = $reviewComment, " + + " r.created_at = $createdAt, " + + " r.reviewed_at = $reviewedAt " + + "RETURN r" + ) + .bindAll(params) + .run(); + + return review; + } + + public Optional findById(String reviewId, String graphId) { + return neo4jClient + .query("MATCH (r:EditReview {id: $id, graph_id: $graphId}) RETURN r") + .bindAll(Map.of("id", reviewId, "graphId", graphId)) + .fetchAs(EditReview.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .one(); + } + + public List findPendingByGraphId(String graphId, long skip, int size) { + return neo4jClient + .query( + "MATCH (r:EditReview {graph_id: $graphId, status: 'PENDING'}) " + + "RETURN r ORDER BY r.created_at DESC SKIP $skip LIMIT $size" + ) + .bindAll(Map.of("graphId", graphId, "skip", skip, "size", size)) + .fetchAs(EditReview.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .all() + .stream().toList(); + } + + public long countPendingByGraphId(String graphId) { + return neo4jClient + .query("MATCH (r:EditReview {graph_id: $graphId, status: 'PENDING'}) RETURN count(r) AS cnt") + .bindAll(Map.of("graphId", graphId)) + .fetchAs(Long.class) + .mappedBy((typeSystem, record) -> record.get("cnt").asLong()) + .one() + .orElse(0L); + } + + public List findByGraphId(String graphId, String status, long skip, int size) { + String statusFilter = (status != null && !status.isBlank()) + ? "AND r.status = $status " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("status", status != null ? status : ""); + params.put("skip", skip); + params.put("size", size); + + return neo4jClient + .query( + "MATCH (r:EditReview {graph_id: $graphId}) " + + "WHERE true " + statusFilter + + "RETURN r ORDER BY r.created_at DESC SKIP $skip LIMIT $size" + ) + .bindAll(params) + .fetchAs(EditReview.class) + .mappedBy((typeSystem, record) -> mapRecord(record)) + .all() + .stream().toList(); + } + + public long countByGraphId(String graphId, String status) { + String statusFilter = (status != null && !status.isBlank()) + ? "AND r.status = $status " + : ""; + + Map params = new HashMap<>(); + params.put("graphId", graphId); + params.put("status", status != null ? status : ""); + + return neo4jClient + .query( + "MATCH (r:EditReview {graph_id: $graphId}) " + + "WHERE true " + statusFilter + + "RETURN count(r) AS cnt" + ) + .bindAll(params) + .fetchAs(Long.class) + .mappedBy((typeSystem, record) -> record.get("cnt").asLong()) + .one() + .orElse(0L); + } + + // ----------------------------------------------------------------------- + // 内部映射 + // ----------------------------------------------------------------------- + + private EditReview mapRecord(MapAccessor record) { + Value r = record.get("r"); + + return EditReview.builder() + .id(getStringOrNull(r, "id")) + .graphId(getStringOrNull(r, "graph_id")) + .operationType(getStringOrNull(r, "operation_type")) + .entityId(getStringOrEmpty(r, "entity_id")) + .relationId(getStringOrEmpty(r, "relation_id")) + .payload(getStringOrNull(r, "payload")) + .status(getStringOrNull(r, "status")) + .submittedBy(getStringOrEmpty(r, "submitted_by")) + .reviewedBy(getStringOrEmpty(r, "reviewed_by")) + .reviewComment(getStringOrEmpty(r, "review_comment")) + .createdAt(getLocalDateTimeOrNull(r, "created_at")) + .reviewedAt(getLocalDateTimeOrNull(r, "reviewed_at")) + .build(); + } + + private static String getStringOrNull(Value value, String key) { + Value v = value.get(key); + return (v == null || v.isNull()) ? null : v.asString(); + } + + private static String getStringOrEmpty(Value value, String key) { + Value v = value.get(key); + if (v == null || v.isNull()) return null; + String s = v.asString(); + return s.isEmpty() ? null : s; + } + + 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/infrastructure/exception/KnowledgeGraphErrorCode.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java index 9d40785..4e50eaf 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java @@ -27,7 +27,9 @@ public enum KnowledgeGraphErrorCode implements ErrorCode { QUERY_TIMEOUT("knowledge_graph.0014", "图查询超时,请缩小搜索范围或减少深度"), SCHEMA_MIGRATION_FAILED("knowledge_graph.0015", "Schema 迁移执行失败"), SCHEMA_CHECKSUM_MISMATCH("knowledge_graph.0016", "Schema 迁移 checksum 不匹配:已应用的迁移被修改"), - SCHEMA_MIGRATION_LOCKED("knowledge_graph.0017", "Schema 迁移锁被占用,其他实例正在执行迁移"); + SCHEMA_MIGRATION_LOCKED("knowledge_graph.0017", "Schema 迁移锁被占用,其他实例正在执行迁移"), + REVIEW_NOT_FOUND("knowledge_graph.0018", "审核记录不存在"), + REVIEW_ALREADY_PROCESSED("knowledge_graph.0019", "审核记录已处理"); private final String code; private final String message; diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/BatchDeleteRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/BatchDeleteRequest.java new file mode 100644 index 0000000..4e03ec5 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/BatchDeleteRequest.java @@ -0,0 +1,18 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 批量删除请求。 + */ +@Data +public class BatchDeleteRequest { + + @NotEmpty(message = "ID 列表不能为空") + @Size(max = 100, message = "单次批量删除最多 100 条") + private List ids; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EditReviewVO.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EditReviewVO.java new file mode 100644 index 0000000..43b8b42 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/EditReviewVO.java @@ -0,0 +1,31 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 编辑审核记录视图对象。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EditReviewVO { + + private String id; + private String graphId; + private String operationType; + private String entityId; + private String relationId; + private String payload; + private String status; + private String submittedBy; + private String reviewedBy; + private String reviewComment; + private LocalDateTime createdAt; + private LocalDateTime reviewedAt; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ReviewActionRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ReviewActionRequest.java new file mode 100644 index 0000000..43ab867 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/ReviewActionRequest.java @@ -0,0 +1,13 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import lombok.Data; + +/** + * 审核通过/拒绝请求。 + */ +@Data +public class ReviewActionRequest { + + /** 审核意见(可选) */ + private String comment; +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubmitReviewRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubmitReviewRequest.java new file mode 100644 index 0000000..0fa287e --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/SubmitReviewRequest.java @@ -0,0 +1,65 @@ +package com.datamate.knowledgegraph.interfaces.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 提交编辑审核请求。 + */ +@Data +public class SubmitReviewRequest { + + 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}$"; + + /** + * 操作类型:CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY, + * CREATE_RELATION, UPDATE_RELATION, DELETE_RELATION, + * BATCH_DELETE_ENTITY, BATCH_DELETE_RELATION + */ + @NotBlank(message = "操作类型不能为空") + @Pattern(regexp = "^(CREATE|UPDATE|DELETE|BATCH_DELETE)_(ENTITY|RELATION)$", + message = "操作类型无效") + private String operationType; + + /** 目标实体 ID(实体操作时必填) */ + private String entityId; + + /** 目标关系 ID(关系操作时必填) */ + private String relationId; + + /** 变更载荷(JSON 格式的请求体) */ + private String payload; + + @AssertTrue(message = "UPDATE/DELETE 实体操作必须提供 entityId") + private boolean isEntityIdValid() { + if (operationType == null) return true; + if (operationType.endsWith("_ENTITY") && !operationType.startsWith("CREATE") + && !operationType.startsWith("BATCH")) { + return entityId != null && !entityId.isBlank(); + } + return true; + } + + @AssertTrue(message = "UPDATE/DELETE 关系操作必须提供 relationId") + private boolean isRelationIdValid() { + if (operationType == null) return true; + if (operationType.endsWith("_RELATION") && !operationType.startsWith("CREATE") + && !operationType.startsWith("BATCH")) { + return relationId != null && !relationId.isBlank(); + } + return true; + } + + @AssertTrue(message = "CREATE/UPDATE/BATCH_DELETE 操作必须提供 payload") + private boolean isPayloadValid() { + if (operationType == null) return true; + if (operationType.startsWith("CREATE") || operationType.startsWith("UPDATE") + || operationType.startsWith("BATCH_DELETE")) { + return payload != null && !payload.isBlank(); + } + return true; + } +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateEntityRequest.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateEntityRequest.java index 936caf9..ffe6f3b 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateEntityRequest.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/dto/UpdateEntityRequest.java @@ -15,4 +15,6 @@ public class UpdateEntityRequest { private List aliases; private Map properties; + + private Double confidence; } diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewController.java new file mode 100644 index 0000000..be5ec5d --- /dev/null +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewController.java @@ -0,0 +1,71 @@ +package com.datamate.knowledgegraph.interfaces.rest; + +import com.datamate.common.interfaces.PagedResponse; +import com.datamate.knowledgegraph.application.EditReviewService; +import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO; +import com.datamate.knowledgegraph.interfaces.dto.ReviewActionRequest; +import com.datamate.knowledgegraph.interfaces.dto.SubmitReviewRequest; +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}/review") +@RequiredArgsConstructor +@Validated +public class EditReviewController { + + 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 EditReviewService reviewService; + + @PostMapping("/submit") + @ResponseStatus(HttpStatus.CREATED) + public EditReviewVO submitReview( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @Valid @RequestBody SubmitReviewRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) { + return reviewService.submitReview(graphId, request, userId); + } + + @PostMapping("/{reviewId}/approve") + public EditReviewVO approveReview( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @PathVariable @Pattern(regexp = UUID_REGEX, message = "reviewId 格式无效") String reviewId, + @RequestBody(required = false) ReviewActionRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) { + String comment = (request != null) ? request.getComment() : null; + return reviewService.approveReview(graphId, reviewId, userId, comment); + } + + @PostMapping("/{reviewId}/reject") + public EditReviewVO rejectReview( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @PathVariable @Pattern(regexp = UUID_REGEX, message = "reviewId 格式无效") String reviewId, + @RequestBody(required = false) ReviewActionRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) { + String comment = (request != null) ? request.getComment() : null; + return reviewService.rejectReview(graphId, reviewId, userId, comment); + } + + @GetMapping("/pending") + public PagedResponse listPendingReviews( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return reviewService.listPendingReviews(graphId, page, size); + } + + @GetMapping + public PagedResponse listReviews( + @PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return reviewService.listReviews(graphId, status, page, size); + } +} diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java index ddd39f2..64768d4 100644 --- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java +++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphEntityController.java @@ -119,4 +119,5 @@ public class GraphEntityController { @RequestParam(defaultValue = "50") int limit) { return entityService.getNeighbors(graphId, entityId, depth, limit); } + } 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 index 7c9a8cc..525f225 100644 --- 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 @@ -62,4 +62,5 @@ public class GraphRelationController { @PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId) { relationService.deleteRelation(graphId, relationId); } + } diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/EditReviewServiceTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/EditReviewServiceTest.java new file mode 100644 index 0000000..2e9baf3 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/application/EditReviewServiceTest.java @@ -0,0 +1,361 @@ +package com.datamate.knowledgegraph.application; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.knowledgegraph.domain.model.EditReview; +import com.datamate.knowledgegraph.domain.repository.EditReviewRepository; +import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO; +import com.datamate.knowledgegraph.interfaces.dto.SubmitReviewRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EditReviewServiceTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String REVIEW_ID = "660e8400-e29b-41d4-a716-446655440001"; + private static final String ENTITY_ID = "770e8400-e29b-41d4-a716-446655440002"; + private static final String USER_ID = "user-1"; + private static final String REVIEWER_ID = "reviewer-1"; + private static final String INVALID_GRAPH_ID = "not-a-uuid"; + + @Mock + private EditReviewRepository reviewRepository; + + @Mock + private GraphEntityService entityService; + + @Mock + private GraphRelationService relationService; + + @InjectMocks + private EditReviewService reviewService; + + private EditReview pendingReview; + + @BeforeEach + void setUp() { + pendingReview = EditReview.builder() + .id(REVIEW_ID) + .graphId(GRAPH_ID) + .operationType("CREATE_ENTITY") + .payload("{\"name\":\"TestEntity\",\"type\":\"Dataset\"}") + .status("PENDING") + .submittedBy(USER_ID) + .createdAt(LocalDateTime.now()) + .build(); + } + + // ----------------------------------------------------------------------- + // graphId 校验 + // ----------------------------------------------------------------------- + + @Test + void submitReview_invalidGraphId_throwsBusinessException() { + SubmitReviewRequest request = new SubmitReviewRequest(); + request.setOperationType("CREATE_ENTITY"); + request.setPayload("{}"); + + assertThatThrownBy(() -> reviewService.submitReview(INVALID_GRAPH_ID, request, USER_ID)) + .isInstanceOf(BusinessException.class); + } + + @Test + void approveReview_invalidGraphId_throwsBusinessException() { + assertThatThrownBy(() -> reviewService.approveReview(INVALID_GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // submitReview + // ----------------------------------------------------------------------- + + @Test + void submitReview_success() { + SubmitReviewRequest request = new SubmitReviewRequest(); + request.setOperationType("CREATE_ENTITY"); + request.setPayload("{\"name\":\"NewEntity\",\"type\":\"Dataset\"}"); + + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + EditReviewVO result = reviewService.submitReview(GRAPH_ID, request, USER_ID); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo("PENDING"); + assertThat(result.getOperationType()).isEqualTo("CREATE_ENTITY"); + verify(reviewRepository).save(any(EditReview.class)); + } + + @Test + void submitReview_withEntityId() { + SubmitReviewRequest request = new SubmitReviewRequest(); + request.setOperationType("UPDATE_ENTITY"); + request.setEntityId(ENTITY_ID); + request.setPayload("{\"name\":\"Updated\"}"); + + EditReview savedReview = EditReview.builder() + .id(REVIEW_ID) + .graphId(GRAPH_ID) + .operationType("UPDATE_ENTITY") + .entityId(ENTITY_ID) + .payload("{\"name\":\"Updated\"}") + .status("PENDING") + .submittedBy(USER_ID) + .createdAt(LocalDateTime.now()) + .build(); + + when(reviewRepository.save(any(EditReview.class))).thenReturn(savedReview); + + EditReviewVO result = reviewService.submitReview(GRAPH_ID, request, USER_ID); + + assertThat(result.getEntityId()).isEqualTo(ENTITY_ID); + assertThat(result.getOperationType()).isEqualTo("UPDATE_ENTITY"); + } + + // ----------------------------------------------------------------------- + // approveReview + // ----------------------------------------------------------------------- + + @Test + void approveReview_success_appliesChange() { + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + EditReviewVO result = reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, "LGTM"); + + assertThat(result).isNotNull(); + assertThat(pendingReview.getStatus()).isEqualTo("APPROVED"); + assertThat(pendingReview.getReviewedBy()).isEqualTo(REVIEWER_ID); + assertThat(pendingReview.getReviewComment()).isEqualTo("LGTM"); + assertThat(pendingReview.getReviewedAt()).isNotNull(); + + // Verify applyChange was called (createEntity for CREATE_ENTITY) + verify(entityService).createEntity(eq(GRAPH_ID), any()); + } + + @Test + void approveReview_notFound_throwsBusinessException() { + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void approveReview_alreadyProcessed_throwsBusinessException() { + pendingReview.setStatus("APPROVED"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + + assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void approveReview_deleteEntity_appliesChange() { + pendingReview.setOperationType("DELETE_ENTITY"); + pendingReview.setEntityId(ENTITY_ID); + pendingReview.setPayload(null); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null); + + verify(entityService).deleteEntity(GRAPH_ID, ENTITY_ID); + } + + @Test + void approveReview_updateEntity_appliesChange() { + pendingReview.setOperationType("UPDATE_ENTITY"); + pendingReview.setEntityId(ENTITY_ID); + pendingReview.setPayload("{\"name\":\"Updated\"}"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null); + + verify(entityService).updateEntity(eq(GRAPH_ID), eq(ENTITY_ID), any()); + } + + @Test + void approveReview_createRelation_appliesChange() { + pendingReview.setOperationType("CREATE_RELATION"); + pendingReview.setPayload("{\"sourceEntityId\":\"a\",\"targetEntityId\":\"b\",\"relationType\":\"HAS_FIELD\"}"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null); + + verify(relationService).createRelation(eq(GRAPH_ID), any()); + } + + @Test + void approveReview_invalidPayload_throwsBusinessException() { + pendingReview.setOperationType("CREATE_ENTITY"); + pendingReview.setPayload("not valid json {{"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + + assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void approveReview_batchDeleteEntity_appliesChange() { + pendingReview.setOperationType("BATCH_DELETE_ENTITY"); + pendingReview.setPayload("{\"ids\":[\"id-1\",\"id-2\",\"id-3\"]}"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null); + + verify(entityService).batchDeleteEntities(eq(GRAPH_ID), eq(List.of("id-1", "id-2", "id-3"))); + } + + @Test + void approveReview_batchDeleteRelation_appliesChange() { + pendingReview.setOperationType("BATCH_DELETE_RELATION"); + pendingReview.setPayload("{\"ids\":[\"rel-1\",\"rel-2\"]}"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null); + + verify(relationService).batchDeleteRelations(eq(GRAPH_ID), eq(List.of("rel-1", "rel-2"))); + } + + // ----------------------------------------------------------------------- + // rejectReview + // ----------------------------------------------------------------------- + + @Test + void rejectReview_success() { + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview); + + EditReviewVO result = reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, "不合适"); + + assertThat(result).isNotNull(); + assertThat(pendingReview.getStatus()).isEqualTo("REJECTED"); + assertThat(pendingReview.getReviewedBy()).isEqualTo(REVIEWER_ID); + assertThat(pendingReview.getReviewComment()).isEqualTo("不合适"); + assertThat(pendingReview.getReviewedAt()).isNotNull(); + + // Verify no change was applied + verifyNoInteractions(entityService); + verifyNoInteractions(relationService); + } + + @Test + void rejectReview_notFound_throwsBusinessException() { + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void rejectReview_alreadyProcessed_throwsBusinessException() { + pendingReview.setStatus("REJECTED"); + + when(reviewRepository.findById(REVIEW_ID, GRAPH_ID)) + .thenReturn(Optional.of(pendingReview)); + + assertThatThrownBy(() -> reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null)) + .isInstanceOf(BusinessException.class); + } + + // ----------------------------------------------------------------------- + // listPendingReviews + // ----------------------------------------------------------------------- + + @Test + void listPendingReviews_returnsPagedResult() { + when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 20)) + .thenReturn(List.of(pendingReview)); + when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(1L); + + var result = reviewService.listPendingReviews(GRAPH_ID, 0, 20); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + void listPendingReviews_clampsPageSize() { + when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 200)) + .thenReturn(List.of()); + when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(0L); + + reviewService.listPendingReviews(GRAPH_ID, 0, 999); + + verify(reviewRepository).findPendingByGraphId(GRAPH_ID, 0L, 200); + } + + @Test + void listPendingReviews_negativePage_clampedToZero() { + when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 20)) + .thenReturn(List.of()); + when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(0L); + + var result = reviewService.listPendingReviews(GRAPH_ID, -1, 20); + + assertThat(result.getPage()).isEqualTo(0); + } + + // ----------------------------------------------------------------------- + // listReviews + // ----------------------------------------------------------------------- + + @Test + void listReviews_withStatusFilter() { + when(reviewRepository.findByGraphId(GRAPH_ID, "APPROVED", 0L, 20)) + .thenReturn(List.of()); + when(reviewRepository.countByGraphId(GRAPH_ID, "APPROVED")).thenReturn(0L); + + var result = reviewService.listReviews(GRAPH_ID, "APPROVED", 0, 20); + + assertThat(result.getContent()).isEmpty(); + verify(reviewRepository).findByGraphId(GRAPH_ID, "APPROVED", 0L, 20); + } + + @Test + void listReviews_withoutStatusFilter() { + when(reviewRepository.findByGraphId(GRAPH_ID, null, 0L, 20)) + .thenReturn(List.of(pendingReview)); + when(reviewRepository.countByGraphId(GRAPH_ID, null)).thenReturn(1L); + + var result = reviewService.listReviews(GRAPH_ID, null, 0, 20); + + assertThat(result.getContent()).hasSize(1); + } +} diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewControllerTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewControllerTest.java new file mode 100644 index 0000000..b36d6e5 --- /dev/null +++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/interfaces/rest/EditReviewControllerTest.java @@ -0,0 +1,239 @@ +package com.datamate.knowledgegraph.interfaces.rest; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.interfaces.PagedResponse; +import com.datamate.knowledgegraph.application.EditReviewService; +import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode; +import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class EditReviewControllerTest { + + private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String REVIEW_ID = "660e8400-e29b-41d4-a716-446655440001"; + private static final String ENTITY_ID = "770e8400-e29b-41d4-a716-446655440002"; + + @Mock + private EditReviewService reviewService; + + @InjectMocks + private EditReviewController controller; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + } + + // ----------------------------------------------------------------------- + // POST /knowledge-graph/{graphId}/review/submit + // ----------------------------------------------------------------------- + + @Test + void submitReview_success() throws Exception { + EditReviewVO vo = buildReviewVO("PENDING"); + when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("user-1"))) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID) + .contentType(MediaType.APPLICATION_JSON) + .header("X-User-Id", "user-1") + .content(objectMapper.writeValueAsString(Map.of( + "operationType", "CREATE_ENTITY", + "payload", "{\"name\":\"Test\",\"type\":\"Dataset\"}" + )))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(REVIEW_ID)) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.operationType").value("CREATE_ENTITY")); + } + + @Test + void submitReview_delegatesToService() throws Exception { + EditReviewVO vo = buildReviewVO("PENDING"); + when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("user-1"))) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID) + .contentType(MediaType.APPLICATION_JSON) + .header("X-User-Id", "user-1") + .content(objectMapper.writeValueAsString(Map.of( + "operationType", "DELETE_ENTITY", + "entityId", ENTITY_ID + )))) + .andExpect(status().isCreated()); + + verify(reviewService).submitReview(eq(GRAPH_ID), any(), eq("user-1")); + } + + @Test + void submitReview_defaultUserId_whenHeaderMissing() throws Exception { + EditReviewVO vo = buildReviewVO("PENDING"); + when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("anonymous"))) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "operationType", "CREATE_ENTITY", + "payload", "{\"name\":\"Test\"}" + )))) + .andExpect(status().isCreated()); + + verify(reviewService).submitReview(eq(GRAPH_ID), any(), eq("anonymous")); + } + + // ----------------------------------------------------------------------- + // POST /knowledge-graph/{graphId}/review/{reviewId}/approve + // ----------------------------------------------------------------------- + + @Test + void approveReview_success() throws Exception { + EditReviewVO vo = buildReviewVO("APPROVED"); + when(reviewService.approveReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), isNull())) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/approve", GRAPH_ID, REVIEW_ID) + .contentType(MediaType.APPLICATION_JSON) + .header("X-User-Id", "reviewer-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + void approveReview_withComment() throws Exception { + EditReviewVO vo = buildReviewVO("APPROVED"); + when(reviewService.approveReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), eq("LGTM"))) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/approve", GRAPH_ID, REVIEW_ID) + .contentType(MediaType.APPLICATION_JSON) + .header("X-User-Id", "reviewer-1") + .content(objectMapper.writeValueAsString(Map.of("comment", "LGTM")))) + .andExpect(status().isOk()); + + verify(reviewService).approveReview(GRAPH_ID, REVIEW_ID, "reviewer-1", "LGTM"); + } + + // ----------------------------------------------------------------------- + // POST /knowledge-graph/{graphId}/review/{reviewId}/reject + // ----------------------------------------------------------------------- + + @Test + void rejectReview_success() throws Exception { + EditReviewVO vo = buildReviewVO("REJECTED"); + when(reviewService.rejectReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), eq("不合适"))) + .thenReturn(vo); + + mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/reject", GRAPH_ID, REVIEW_ID) + .contentType(MediaType.APPLICATION_JSON) + .header("X-User-Id", "reviewer-1") + .content(objectMapper.writeValueAsString(Map.of("comment", "不合适")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REJECTED")); + + verify(reviewService).rejectReview(GRAPH_ID, REVIEW_ID, "reviewer-1", "不合适"); + } + + // ----------------------------------------------------------------------- + // GET /knowledge-graph/{graphId}/review/pending + // ----------------------------------------------------------------------- + + @Test + void listPendingReviews_success() throws Exception { + EditReviewVO vo = buildReviewVO("PENDING"); + PagedResponse page = PagedResponse.of(List.of(vo), 0, 1, 1); + when(reviewService.listPendingReviews(GRAPH_ID, 0, 20)).thenReturn(page); + + mockMvc.perform(get("/knowledge-graph/{graphId}/review/pending", GRAPH_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(REVIEW_ID)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void listPendingReviews_customPageSize() throws Exception { + PagedResponse page = PagedResponse.of(List.of(), 0, 0, 0); + when(reviewService.listPendingReviews(GRAPH_ID, 1, 10)).thenReturn(page); + + mockMvc.perform(get("/knowledge-graph/{graphId}/review/pending", GRAPH_ID) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()); + + verify(reviewService).listPendingReviews(GRAPH_ID, 1, 10); + } + + // ----------------------------------------------------------------------- + // GET /knowledge-graph/{graphId}/review + // ----------------------------------------------------------------------- + + @Test + void listReviews_withStatusFilter() throws Exception { + PagedResponse page = PagedResponse.of(List.of(), 0, 0, 0); + when(reviewService.listReviews(GRAPH_ID, "APPROVED", 0, 20)).thenReturn(page); + + mockMvc.perform(get("/knowledge-graph/{graphId}/review", GRAPH_ID) + .param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isEmpty()); + + verify(reviewService).listReviews(GRAPH_ID, "APPROVED", 0, 20); + } + + @Test + void listReviews_withoutStatusFilter() throws Exception { + EditReviewVO vo = buildReviewVO("PENDING"); + PagedResponse page = PagedResponse.of(List.of(vo), 0, 1, 1); + when(reviewService.listReviews(GRAPH_ID, null, 0, 20)).thenReturn(page); + + mockMvc.perform(get("/knowledge-graph/{graphId}/review", GRAPH_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(REVIEW_ID)); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private EditReviewVO buildReviewVO(String status) { + return EditReviewVO.builder() + .id(REVIEW_ID) + .graphId(GRAPH_ID) + .operationType("CREATE_ENTITY") + .payload("{\"name\":\"Test\",\"type\":\"Dataset\"}") + .status(status) + .submittedBy("user-1") + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx b/frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx index edf8056..b0f37e9 100644 --- a/frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx +++ b/frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx @@ -1,19 +1,26 @@ import { useState, useCallback, useEffect } from "react"; -import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, message } from "antd"; -import { Network, RotateCcw } from "lucide-react"; +import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, Switch, message, Popconfirm } from "antd"; +import { Network, RotateCcw, Plus, Link2, Trash2 } from "lucide-react"; import { useSearchParams } from "react-router"; +import { useAppSelector } from "@/store/hooks"; +import { hasPermission, PermissionCodes } from "@/auth/permissions"; import GraphCanvas from "../components/GraphCanvas"; import SearchPanel from "../components/SearchPanel"; import QueryBuilder from "../components/QueryBuilder"; import NodeDetail from "../components/NodeDetail"; import RelationDetail from "../components/RelationDetail"; +import EntityEditForm from "../components/EntityEditForm"; +import RelationEditForm from "../components/RelationEditForm"; +import ReviewPanel from "../components/ReviewPanel"; import useGraphData from "../hooks/useGraphData"; import useGraphLayout, { LAYOUT_OPTIONS } from "../hooks/useGraphLayout"; +import type { GraphEntity, RelationVO } from "../knowledge-graph.model"; import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR, ENTITY_TYPE_LABELS, } from "../knowledge-graph.const"; +import * as api from "../knowledge-graph.api"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -22,6 +29,10 @@ export default function KnowledgeGraphPage() { const [graphId, setGraphId] = useState(() => params.get("graphId") ?? ""); const [graphIdInput, setGraphIdInput] = useState(() => params.get("graphId") ?? ""); + // Permission check + const permissions = useAppSelector((state) => state.auth.permissions); + const canWrite = hasPermission(permissions, PermissionCodes.knowledgeGraphWrite); + const { graphData, loading, @@ -38,12 +49,26 @@ export default function KnowledgeGraphPage() { const { layoutType, setLayoutType } = useGraphLayout(); + // Edit mode (only allowed with write permission) + const [editMode, setEditMode] = useState(false); + // Detail panel state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [nodeDetailOpen, setNodeDetailOpen] = useState(false); const [relationDetailOpen, setRelationDetailOpen] = useState(false); + // Edit form state + const [entityFormOpen, setEntityFormOpen] = useState(false); + const [editingEntity, setEditingEntity] = useState(null); + const [relationFormOpen, setRelationFormOpen] = useState(false); + const [editingRelation, setEditingRelation] = useState(null); + const [defaultRelationSourceId, setDefaultRelationSourceId] = useState(); + + // Batch selection state + const [selectedNodeIds, setSelectedNodeIds] = useState([]); + const [selectedEdgeIds, setSelectedEdgeIds] = useState([]); + // Load graph when graphId changes useEffect(() => { if (graphId && UUID_REGEX.test(graphId)) { @@ -110,7 +135,6 @@ export default function KnowledgeGraphPage() { const handleSearchResultClick = useCallback( (entityId: string) => { handleNodeClick(entityId); - // If the entity is not in the current graph, expand it if (!graphData.nodes.find((n) => n.id === entityId) && graphId) { expandNode(graphId, entityId); } @@ -124,9 +148,123 @@ export default function KnowledgeGraphPage() { setNodeDetailOpen(false); }, []); + const handleSelectionChange = useCallback((nodeIds: string[], edgeIds: string[]) => { + setSelectedNodeIds(nodeIds); + setSelectedEdgeIds(edgeIds); + }, []); + + // ---- Edit handlers ---- + + const refreshGraph = useCallback(() => { + if (graphId) { + loadInitialData(graphId); + } + }, [graphId, loadInitialData]); + + const handleEditEntity = useCallback((entity: GraphEntity) => { + setEditingEntity(entity); + setEntityFormOpen(true); + }, []); + + const handleCreateEntity = useCallback(() => { + setEditingEntity(null); + setEntityFormOpen(true); + }, []); + + const handleDeleteEntity = useCallback( + async (entityId: string) => { + if (!graphId) return; + try { + await api.submitReview(graphId, { + operationType: "DELETE_ENTITY", + entityId, + }); + message.success("实体删除已提交审核"); + setNodeDetailOpen(false); + setSelectedNodeId(null); + refreshGraph(); + } catch { + message.error("提交实体删除审核失败"); + } + }, + [graphId, refreshGraph] + ); + + const handleEditRelation = useCallback((relation: RelationVO) => { + setEditingRelation(relation); + setDefaultRelationSourceId(undefined); + setRelationFormOpen(true); + }, []); + + const handleCreateRelation = useCallback((sourceEntityId?: string) => { + setEditingRelation(null); + setDefaultRelationSourceId(sourceEntityId); + setRelationFormOpen(true); + }, []); + + const handleDeleteRelation = useCallback( + async (relationId: string) => { + if (!graphId) return; + try { + await api.submitReview(graphId, { + operationType: "DELETE_RELATION", + relationId, + }); + message.success("关系删除已提交审核"); + setRelationDetailOpen(false); + setSelectedEdgeId(null); + refreshGraph(); + } catch { + message.error("提交关系删除审核失败"); + } + }, + [graphId, refreshGraph] + ); + + const handleEntityFormSuccess = useCallback(() => { + refreshGraph(); + }, [refreshGraph]); + + const handleRelationFormSuccess = useCallback(() => { + refreshGraph(); + }, [refreshGraph]); + + // ---- Batch operations ---- + + const handleBatchDeleteNodes = useCallback(async () => { + if (!graphId || selectedNodeIds.length === 0) return; + try { + await api.submitReview(graphId, { + operationType: "BATCH_DELETE_ENTITY", + payload: JSON.stringify({ ids: selectedNodeIds }), + }); + message.success("批量删除实体已提交审核"); + setSelectedNodeIds([]); + refreshGraph(); + } catch { + message.error("提交批量删除实体审核失败"); + } + }, [graphId, selectedNodeIds, refreshGraph]); + + const handleBatchDeleteEdges = useCallback(async () => { + if (!graphId || selectedEdgeIds.length === 0) return; + try { + await api.submitReview(graphId, { + operationType: "BATCH_DELETE_RELATION", + payload: JSON.stringify({ ids: selectedEdgeIds }), + }); + message.success("批量删除关系已提交审核"); + setSelectedEdgeIds([]); + refreshGraph(); + } catch { + message.error("提交批量删除关系审核失败"); + } + }, [graphId, selectedEdgeIds, refreshGraph]); + const hasGraph = graphId && UUID_REGEX.test(graphId); const nodeCount = graphData.nodes.length; const edgeCount = graphData.edges.length; + const hasBatchSelection = editMode && (selectedNodeIds.length > 1 || selectedEdgeIds.length > 1); // Collect unique entity types in current graph for legend const entityTypes = [...new Set(graphData.nodes.map((n) => n.data.type))].sort(); @@ -139,6 +277,16 @@ export default function KnowledgeGraphPage() { 知识图谱浏览器 + {hasGraph && canWrite && ( +

+ 编辑模式 + +
+ )} {/* Graph ID Input + Controls */} @@ -176,6 +324,62 @@ export default function KnowledgeGraphPage() { )} + + {/* Edit mode toolbar */} + {hasGraph && editMode && ( + <> + + + + )} + + {/* Batch operations toolbar */} + {hasBatchSelection && ( + <> + {selectedNodeIds.length > 1 && ( + + + + )} + {selectedEdgeIds.length > 1 && ( + + + + )} + + )} {/* Legend */} @@ -223,6 +427,11 @@ export default function KnowledgeGraphPage() { /> ), }, + { + key: "review", + label: "审核", + children: , + }, ]} /> @@ -236,10 +445,12 @@ export default function KnowledgeGraphPage() { loading={loading} layoutType={layoutType} highlightedNodeIds={highlightedNodeIds} + editMode={editMode} onNodeClick={handleNodeClick} onEdgeClick={handleEdgeClick} onNodeDoubleClick={handleNodeDoubleClick} onCanvasClick={handleCanvasClick} + onSelectionChange={handleSelectionChange} /> ) : (
@@ -257,17 +468,41 @@ export default function KnowledgeGraphPage() { graphId={graphId} entityId={selectedNodeId} open={nodeDetailOpen} + editMode={editMode} onClose={() => setNodeDetailOpen(false)} onExpandNode={handleExpandNode} onRelationClick={handleRelationClick} onEntityNavigate={handleEntityNavigate} + onEditEntity={handleEditEntity} + onDeleteEntity={handleDeleteEntity} + onCreateRelation={handleCreateRelation} /> setRelationDetailOpen(false)} onEntityNavigate={handleEntityNavigate} + onEditRelation={handleEditRelation} + onDeleteRelation={handleDeleteRelation} + /> + + {/* Edit forms */} + setEntityFormOpen(false)} + onSuccess={handleEntityFormSuccess} + /> + setRelationFormOpen(false)} + onSuccess={handleRelationFormSuccess} + defaultSourceId={defaultRelationSourceId} />
); diff --git a/frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx b/frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx new file mode 100644 index 0000000..88a673f --- /dev/null +++ b/frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx @@ -0,0 +1,143 @@ +import { useEffect } from "react"; +import { Modal, Form, Input, Select, InputNumber, message } from "antd"; +import type { GraphEntity } from "../knowledge-graph.model"; +import { ENTITY_TYPES, ENTITY_TYPE_LABELS } from "../knowledge-graph.const"; +import * as api from "../knowledge-graph.api"; + +interface EntityEditFormProps { + graphId: string; + entity?: GraphEntity | null; + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export default function EntityEditForm({ + graphId, + entity, + open, + onClose, + onSuccess, +}: EntityEditFormProps) { + const [form] = Form.useForm(); + const isEdit = !!entity; + + useEffect(() => { + if (open && entity) { + form.setFieldsValue({ + name: entity.name, + type: entity.type, + description: entity.description ?? "", + aliases: entity.aliases?.join(", ") ?? "", + confidence: entity.confidence ?? 1.0, + }); + } else if (open) { + form.resetFields(); + } + }, [open, entity, form]); + + const handleSubmit = async () => { + let values; + try { + values = await form.validateFields(); + } catch { + return; // Form validation failed — Antd shows inline errors + } + + const parsedAliases = values.aliases + ? values.aliases + .split(",") + .map((a: string) => a.trim()) + .filter(Boolean) + : []; + + try { + if (isEdit && entity) { + const payload = JSON.stringify({ + name: values.name, + description: values.description || undefined, + aliases: parsedAliases.length > 0 ? parsedAliases : undefined, + properties: entity.properties, + confidence: values.confidence, + }); + await api.submitReview(graphId, { + operationType: "UPDATE_ENTITY", + entityId: entity.id, + payload, + }); + message.success("实体更新已提交审核"); + } else { + const payload = JSON.stringify({ + name: values.name, + type: values.type, + description: values.description || undefined, + aliases: parsedAliases.length > 0 ? parsedAliases : undefined, + properties: {}, + confidence: values.confidence, + }); + await api.submitReview(graphId, { + operationType: "CREATE_ENTITY", + payload, + }); + message.success("实体创建已提交审核"); + } + onSuccess(); + onClose(); + } catch { + message.error(isEdit ? "提交实体更新审核失败" : "提交实体创建审核失败"); + } + }; + + return ( + +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx b/frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx index c0b1c7a..27b2f57 100644 --- a/frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx +++ b/frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx @@ -10,10 +10,12 @@ interface GraphCanvasProps { loading?: boolean; layoutType: LayoutType; highlightedNodeIds?: Set; + editMode?: boolean; onNodeClick?: (nodeId: string) => void; onEdgeClick?: (edgeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void; onCanvasClick?: () => void; + onSelectionChange?: (nodeIds: string[], edgeIds: string[]) => void; } function GraphCanvas({ @@ -21,10 +23,12 @@ function GraphCanvas({ loading = false, layoutType, highlightedNodeIds, + editMode = false, onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick, + onSelectionChange, }: GraphCanvasProps) { const containerRef = useRef(null); const graphRef = useRef(null); @@ -33,7 +37,7 @@ function GraphCanvas({ useEffect(() => { if (!containerRef.current) return; - const options = createGraphOptions(containerRef.current); + const options = createGraphOptions(containerRef.current, editMode); const graph = new Graph(options); graphRef.current = graph; @@ -43,7 +47,8 @@ function GraphCanvas({ graphRef.current = null; graph.destroy(); }; - }, []); + // editMode is intentionally included so the graph re-creates with correct multi-select setting + }, [editMode]); // Update data (with large-graph performance optimization) useEffect(() => { @@ -120,6 +125,25 @@ function GraphCanvas({ }); }, [highlightedNodeIds, data]); + // Helper: query selected elements from graph and notify parent + const emitSelectionChange = useCallback(() => { + const graph = graphRef.current; + if (!graph || !onSelectionChange) return; + // Defer to next tick so G6 internal state has settled + setTimeout(() => { + try { + const selectedNodes = graph.getElementDataByState("node", "selected"); + const selectedEdges = graph.getElementDataByState("edge", "selected"); + onSelectionChange( + selectedNodes.map((n: { id: string }) => n.id), + selectedEdges.map((e: { id: string }) => e.id) + ); + } catch { + // graph may be destroyed + } + }, 0); + }, [onSelectionChange]); + // Bind events useEffect(() => { const graph = graphRef.current; @@ -127,15 +151,18 @@ function GraphCanvas({ const handleNodeClick = (event: { target: { id: string } }) => { onNodeClick?.(event.target.id); + emitSelectionChange(); }; const handleEdgeClick = (event: { target: { id: string } }) => { onEdgeClick?.(event.target.id); + emitSelectionChange(); }; const handleNodeDblClick = (event: { target: { id: string } }) => { onNodeDoubleClick?.(event.target.id); }; const handleCanvasClick = () => { onCanvasClick?.(); + emitSelectionChange(); }; graph.on("node:click", handleNodeClick); @@ -149,7 +176,7 @@ function GraphCanvas({ graph.off("node:dblclick", handleNodeDblClick); graph.off("canvas:click", handleCanvasClick); }; - }, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick]); + }, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick, emitSelectionChange]); // Fit view helper const handleFitView = useCallback(() => { diff --git a/frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx b/frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx index 49e378c..012347d 100644 --- a/frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx +++ b/frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, message } from "antd"; -import { Expand } from "lucide-react"; +import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, Popconfirm, Space, message } from "antd"; +import { Expand, Pencil, Trash2 } from "lucide-react"; import type { GraphEntity, RelationVO, PagedResponse } from "../knowledge-graph.model"; import { ENTITY_TYPE_LABELS, @@ -14,20 +14,28 @@ interface NodeDetailProps { graphId: string; entityId: string | null; open: boolean; + editMode?: boolean; onClose: () => void; onExpandNode: (entityId: string) => void; onRelationClick: (relationId: string) => void; onEntityNavigate: (entityId: string) => void; + onEditEntity?: (entity: GraphEntity) => void; + onDeleteEntity?: (entityId: string) => void; + onCreateRelation?: (sourceEntityId: string) => void; } export default function NodeDetail({ graphId, entityId, open, + editMode = false, onClose, onExpandNode, onRelationClick, onEntityNavigate, + onEditEntity, + onDeleteEntity, + onCreateRelation, }: NodeDetailProps) { const [entity, setEntity] = useState(null); const [relations, setRelations] = useState([]); @@ -58,6 +66,12 @@ export default function NodeDetail({ }); }, [graphId, entityId, open]); + const handleDelete = () => { + if (entityId) { + onDeleteEntity?.(entityId); + } + }; + return ( } - onClick={() => onExpandNode(entityId)} - > - 展开邻居 - + + {editMode && entity && ( + <> + + + + + + )} + + ) } > @@ -130,7 +172,18 @@ export default function NodeDetail({ )} -

关系列表 ({relations.length})

+
+

关系列表 ({relations.length})

+ {editMode && entityId && ( + + )} +
{relations.length > 0 ? ( void; onEntityNavigate: (entityId: string) => void; + onEditRelation?: (relation: RelationVO) => void; + onDeleteRelation?: (relationId: string) => void; } export default function RelationDetail({ graphId, relationId, open, + editMode = false, onClose, onEntityNavigate, + onEditRelation, + onDeleteRelation, }: RelationDetailProps) { const [relation, setRelation] = useState(null); const [loading, setLoading] = useState(false); @@ -42,8 +49,46 @@ export default function RelationDetail({ .finally(() => setLoading(false)); }, [graphId, relationId, open]); + const handleDelete = () => { + if (relationId) { + onDeleteRelation?.(relationId); + } + }; + return ( - + + + + + + + ) + } + > {relation ? (
diff --git a/frontend/src/pages/KnowledgeGraph/components/RelationEditForm.tsx b/frontend/src/pages/KnowledgeGraph/components/RelationEditForm.tsx new file mode 100644 index 0000000..e7f4b6a --- /dev/null +++ b/frontend/src/pages/KnowledgeGraph/components/RelationEditForm.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState, useCallback } from "react"; +import { Modal, Form, Select, InputNumber, message, Spin } from "antd"; +import type { RelationVO, GraphEntity } from "../knowledge-graph.model"; +import { RELATION_TYPES, RELATION_TYPE_LABELS } from "../knowledge-graph.const"; +import * as api from "../knowledge-graph.api"; + +interface RelationEditFormProps { + graphId: string; + relation?: RelationVO | null; + open: boolean; + onClose: () => void; + onSuccess: () => void; + /** Pre-fill source entity when creating from a node context */ + defaultSourceId?: string; +} + +export default function RelationEditForm({ + graphId, + relation, + open, + onClose, + onSuccess, + defaultSourceId, +}: RelationEditFormProps) { + const [form] = Form.useForm(); + const isEdit = !!relation; + const [entityOptions, setEntityOptions] = useState< + { label: string; value: string }[] + >([]); + const [searchLoading, setSearchLoading] = useState(false); + + useEffect(() => { + if (open && relation) { + form.setFieldsValue({ + relationType: relation.relationType, + sourceEntityId: relation.sourceEntityId, + targetEntityId: relation.targetEntityId, + weight: relation.weight, + confidence: relation.confidence, + }); + } else if (open) { + form.resetFields(); + if (defaultSourceId) { + form.setFieldsValue({ sourceEntityId: defaultSourceId }); + } + } + }, [open, relation, form, defaultSourceId]); + + const searchEntities = useCallback( + async (keyword: string) => { + if (!keyword.trim() || !graphId) return; + setSearchLoading(true); + try { + const result = await api.listEntitiesPaged(graphId, { + keyword, + page: 0, + size: 20, + }); + setEntityOptions( + result.content.map((e: GraphEntity) => ({ + label: `${e.name} (${e.type})`, + value: e.id, + })) + ); + } catch { + // ignore + } finally { + setSearchLoading(false); + } + }, + [graphId] + ); + + const handleSubmit = async () => { + let values; + try { + values = await form.validateFields(); + } catch { + return; // Form validation failed — Antd shows inline errors + } + + try { + if (isEdit && relation) { + const payload = JSON.stringify({ + relationType: values.relationType, + weight: values.weight, + confidence: values.confidence, + }); + await api.submitReview(graphId, { + operationType: "UPDATE_RELATION", + relationId: relation.id, + payload, + }); + message.success("关系更新已提交审核"); + } else { + const payload = JSON.stringify({ + sourceEntityId: values.sourceEntityId, + targetEntityId: values.targetEntityId, + relationType: values.relationType, + weight: values.weight, + confidence: values.confidence, + }); + await api.submitReview(graphId, { + operationType: "CREATE_RELATION", + payload, + }); + message.success("关系创建已提交审核"); + } + onSuccess(); + onClose(); + } catch { + message.error(isEdit ? "提交关系更新审核失败" : "提交关系创建审核失败"); + } + }; + + return ( + +
+ + : null} + /> + + + +