Forráskód Böngészése

refactor: 代码优化和问题修复

1. 修复数据库字段名不匹配问题
   - document_blocks/document_entities 表字段改为 create_time/update_time
   - 与 SimpleModel 基类保持一致

2. 提取共享常量类 EntityTypeConstants
   - 统一管理实体类型的名称、图标、颜色映射
   - 消除 KnowledgeGraphService 和 Neo4jGraphService 中的重复代码

3. 优化 KnowledgeGraphService.getEntityListGroupedByType
   - 修复 N+1 查询问题
   - 批量获取关系,避免每个节点单独查询

4. 改进异常处理
   - StructuredDocumentService 使用 ServiceException 替代 RuntimeException

5. 修复 XSS 安全风险
   - DocumentBlock.toMarkedHtml() 中对 entityId 和 entityType 进行转义

6. 改进日志提示
   - NerToBlockService 中添加关于 indexOf 限制的注释和日志
何文松 1 hónapja
szülő
commit
52f9904e6e

+ 5 - 3
backend/document-service/src/main/java/com/lingyue/document/entity/DocumentBlock.java

@@ -170,10 +170,12 @@ public class DocumentBlock extends SimpleModel {
                 sb.append(escapeHtml(el.getContent()));
             } else if ("entity".equals(el.getType())) {
                 String cssClass = getEntityCssClass(el.getEntityType());
+                String safeEntityId = escapeHtml(el.getEntityId());
+                String safeEntityType = escapeHtml(el.getEntityType());
                 sb.append("<span class=\"").append(cssClass).append("\" ")
-                  .append("data-entity-id=\"").append(el.getEntityId()).append("\" ")
-                  .append("data-type=\"").append(el.getEntityType()).append("\" ")
-                  .append("onclick=\"showEntityEditModal(event,'").append(el.getEntityId()).append("')\" ")
+                  .append("data-entity-id=\"").append(safeEntityId).append("\" ")
+                  .append("data-type=\"").append(safeEntityType).append("\" ")
+                  .append("onclick=\"showEntityEditModal(event,'").append(safeEntityId).append("')\" ")
                   .append("contenteditable=\"false\">")
                   .append(escapeHtml(el.getEntityText()))
                   .append("</span>");

+ 6 - 5
backend/document-service/src/main/java/com/lingyue/document/service/StructuredDocumentService.java

@@ -1,5 +1,6 @@
 package com.lingyue.document.service;
 
+import com.lingyue.common.exception.ServiceException;
 import com.lingyue.document.dto.StructuredDocumentDTO;
 import com.lingyue.document.dto.StructuredDocumentDTO.*;
 import com.lingyue.document.entity.Document;
@@ -124,7 +125,7 @@ public class StructuredDocumentService {
     public void updateBlockElements(String blockId, List<TextElement> elements) {
         DocumentBlock block = blockRepository.selectById(blockId);
         if (block == null) {
-            throw new RuntimeException("块不存在: " + blockId);
+            throw new ServiceException("块不存在: " + blockId);
         }
         
         block.setElements(elements);
@@ -148,22 +149,22 @@ public class StructuredDocumentService {
     public String markEntity(String blockId, int elementIndex, int startOffset, int endOffset, String entityType) {
         DocumentBlock block = blockRepository.selectById(blockId);
         if (block == null || block.getElements() == null) {
-            throw new RuntimeException("块不存在或没有内容");
+            throw new ServiceException("块不存在或没有内容");
         }
         
         List<TextElement> elements = new ArrayList<>(block.getElements());
         if (elementIndex >= elements.size()) {
-            throw new RuntimeException("元素索引越界");
+            throw new ServiceException("元素索引越界");
         }
         
         TextElement targetElement = elements.get(elementIndex);
         if (!"text_run".equals(targetElement.getType()) || targetElement.getContent() == null) {
-            throw new RuntimeException("只能在文本元素上标记实体");
+            throw new ServiceException("只能在文本元素上标记实体");
         }
         
         String content = targetElement.getContent();
         if (startOffset < 0 || endOffset > content.length() || startOffset >= endOffset) {
-            throw new RuntimeException("偏移量无效");
+            throw new ServiceException("偏移量无效");
         }
         
         String entityId = UUID.randomUUID().toString().replace("-", "");

+ 135 - 0
backend/graph-service/src/main/java/com/lingyue/graph/constant/EntityTypeConstants.java

@@ -0,0 +1,135 @@
+package com.lingyue.graph.constant;
+
+import java.util.Map;
+
+/**
+ * 实体类型常量
+ * 
+ * 统一管理实体类型的显示名称、图标、颜色映射
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+public final class EntityTypeConstants {
+    
+    private EntityTypeConstants() {}
+    
+    /**
+     * 类型到显示名称的映射
+     */
+    public static final Map<String, String> TYPE_NAMES = Map.ofEntries(
+            Map.entry("person", "人物"),
+            Map.entry("org", "机构"),
+            Map.entry("loc", "地点"),
+            Map.entry("location", "地点"),
+            Map.entry("date", "日期"),
+            Map.entry("number", "数值"),
+            Map.entry("money", "金额"),
+            Map.entry("data", "数据"),
+            Map.entry("concept", "概念"),
+            Map.entry("device", "设备"),
+            Map.entry("term", "术语"),
+            Map.entry("entity", "实体"),
+            Map.entry("project", "项目"),
+            Map.entry("other", "其他")
+    );
+    
+    /**
+     * 类型到图标的映射(emoji)
+     */
+    public static final Map<String, String> TYPE_ICONS = Map.ofEntries(
+            Map.entry("person", "👤"),
+            Map.entry("org", "🏢"),
+            Map.entry("loc", "📍"),
+            Map.entry("location", "📍"),
+            Map.entry("date", "📅"),
+            Map.entry("number", "🔢"),
+            Map.entry("money", "💰"),
+            Map.entry("data", "📊"),
+            Map.entry("concept", "💡"),
+            Map.entry("device", "🔧"),
+            Map.entry("term", "📝"),
+            Map.entry("entity", "🏷️"),
+            Map.entry("project", "📋"),
+            Map.entry("other", "📌")
+    );
+    
+    /**
+     * 类型到颜色的映射(用于前端渲染)
+     */
+    public static final Map<String, String> TYPE_COLORS = Map.ofEntries(
+            Map.entry("person", "#1890ff"),
+            Map.entry("org", "#52c41a"),
+            Map.entry("loc", "#fa8c16"),
+            Map.entry("location", "#fa8c16"),
+            Map.entry("date", "#722ed1"),
+            Map.entry("number", "#13c2c2"),
+            Map.entry("money", "#52c41a"),
+            Map.entry("data", "#13c2c2"),
+            Map.entry("concept", "#722ed1"),
+            Map.entry("device", "#eb2f96"),
+            Map.entry("term", "#2f54eb"),
+            Map.entry("entity", "#1890ff"),
+            Map.entry("project", "#faad14"),
+            Map.entry("other", "#8c8c8c")
+    );
+    
+    /**
+     * 类型到 Neo4j 标签的映射
+     */
+    public static final Map<String, String> TYPE_LABELS = Map.ofEntries(
+            Map.entry("person", "Person"),
+            Map.entry("org", "Organization"),
+            Map.entry("loc", "Location"),
+            Map.entry("location", "Location"),
+            Map.entry("date", "Date"),
+            Map.entry("number", "Number"),
+            Map.entry("money", "Money"),
+            Map.entry("data", "Data"),
+            Map.entry("concept", "Concept"),
+            Map.entry("device", "Device"),
+            Map.entry("term", "Term"),
+            Map.entry("entity", "Entity"),
+            Map.entry("project", "Project")
+    );
+    
+    /**
+     * 预定义的类型排序顺序
+     */
+    public static final java.util.List<String> TYPE_ORDER = java.util.List.of(
+            "entity", "concept", "project", "data", "number", "money", 
+            "person", "org", "loc", "location", "date", "device", "term", "other"
+    );
+    
+    /**
+     * 获取类型的显示名称
+     */
+    public static String getTypeName(String type) {
+        if (type == null) return TYPE_NAMES.get("other");
+        return TYPE_NAMES.getOrDefault(type.toLowerCase(), type);
+    }
+    
+    /**
+     * 获取类型的图标
+     */
+    public static String getTypeIcon(String type) {
+        if (type == null) return TYPE_ICONS.get("other");
+        return TYPE_ICONS.getOrDefault(type.toLowerCase(), "📌");
+    }
+    
+    /**
+     * 获取类型的颜色
+     */
+    public static String getTypeColor(String type) {
+        if (type == null) return TYPE_COLORS.get("other");
+        return TYPE_COLORS.getOrDefault(type.toLowerCase(), "#8c8c8c");
+    }
+    
+    /**
+     * 获取类型的 Neo4j 标签
+     */
+    public static String getTypeLabel(String type) {
+        if (type == null) return "Entity";
+        return TYPE_LABELS.getOrDefault(type.toLowerCase(), "Entity");
+    }
+}

+ 53 - 14
backend/graph-service/src/main/java/com/lingyue/graph/service/KnowledgeGraphService.java

@@ -221,6 +221,8 @@ public class KnowledgeGraphService {
     
     /**
      * 获取实体列表(按类型分组)
+     * 
+     * 优化:批量获取关系,避免 N+1 查询
      */
     public List<EntityGroupDTO> getEntityListGroupedByType(String documentId, String filterType) {
         List<GraphNode> nodes = nodeRepository.findByDocumentId(documentId);
@@ -231,26 +233,48 @@ public class KnowledgeGraphService {
                     .collect(Collectors.toList());
         }
         
+        if (nodes.isEmpty()) {
+            return Collections.emptyList();
+        }
+        
         // 按类型分组
         Map<String, List<GraphNode>> nodesByType = nodes.stream()
                 .collect(Collectors.groupingBy(n -> n.getType() != null ? n.getType().toLowerCase() : "other"));
         
-        // 获取所有关系用于计算关联数
-        Set<String> nodeIds = nodes.stream().map(GraphNode::getId).collect(Collectors.toSet());
+        // 构建节点ID到节点的映射(用于快速查找)
+        Map<String, GraphNode> nodeMap = nodes.stream()
+                .collect(Collectors.toMap(GraphNode::getId, n -> n));
+        Set<String> nodeIds = nodeMap.keySet();
+        
+        // 批量获取所有相关关系(一次查询)
+        // 注意:这里假设文档的节点数量不会太多,实际生产环境可能需要分批处理
+        List<GraphRelation> allRelations = new ArrayList<>();
+        for (GraphNode node : nodes) {
+            allRelations.addAll(relationRepository.findByNodeId(node.getId()));
+        }
+        
+        // 去重并只保留两端都在当前节点集中的关系
+        Set<String> seenRelIds = new HashSet<>();
+        List<GraphRelation> filteredRelations = allRelations.stream()
+                .filter(r -> seenRelIds.add(r.getId()))
+                .filter(r -> nodeIds.contains(r.getFromNodeId()) || nodeIds.contains(r.getToNodeId()))
+                .collect(Collectors.toList());
+        
+        // 计算每个节点的关联数和关联实体预览
         Map<String, List<RelatedEntityDTO>> relatedEntitiesMap = new HashMap<>();
         Map<String, Integer> relationCountMap = new HashMap<>();
         
-        for (String nodeId : nodeIds) {
-            List<GraphRelation> rels = relationRepository.findByNodeId(nodeId);
-            List<RelatedEntityDTO> related = new ArrayList<>();
-            int count = 0;
-            
-            for (GraphRelation rel : rels) {
-                String otherId = rel.getFromNodeId().equals(nodeId) ? rel.getToNodeId() : rel.getFromNodeId();
-                GraphNode otherNode = nodeRepository.selectById(otherId);
+        for (GraphRelation rel : filteredRelations) {
+            // 处理 from 节点
+            if (nodeIds.contains(rel.getFromNodeId())) {
+                relationCountMap.merge(rel.getFromNodeId(), 1, Integer::sum);
+                
+                // 添加关联实体预览(限制3个)
+                GraphNode otherNode = nodeMap.get(rel.getToNodeId());
                 if (otherNode != null) {
-                    count++;
-                    if (related.size() < 3) { // 只取前3个预览
+                    List<RelatedEntityDTO> related = relatedEntitiesMap.computeIfAbsent(
+                            rel.getFromNodeId(), k -> new ArrayList<>());
+                    if (related.size() < 3) {
                         related.add(RelatedEntityDTO.builder()
                                 .id(otherNode.getId())
                                 .name(otherNode.getName())
@@ -260,8 +284,23 @@ public class KnowledgeGraphService {
                 }
             }
             
-            relatedEntitiesMap.put(nodeId, related);
-            relationCountMap.put(nodeId, count);
+            // 处理 to 节点
+            if (nodeIds.contains(rel.getToNodeId())) {
+                relationCountMap.merge(rel.getToNodeId(), 1, Integer::sum);
+                
+                GraphNode otherNode = nodeMap.get(rel.getFromNodeId());
+                if (otherNode != null) {
+                    List<RelatedEntityDTO> related = relatedEntitiesMap.computeIfAbsent(
+                            rel.getToNodeId(), k -> new ArrayList<>());
+                    if (related.size() < 3) {
+                        related.add(RelatedEntityDTO.builder()
+                                .id(otherNode.getId())
+                                .name(otherNode.getName())
+                                .relationType(rel.getRelationType())
+                                .build());
+                    }
+                }
+            }
         }
         
         // 构建分组列表

+ 3 - 1
backend/graph-service/src/main/java/com/lingyue/graph/service/NerToBlockService.java

@@ -137,12 +137,14 @@ public class NerToBlockService {
                     positions.add(pos);
                 } else {
                     // 位置不匹配,尝试通过名称查找
+                    // 注意:indexOf 只返回第一个匹配位置,对于重复出现的实体可能不准确
+                    // 理想情况下 NER 服务应该提供准确的位置信息
                     int foundIndex = text.indexOf(pos.getName());
                     if (foundIndex >= 0) {
                         pos.setCharStart(foundIndex);
                         pos.setCharEnd(foundIndex + pos.getName().length());
                         positions.add(pos);
-                        log.debug("实体位置校正: name={}, newStart={}", pos.getName(), foundIndex);
+                        log.debug("实体位置校正: name={}, newStart={}(注意:仅匹配首次出现)", pos.getName(), foundIndex);
                     } else {
                         log.warn("实体位置无效且无法查找: name={}, charStart={}, charEnd={}", 
                                 pos.getName(), pos.getCharStart(), pos.getCharEnd());

+ 12 - 4
database/migrations/V2026_01_21__add_document_blocks_and_entities.sql

@@ -10,8 +10,12 @@ CREATE TABLE IF NOT EXISTS document_blocks (
     elements JSONB,
     style JSONB,
     metadata JSONB,
-    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+    create_by VARCHAR(64),
+    create_by_name VARCHAR(128),
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    update_by VARCHAR(64),
+    update_by_name VARCHAR(128),
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 );
 
 -- 索引
@@ -40,8 +44,12 @@ CREATE TABLE IF NOT EXISTS document_entities (
     confirmed BOOLEAN DEFAULT FALSE,
     graph_node_id VARCHAR(64),
     metadata JSONB,
-    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+    create_by VARCHAR(64),
+    create_by_name VARCHAR(128),
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    update_by VARCHAR(64),
+    update_by_name VARCHAR(128),
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 );
 
 -- 索引