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 && (
+ <>
+ }
+ onClick={handleCreateEntity}
+ >
+ 创建实体
+
+ }
+ onClick={() => handleCreateRelation()}
+ >
+ 创建关系
+
+ >
+ )}
+
+ {/* Batch operations toolbar */}
+ {hasBatchSelection && (
+ <>
+ {selectedNodeIds.length > 1 && (
+
+ }
+ >
+ 批量删除实体 ({selectedNodeIds.length})
+
+
+ )}
+ {selectedEdgeIds.length > 1 && (
+
+ }
+ >
+ 批量删除关系 ({selectedEdgeIds.length})
+
+
+ )}
+ >
+ )}
{/* 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 && (
+ <>
+ }
+ onClick={() => onEditEntity?.(entity)}
+ >
+ 编辑
+
+
+ }
+ >
+ 删除
+
+
+ >
+ )}
+ }
+ onClick={() => onExpandNode(entityId)}
+ >
+ 展开邻居
+
+
)
}
>
@@ -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 (
-
+
+ }
+ onClick={() => onEditRelation?.(relation)}
+ >
+ 编辑
+
+
+ }
+ >
+ 删除
+
+
+
+ )
+ }
+ >
{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}
+ />
+
+
+
+ : null}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx b/frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
new file mode 100644
index 0000000..5c1dd0a
--- /dev/null
+++ b/frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
@@ -0,0 +1,206 @@
+import { useState, useCallback, useEffect } from "react";
+import { List, Tag, Button, Empty, Spin, Popconfirm, Input, message } from "antd";
+import { Check, X } from "lucide-react";
+import type { EditReviewVO, PagedResponse } from "../knowledge-graph.model";
+import * as api from "../knowledge-graph.api";
+
+const OPERATION_LABELS: Record
= {
+ CREATE_ENTITY: "创建实体",
+ UPDATE_ENTITY: "更新实体",
+ DELETE_ENTITY: "删除实体",
+ CREATE_RELATION: "创建关系",
+ UPDATE_RELATION: "更新关系",
+ DELETE_RELATION: "删除关系",
+};
+
+const STATUS_COLORS: Record = {
+ PENDING: "orange",
+ APPROVED: "green",
+ REJECTED: "red",
+};
+
+const STATUS_LABELS: Record = {
+ PENDING: "待审核",
+ APPROVED: "已通过",
+ REJECTED: "已拒绝",
+};
+
+interface ReviewPanelProps {
+ graphId: string;
+}
+
+export default function ReviewPanel({ graphId }: ReviewPanelProps) {
+ const [reviews, setReviews] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [total, setTotal] = useState(0);
+
+ const loadReviews = useCallback(async () => {
+ if (!graphId) return;
+ setLoading(true);
+ try {
+ const result: PagedResponse = await api.listPendingReviews(
+ graphId,
+ { page: 0, size: 50 }
+ );
+ setReviews(result.content);
+ setTotal(result.totalElements);
+ } catch {
+ message.error("加载审核列表失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [graphId]);
+
+ useEffect(() => {
+ loadReviews();
+ }, [loadReviews]);
+
+ const handleApprove = useCallback(
+ async (reviewId: string) => {
+ try {
+ await api.approveReview(graphId, reviewId);
+ message.success("审核通过");
+ loadReviews();
+ } catch {
+ message.error("审核操作失败");
+ }
+ },
+ [graphId, loadReviews]
+ );
+
+ const handleReject = useCallback(
+ async (reviewId: string, comment: string) => {
+ try {
+ await api.rejectReview(graphId, reviewId, { comment });
+ message.success("已拒绝");
+ loadReviews();
+ } catch {
+ message.error("审核操作失败");
+ }
+ },
+ [graphId, loadReviews]
+ );
+
+ return (
+
+
+
+ 待审核: {total}
+
+
+
+
+
+ {reviews.length > 0 ? (
+ (
+
+ )}
+ />
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ReviewItem({
+ review,
+ onApprove,
+ onReject,
+}: {
+ review: EditReviewVO;
+ onApprove: (id: string) => void;
+ onReject: (id: string, comment: string) => void;
+}) {
+ const [rejectComment, setRejectComment] = useState("");
+
+ const payload = review.payload ? tryParsePayload(review.payload) : null;
+
+ return (
+
+
+
+
+ {STATUS_LABELS[review.status] ?? review.status}
+
+
+ {OPERATION_LABELS[review.operationType] ?? review.operationType}
+
+
+
+ {payload && (
+
+ {payload.name && 名称: {payload.name} }
+ {payload.relationType && 类型: {payload.relationType}}
+
+ )}
+
+
+ {review.submittedBy && 提交人: {review.submittedBy}}
+ {review.createdAt && {review.createdAt}}
+
+
+ {review.status === "PENDING" && (
+
+
}
+ onClick={() => onApprove(review.id)}
+ >
+ 通过
+
+
setRejectComment(e.target.value)}
+ />
+ }
+ onConfirm={() => {
+ onReject(review.id, rejectComment);
+ setRejectComment("");
+ }}
+ okText="确认拒绝"
+ cancelText="取消"
+ >
+ }
+ >
+ 拒绝
+
+
+
+ )}
+
+
+ );
+}
+
+function tryParsePayload(
+ payload: string
+): Record | null {
+ try {
+ return JSON.parse(payload);
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/pages/KnowledgeGraph/graphConfig.ts b/frontend/src/pages/KnowledgeGraph/graphConfig.ts
index cf8e09c..2eb0214 100644
--- a/frontend/src/pages/KnowledgeGraph/graphConfig.ts
+++ b/frontend/src/pages/KnowledgeGraph/graphConfig.ts
@@ -4,7 +4,7 @@ import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR } from "./knowledge-graph.cons
export const LARGE_GRAPH_THRESHOLD = 200;
/** Create the G6 v5 graph options. */
-export function createGraphOptions(container: HTMLElement) {
+export function createGraphOptions(container: HTMLElement, multiSelect = false) {
return {
container,
autoFit: "view" as const,
@@ -99,7 +99,7 @@ export function createGraphOptions(container: HTMLElement) {
"drag-element",
{
type: "click-select" as const,
- multiple: false,
+ multiple: multiSelect,
},
],
};
diff --git a/frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts b/frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts
index 5aee66a..0ee2585 100644
--- a/frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts
+++ b/frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts
@@ -7,9 +7,10 @@ import type {
PagedResponse,
PathVO,
AllPathsVO,
+ EditReviewVO,
} from "./knowledge-graph.model";
-const BASE = "/knowledge-graph";
+const BASE = "/api/knowledge-graph";
// ---- Entity ----
@@ -33,7 +34,7 @@ export function listEntitiesPaged(
export function createEntity(
graphId: string,
- data: { name: string; type: string; description?: string; properties?: Record }
+ data: { name: string; type: string; description?: string; aliases?: string[]; properties?: Record; confidence?: number }
): Promise {
return post(`${BASE}/${graphId}/entities`, data);
}
@@ -41,7 +42,7 @@ export function createEntity(
export function updateEntity(
graphId: string,
entityId: string,
- data: { name?: string; type?: string; description?: string; properties?: Record }
+ data: { name?: string; description?: string; aliases?: string[]; properties?: Record; confidence?: number }
): Promise {
return put(`${BASE}/${graphId}/entities/${entityId}`, data);
}
@@ -146,3 +147,47 @@ export function getEntityNeighbors(
): Promise {
return get(`${BASE}/${graphId}/entities/${entityId}/neighbors`, params ?? null);
}
+
+// ---- Review ----
+
+export function submitReview(
+ graphId: string,
+ data: {
+ operationType: string;
+ entityId?: string;
+ relationId?: string;
+ payload?: string;
+ }
+): Promise {
+ return post(`${BASE}/${graphId}/review/submit`, data);
+}
+
+export function approveReview(
+ graphId: string,
+ reviewId: string,
+ data?: { comment?: string }
+): Promise {
+ return post(`${BASE}/${graphId}/review/${reviewId}/approve`, data ?? {});
+}
+
+export function rejectReview(
+ graphId: string,
+ reviewId: string,
+ data?: { comment?: string }
+): Promise {
+ return post(`${BASE}/${graphId}/review/${reviewId}/reject`, data ?? {});
+}
+
+export function listPendingReviews(
+ graphId: string,
+ params?: { page?: number; size?: number }
+): Promise> {
+ return get(`${BASE}/${graphId}/review/pending`, params ?? null);
+}
+
+export function listReviews(
+ graphId: string,
+ params?: { status?: string; page?: number; size?: number }
+): Promise> {
+ return get(`${BASE}/${graphId}/review`, params ?? null);
+}
diff --git a/frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts b/frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts
index e25de5d..4cedd87 100644
--- a/frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts
+++ b/frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts
@@ -79,3 +79,30 @@ export interface AllPathsVO {
paths: PathVO[];
pathCount: number;
}
+
+// ---- Edit Review ----
+
+export type ReviewOperationType =
+ | "CREATE_ENTITY"
+ | "UPDATE_ENTITY"
+ | "DELETE_ENTITY"
+ | "CREATE_RELATION"
+ | "UPDATE_RELATION"
+ | "DELETE_RELATION";
+
+export type ReviewStatus = "PENDING" | "APPROVED" | "REJECTED";
+
+export interface EditReviewVO {
+ id: string;
+ graphId: string;
+ operationType: ReviewOperationType;
+ entityId?: string;
+ relationId?: string;
+ payload?: string;
+ status: ReviewStatus;
+ submittedBy?: string;
+ reviewedBy?: string;
+ reviewComment?: string;
+ createdAt?: string;
+ reviewedAt?: string;
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 1ecb015..f3b5013 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -18,10 +18,11 @@ export default defineConfig({
// "Origin, X-Requested-With, Content-Type, Accept",
// },
proxy: {
- "^/knowledge-graph": {
+ "/api/knowledge-graph": {
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
+ rewrite: (path) => path.replace(/^\/api/, ""),
},
"^/api": {
target: "http://localhost:8080", // 本地后端服务地址