Browse Source

feat: 实现结构化文档存储、知识图谱 API 和 Neo4j 集成

主要功能:
1. 结构化文档存储(参考飞书设计)
   - DocumentBlock 实体:支持树形结构和 TextElement 数组
   - TextElement:支持 text_run/entity/link/mention_doc 等类型
   - 实体作为元素嵌入,避免字符偏移失效问题

2. 知识图谱 API
   - GET /api/v1/graph/documents/{id} - 获取文档图谱
   - GET /api/v1/graph/documents/{id}/entities - 获取实体列表(按类型分组)
   - GET /api/v1/graph/entities/{id} - 获取实体详情
   - GET /api/v1/graph/search - 搜索实体

3. Neo4j 图数据库集成
   - Neo4jConfig: 条件化启用,支持无密码模式
   - Neo4jNodeRepository/Neo4jRelationRepository: 节点和关系 CRUD
   - Neo4jGraphService: 图同步、路径查询、邻域分析
   - GraphSyncService: PostgreSQL -> Neo4j 实时同步

4. NER 结果转换
   - NerToBlockService: NER 实体转 TextElement
   - DocumentBlockGeneratorService: 生成结构化文档块

5. 文档管理 API 完善
   - 分页查询、状态筛选、关键词搜索
   - /auth/me 用户信息接口

新增文件:29 个
修改文件:8 个
何文松 1 tháng trước cách đây
mục cha
commit
18c7f3ff9d
29 tập tin đã thay đổi với 5116 bổ sung26 xóa
  1. 77 2
      backend/auth-service/src/main/java/com/lingyue/auth/controller/AuthController.java
  2. 129 19
      backend/document-service/src/main/java/com/lingyue/document/controller/DocumentController.java
  3. 215 0
      backend/document-service/src/main/java/com/lingyue/document/controller/StructuredDocumentController.java
  4. 110 0
      backend/document-service/src/main/java/com/lingyue/document/dto/StructuredDocumentDTO.java
  5. 217 0
      backend/document-service/src/main/java/com/lingyue/document/entity/DocumentBlock.java
  6. 87 0
      backend/document-service/src/main/java/com/lingyue/document/entity/DocumentEntity.java
  7. 47 0
      backend/document-service/src/main/java/com/lingyue/document/repository/DocumentBlockRepository.java
  8. 79 0
      backend/document-service/src/main/java/com/lingyue/document/repository/DocumentEntityRepository.java
  9. 95 3
      backend/document-service/src/main/java/com/lingyue/document/service/DocumentService.java
  10. 396 0
      backend/document-service/src/main/java/com/lingyue/document/service/StructuredDocumentService.java
  11. 6 0
      backend/graph-service/pom.xml
  12. 122 0
      backend/graph-service/src/main/java/com/lingyue/graph/config/Neo4jConfig.java
  13. 114 0
      backend/graph-service/src/main/java/com/lingyue/graph/controller/KnowledgeGraphController.java
  14. 121 0
      backend/graph-service/src/main/java/com/lingyue/graph/controller/Neo4jGraphController.java
  15. 110 0
      backend/graph-service/src/main/java/com/lingyue/graph/controller/NerBlockController.java
  16. 322 0
      backend/graph-service/src/main/java/com/lingyue/graph/dto/KnowledgeGraphDTO.java
  17. 15 1
      backend/graph-service/src/main/java/com/lingyue/graph/listener/DocumentParsedEventListener.java
  18. 441 0
      backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jGraphService.java
  19. 313 0
      backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jNodeRepository.java
  20. 271 0
      backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jRelationRepository.java
  21. 315 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/DocumentBlockGeneratorService.java
  22. 11 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/GraphNerService.java
  23. 183 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/GraphSyncService.java
  24. 478 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/KnowledgeGraphService.java
  25. 392 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/NerToBlockService.java
  26. 19 1
      backend/lingyue-starter/src/main/resources/application.properties
  27. 8 0
      backend/pom.xml
  28. 61 0
      database/migrations/V2026_01_21__add_document_blocks_and_entities.sql
  29. 362 0
      docs/neo4j-local-install.md

+ 77 - 2
backend/auth-service/src/main/java/com/lingyue/auth/controller/AuthController.java

@@ -3,23 +3,39 @@ package com.lingyue.auth.controller;
 import com.lingyue.auth.dto.AuthResponse;
 import com.lingyue.auth.dto.LoginRequest;
 import com.lingyue.auth.dto.RegisterRequest;
+import com.lingyue.auth.entity.User;
 import com.lingyue.auth.service.AuthService;
+import com.lingyue.auth.service.UserService;
 import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.common.util.JwtUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
+import lombok.Data;
 import lombok.RequiredArgsConstructor;
-import org.springframework.http.HttpStatus;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 
 /**
  * 认证控制器
+ * 
+ * @author lingyue
+ * @since 2026-01-14
  */
+@Slf4j
 @RestController
 @RequestMapping("/auth")
 @RequiredArgsConstructor
+@Tag(name = "认证接口", description = "用户认证相关接口")
 public class AuthController {
     
     private final AuthService authService;
+    private final UserService userService;
+    
+    @Value("${jwt.secret:lingyue-zhibao-secret-key-2024-please-change-in-production}")
+    private String jwtSecret;
     
     /**
      * 用户注册
@@ -58,11 +74,56 @@ public class AuthController {
      * 刷新Token
      */
     @PostMapping("/refresh")
+    @Operation(summary = "刷新Token", description = "使用刷新Token获取新的访问Token")
     public AjaxResult<AuthResponse> refreshToken(@RequestBody RefreshTokenRequest request) {
         AuthResponse response = authService.refreshToken(request.getRefreshToken());
         return AjaxResult.success(response);
     }
     
+    /**
+     * 获取当前用户信息
+     */
+    @GetMapping("/me")
+    @Operation(summary = "获取当前用户", description = "根据Token获取当前登录用户信息")
+    public AjaxResult<?> getCurrentUser(
+            @RequestHeader(value = "Authorization", required = false) String authorization) {
+        
+        if (authorization == null || !authorization.startsWith("Bearer ")) {
+            return AjaxResult.error("未提供有效的认证Token");
+        }
+        
+        String token = authorization.substring(7);
+        
+        try {
+            // 从Token中解析用户ID
+            String userId = JwtUtil.getUserIdFromToken(token, jwtSecret);
+            if (userId == null) {
+                return AjaxResult.error("Token无效或已过期");
+            }
+            
+            // 获取用户信息
+            User user = userService.getUserById(userId);
+            if (user == null) {
+                return AjaxResult.error("用户不存在");
+            }
+            
+            // 构建响应(不返回敏感信息)
+            UserInfoResponse response = new UserInfoResponse();
+            response.setId(user.getId());
+            response.setUsername(user.getUsername());
+            response.setEmail(user.getEmail());
+            response.setAvatarUrl(user.getAvatarUrl());
+            response.setRole(user.getRole());
+            response.setLastLoginAt(user.getLastLoginAt());
+            response.setCreateTime(user.getCreateTime());
+            
+            return AjaxResult.success(response);
+        } catch (Exception e) {
+            log.error("获取用户信息失败: {}", e.getMessage());
+            return AjaxResult.error("Token无效或已过期");
+        }
+    }
+    
     /**
      * 获取客户端IP地址
      */
@@ -81,11 +142,25 @@ public class AuthController {
     /**
      * 刷新Token请求DTO
      */
-    @lombok.Data
+    @Data
     @lombok.NoArgsConstructor
     @lombok.AllArgsConstructor
     public static class RefreshTokenRequest {
         private String refreshToken;
     }
+    
+    /**
+     * 用户信息响应DTO
+     */
+    @Data
+    public static class UserInfoResponse {
+        private String id;
+        private String username;
+        private String email;
+        private String avatarUrl;
+        private String role;
+        private java.util.Date lastLoginAt;
+        private java.util.Date createTime;
+    }
 }
 

+ 129 - 19
backend/document-service/src/main/java/com/lingyue/document/controller/DocumentController.java

@@ -1,53 +1,163 @@
 package com.lingyue.document.controller;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.lingyue.document.entity.Document;
 import com.lingyue.document.service.DocumentService;
 import com.lingyue.common.domain.AjaxResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.Data;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
 /**
- * 文档控制器(基础框架)
+ * 文档控制器
+ * 提供文档的 CRUD 操作
+ * 
+ * @author lingyue
+ * @since 2026-01-21
  */
+@Slf4j
 @RestController
-@RequestMapping("/documents")
+@RequestMapping("/api/v1/documents")
 @RequiredArgsConstructor
+@Tag(name = "文档管理", description = "文档 CRUD 接口")
 public class DocumentController {
     
     private final DocumentService documentService;
     
     /**
-     * 获取文档列表(待实现
+     * 获取文档列表(分页
      */
     @GetMapping
-    public AjaxResult<?> getDocuments() {
-        // TODO: 实现文档列表查询
-        return AjaxResult.success("文档列表接口待实现");
+    @Operation(summary = "获取文档列表", description = "分页获取用户的文档列表")
+    public AjaxResult<IPage<Document>> getDocuments(
+            @Parameter(description = "用户ID", required = true) 
+            @RequestParam String userId,
+            @Parameter(description = "页码,从1开始") 
+            @RequestParam(defaultValue = "1") Integer page,
+            @Parameter(description = "每页数量") 
+            @RequestParam(defaultValue = "20") Integer size,
+            @Parameter(description = "状态筛选") 
+            @RequestParam(required = false) String status,
+            @Parameter(description = "关键词搜索") 
+            @RequestParam(required = false) String keyword) {
+        
+        log.info("获取文档列表: userId={}, page={}, size={}", userId, page, size);
+        
+        Page<Document> pageParam = new Page<>(page, size);
+        IPage<Document> result = documentService.getDocumentsByUserId(userId, pageParam, status, keyword);
+        
+        return AjaxResult.success(result);
     }
     
     /**
-     * 获取文档详情(待实现)
+     * 获取文档详情
      */
     @GetMapping("/{documentId}")
-    public AjaxResult<?> getDocument(@PathVariable String documentId) {
-        // TODO: 实现文档详情查询
-        return AjaxResult.success("文档详情接口待实现");
+    @Operation(summary = "获取文档详情", description = "根据文档ID获取详情")
+    public AjaxResult<?> getDocument(
+            @Parameter(description = "文档ID", required = true) 
+            @PathVariable String documentId) {
+        
+        Document document = documentService.getDocumentById(documentId);
+        if (document == null) {
+            return AjaxResult.error("文档不存在: " + documentId);
+        }
+        
+        return AjaxResult.success(document);
     }
     
     /**
-     * 上传文档(待实现)
+     * 获取文档提取的文本内容
      */
-    @PostMapping
-    public AjaxResult<?> uploadDocument() {
-        // TODO: 实现文档上传
-        return AjaxResult.success("文档上传接口待实现");
+    @GetMapping("/{documentId}/text")
+    @Operation(summary = "获取文档文本", description = "获取文档解析后的文本内容")
+    public AjaxResult<?> getDocumentText(
+            @Parameter(description = "文档ID", required = true) 
+            @PathVariable String documentId) {
+        
+        try {
+            String text = documentService.getDocumentText(documentId);
+            if (text == null) {
+                return AjaxResult.error("文档文本不存在或尚未解析完成");
+            }
+            
+            DocumentTextResponse response = new DocumentTextResponse();
+            response.setDocumentId(documentId);
+            response.setText(text);
+            response.setLength(text.length());
+            
+            return AjaxResult.success(response);
+        } catch (Exception e) {
+            log.error("获取文档文本失败: documentId={}", documentId, e);
+            return AjaxResult.error("获取文档文本失败: " + e.getMessage());
+        }
     }
     
     /**
-     * 删除文档(待实现)
+     * 获取文档解析状态
+     */
+    @GetMapping("/{documentId}/parse-status")
+    @Operation(summary = "获取解析状态", description = "获取文档的解析进度和状态")
+    public AjaxResult<?> getParseStatus(
+            @Parameter(description = "文档ID", required = true) 
+            @PathVariable String documentId) {
+        
+        Document document = documentService.getDocumentById(documentId);
+        if (document == null) {
+            return AjaxResult.error("文档不存在: " + documentId);
+        }
+        
+        ParseStatusResponse response = new ParseStatusResponse();
+        response.setDocumentId(documentId);
+        response.setStatus(document.getParseStatus());
+        response.setProgress(document.getParseProgress());
+        response.setError(document.getParseError());
+        response.setStartedAt(document.getParseStartedAt());
+        response.setCompletedAt(document.getParseCompletedAt());
+        
+        return AjaxResult.success(response);
+    }
+    
+    /**
+     * 删除文档
      */
     @DeleteMapping("/{documentId}")
-    public AjaxResult<?> deleteDocument(@PathVariable String documentId) {
-        // TODO: 实现文档删除
-        return AjaxResult.success("文档删除接口待实现");
+    @Operation(summary = "删除文档", description = "删除指定文档及其关联数据")
+    public AjaxResult<?> deleteDocument(
+            @Parameter(description = "文档ID", required = true) 
+            @PathVariable String documentId) {
+        
+        try {
+            documentService.deleteDocument(documentId);
+            log.info("删除文档成功: documentId={}", documentId);
+            return AjaxResult.success("删除成功");
+        } catch (Exception e) {
+            log.error("删除文档失败: documentId={}", documentId, e);
+            return AjaxResult.error("删除文档失败: " + e.getMessage());
+        }
+    }
+    
+    // ==================== 响应 DTO ====================
+    
+    @Data
+    public static class DocumentTextResponse {
+        private String documentId;
+        private String text;
+        private Integer length;
+    }
+    
+    @Data
+    public static class ParseStatusResponse {
+        private String documentId;
+        private String status;
+        private Integer progress;
+        private String error;
+        private java.util.Date startedAt;
+        private java.util.Date completedAt;
     }
 }

+ 215 - 0
backend/document-service/src/main/java/com/lingyue/document/controller/StructuredDocumentController.java

@@ -0,0 +1,215 @@
+package com.lingyue.document.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.document.dto.StructuredDocumentDTO;
+import com.lingyue.document.entity.DocumentBlock;
+import com.lingyue.document.entity.DocumentBlock.TextElement;
+import com.lingyue.document.service.StructuredDocumentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 结构化文档控制器(参考飞书设计)
+ * 提供编辑器所需的结构化文档和实体标注接口
+ * 
+ * 核心设计:
+ * - 文档由 Block 树组成
+ * - 实体作为 TextElement 嵌入块中
+ * - 编辑实体 = 修改块的 elements 数组
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/documents")
+@RequiredArgsConstructor
+@Tag(name = "结构化文档", description = "文档编辑器接口(参考飞书设计)")
+public class StructuredDocumentController {
+    
+    private final StructuredDocumentService structuredDocumentService;
+    
+    /**
+     * 获取结构化文档
+     */
+    @GetMapping("/{documentId}/structured")
+    @Operation(summary = "获取结构化文档", description = "获取带实体标注的结构化文档,用于编辑器渲染")
+    public AjaxResult<?> getStructuredDocument(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        StructuredDocumentDTO document = structuredDocumentService.getStructuredDocument(documentId);
+        if (document == null) {
+            return AjaxResult.error("文档不存在: " + documentId);
+        }
+        
+        return AjaxResult.success(document);
+    }
+    
+    // ==================== 块操作 ====================
+    
+    /**
+     * 更新块的元素
+     */
+    @PutMapping("/{documentId}/blocks/{blockId}/elements")
+    @Operation(summary = "更新块元素", description = "更新块的 elements 数组")
+    public AjaxResult<?> updateBlockElements(
+            @PathVariable String documentId,
+            @PathVariable String blockId,
+            @RequestBody UpdateElementsRequest request) {
+        
+        try {
+            structuredDocumentService.updateBlockElements(blockId, request.getElements());
+            return AjaxResult.success("更新成功");
+        } catch (Exception e) {
+            log.error("更新块元素失败: blockId={}", blockId, e);
+            return AjaxResult.error("更新失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 创建新块
+     */
+    @PostMapping("/{documentId}/blocks")
+    @Operation(summary = "创建块", description = "在文档中创建新块")
+    public AjaxResult<?> createBlock(
+            @PathVariable String documentId,
+            @RequestBody CreateBlockRequest request) {
+        
+        try {
+            DocumentBlock block = structuredDocumentService.createBlock(
+                    documentId,
+                    request.getParentId(),
+                    request.getIndex(),
+                    request.getBlockType(),
+                    request.getElements()
+            );
+            return AjaxResult.success("创建成功", block);
+        } catch (Exception e) {
+            log.error("创建块失败: documentId={}", documentId, e);
+            return AjaxResult.error("创建失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 删除块
+     */
+    @DeleteMapping("/{documentId}/blocks/{blockId}")
+    @Operation(summary = "删除块", description = "删除指定块及其子块")
+    public AjaxResult<?> deleteBlock(
+            @PathVariable String documentId,
+            @PathVariable String blockId) {
+        
+        structuredDocumentService.deleteBlock(blockId);
+        return AjaxResult.success("删除成功");
+    }
+    
+    // ==================== 实体操作 ====================
+    
+    /**
+     * 标记实体(将文本转为实体)
+     */
+    @PostMapping("/{documentId}/blocks/{blockId}/mark-entity")
+    @Operation(summary = "标记实体", description = "将块中的文本片段标记为实体")
+    public AjaxResult<?> markEntity(
+            @PathVariable String documentId,
+            @PathVariable String blockId,
+            @RequestBody MarkEntityRequest request) {
+        
+        try {
+            String entityId = structuredDocumentService.markEntity(
+                    blockId,
+                    request.getElementIndex(),
+                    request.getStartOffset(),
+                    request.getEndOffset(),
+                    request.getEntityType()
+            );
+            return AjaxResult.success("标记成功", entityId);
+        } catch (Exception e) {
+            log.error("标记实体失败: blockId={}", blockId, e);
+            return AjaxResult.error("标记失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 取消实体标记(将实体还原为文本)
+     */
+    @DeleteMapping("/{documentId}/blocks/{blockId}/entities/{entityId}")
+    @Operation(summary = "取消实体标记", description = "将实体还原为普通文本")
+    public AjaxResult<?> unmarkEntity(
+            @PathVariable String documentId,
+            @PathVariable String blockId,
+            @PathVariable String entityId) {
+        
+        structuredDocumentService.unmarkEntity(blockId, entityId);
+        return AjaxResult.success("取消成功");
+    }
+    
+    /**
+     * 更新实体类型
+     */
+    @PutMapping("/{documentId}/blocks/{blockId}/entities/{entityId}")
+    @Operation(summary = "更新实体", description = "更新实体类型")
+    public AjaxResult<?> updateEntity(
+            @PathVariable String documentId,
+            @PathVariable String blockId,
+            @PathVariable String entityId,
+            @RequestBody UpdateEntityRequest request) {
+        
+        structuredDocumentService.updateEntityType(blockId, entityId, request.getEntityType());
+        return AjaxResult.success("更新成功");
+    }
+    
+    /**
+     * 确认实体
+     */
+    @PostMapping("/{documentId}/blocks/{blockId}/entities/{entityId}/confirm")
+    @Operation(summary = "确认实体", description = "确认实体标注正确")
+    public AjaxResult<?> confirmEntity(
+            @PathVariable String documentId,
+            @PathVariable String blockId,
+            @PathVariable String entityId) {
+        
+        structuredDocumentService.confirmEntity(blockId, entityId);
+        return AjaxResult.success("确认成功");
+    }
+    
+    // ==================== 请求 DTO ====================
+    
+    @Data
+    public static class UpdateElementsRequest {
+        private List<TextElement> elements;
+    }
+    
+    @Data
+    public static class CreateBlockRequest {
+        private String parentId;
+        private Integer index;
+        private String blockType;
+        private List<TextElement> elements;
+    }
+    
+    @Data
+    public static class MarkEntityRequest {
+        /** 要标记的元素在 elements 数组中的索引 */
+        private Integer elementIndex;
+        /** 在该元素文本中的起始位置 */
+        private Integer startOffset;
+        /** 在该元素文本中的结束位置 */
+        private Integer endOffset;
+        /** 实体类型 */
+        private String entityType;
+    }
+    
+    @Data
+    public static class UpdateEntityRequest {
+        private String entityType;
+    }
+}

+ 110 - 0
backend/document-service/src/main/java/com/lingyue/document/dto/StructuredDocumentDTO.java

@@ -0,0 +1,110 @@
+package com.lingyue.document.dto;
+
+import com.lingyue.document.entity.DocumentBlock.TextElement;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.util.List;
+import java.util.Date;
+
+/**
+ * 结构化文档 DTO(参考飞书设计)
+ * 包含文档的完整结构化内容,用于编辑器渲染
+ * 
+ * 核心设计:
+ * - 文档由 Block 树组成
+ * - 每个 Block 包含 elements 数组(TextElement)
+ * - 实体作为 TextElement 嵌入,无需字符偏移定位
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "结构化文档")
+public class StructuredDocumentDTO {
+    
+    @Schema(description = "文档ID")
+    private String documentId;
+    
+    @Schema(description = "版本号")
+    private Integer revision;
+    
+    @Schema(description = "文档标题")
+    private String title;
+    
+    @Schema(description = "文档状态")
+    private String status;
+    
+    @Schema(description = "内容块列表")
+    private List<BlockDTO> blocks;
+    
+    @Schema(description = "实体统计")
+    private EntityStats entityStats;
+    
+    @Schema(description = "最后更新时间")
+    private Date updatedAt;
+    
+    /**
+     * 内容块 DTO(参考飞书Block)
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "内容块")
+    public static class BlockDTO {
+        
+        @Schema(description = "块ID")
+        private String id;
+        
+        @Schema(description = "父块ID")
+        private String parentId;
+        
+        @Schema(description = "子块ID列表")
+        private List<String> children;
+        
+        @Schema(description = "块序号")
+        private Integer index;
+        
+        @Schema(description = "块类型", example = "page/heading1/heading2/text/bullet/ordered/table/image/code/quote")
+        private String type;
+        
+        @Schema(description = "块内元素列表(核心:实体作为元素嵌入)")
+        private List<TextElement> elements;
+        
+        @Schema(description = "纯文本内容(便于搜索)")
+        private String plainText;
+        
+        @Schema(description = "HTML渲染(原文视图)")
+        private String html;
+        
+        @Schema(description = "HTML渲染(标记视图,实体高亮)")
+        private String markedHtml;
+    }
+    
+    /**
+     * 实体统计
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "实体统计")
+    public static class EntityStats {
+        
+        @Schema(description = "总实体数")
+        private Integer total;
+        
+        @Schema(description = "已确认数")
+        private Integer confirmed;
+        
+        @Schema(description = "按类型统计")
+        private java.util.Map<String, Integer> byType;
+    }
+}

+ 217 - 0
backend/document-service/src/main/java/com/lingyue/document/entity/DocumentBlock.java

@@ -0,0 +1,217 @@
+package com.lingyue.document.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.lingyue.common.domain.entity.SimpleModel;
+import com.lingyue.common.mybatis.PostgreSqlJsonbTypeHandler;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 文档块实体(参考飞书 Block 设计)
+ * 表示文档中的一个结构化内容块(段落、标题、表格、列表等)
+ * 
+ * 核心设计:
+ * - 块内容由多个 TextElement 组成,实体作为元素嵌入,而非通过字符偏移定位
+ * - 这样编辑时只需修改对应元素,不会导致其他元素位置失效
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@TableName(value = "document_blocks", autoResultMap = true)
+@Schema(description = "文档块实体")
+public class DocumentBlock extends SimpleModel {
+    
+    @Schema(description = "文档ID")
+    @TableField("document_id")
+    private String documentId;
+    
+    @Schema(description = "父块ID(支持嵌套结构)")
+    @TableField("parent_id")
+    private String parentId;
+    
+    @Schema(description = "子块ID列表")
+    @TableField(value = "children", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private List<String> children;
+    
+    @Schema(description = "块序号(在同级中的顺序)")
+    @TableField("block_index")
+    private Integer blockIndex;
+    
+    @Schema(description = "块类型", example = "page/heading1/heading2/text/bullet/ordered/table/image/code/quote")
+    @TableField("block_type")
+    private String blockType;
+    
+    @Schema(description = "块内元素列表(TextElement数组)", 
+            example = "[{\"type\":\"text_run\",\"content\":\"Hello\"},{\"type\":\"entity\",\"entityId\":\"xxx\"}]")
+    @TableField(value = "elements", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private List<TextElement> elements;
+    
+    @Schema(description = "块样式")
+    @TableField(value = "style", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private Object style;
+    
+    @Schema(description = "块元数据")
+    @TableField(value = "metadata", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private Object metadata;
+    
+    /**
+     * 文本元素(内嵌类)
+     * 参考飞书的 TextElement 设计
+     */
+    @Data
+    @Schema(description = "文本元素")
+    public static class TextElement {
+        
+        @Schema(description = "元素类型", example = "text_run/entity/mention_doc/link/equation")
+        private String type;
+        
+        // ===== text_run 类型 =====
+        @Schema(description = "文本内容(type=text_run时)")
+        private String content;
+        
+        @Schema(description = "文本样式")
+        private TextStyle style;
+        
+        // ===== entity 类型(我们的特色:实体标注)=====
+        @Schema(description = "实体ID(type=entity时)")
+        private String entityId;
+        
+        @Schema(description = "实体显示文本")
+        private String entityText;
+        
+        @Schema(description = "实体类型", example = "PERSON/ORG/LOC/DATE/MONEY")
+        private String entityType;
+        
+        @Schema(description = "是否已确认")
+        private Boolean confirmed;
+        
+        // ===== link 类型 =====
+        @Schema(description = "链接URL")
+        private String url;
+        
+        // ===== mention_doc 类型 =====
+        @Schema(description = "引用文档ID")
+        private String refDocId;
+        
+        @Schema(description = "引用文档标题")
+        private String refDocTitle;
+    }
+    
+    /**
+     * 文本样式
+     */
+    @Data
+    @Schema(description = "文本样式")
+    public static class TextStyle {
+        private Boolean bold;
+        private Boolean italic;
+        private Boolean underline;
+        private Boolean strikethrough;
+        private String textColor;
+        private String backgroundColor;
+        private Boolean inlineCode;
+    }
+    
+    /**
+     * 获取块的纯文本内容(用于搜索和NER)
+     */
+    public String getPlainText() {
+        if (elements == null || elements.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (TextElement el : elements) {
+            if ("text_run".equals(el.getType()) && el.getContent() != null) {
+                sb.append(el.getContent());
+            } else if ("entity".equals(el.getType()) && el.getEntityText() != null) {
+                sb.append(el.getEntityText());
+            }
+        }
+        return sb.toString();
+    }
+    
+    /**
+     * 获取块的HTML渲染(原文视图)
+     */
+    public String toHtml() {
+        if (elements == null || elements.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (TextElement el : elements) {
+            if ("text_run".equals(el.getType())) {
+                sb.append(escapeHtml(el.getContent()));
+            } else if ("entity".equals(el.getType())) {
+                sb.append(escapeHtml(el.getEntityText()));
+            } else if ("link".equals(el.getType())) {
+                sb.append("<a href=\"").append(el.getUrl()).append("\">")
+                  .append(escapeHtml(el.getContent())).append("</a>");
+            }
+        }
+        return wrapWithTag(sb.toString());
+    }
+    
+    /**
+     * 获取块的HTML渲染(标记视图,实体高亮)
+     */
+    public String toMarkedHtml() {
+        if (elements == null || elements.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (TextElement el : elements) {
+            if ("text_run".equals(el.getType())) {
+                sb.append(escapeHtml(el.getContent()));
+            } else if ("entity".equals(el.getType())) {
+                String cssClass = getEntityCssClass(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("contenteditable=\"false\">")
+                  .append(escapeHtml(el.getEntityText()))
+                  .append("</span>");
+            }
+        }
+        return wrapWithTag(sb.toString());
+    }
+    
+    private String wrapWithTag(String content) {
+        return switch (blockType) {
+            case "heading1" -> "<h1>" + content + "</h1>";
+            case "heading2" -> "<h2>" + content + "</h2>";
+            case "heading3" -> "<h3>" + content + "</h3>";
+            case "bullet" -> "<li>" + content + "</li>";
+            case "ordered" -> "<li>" + content + "</li>";
+            case "quote" -> "<blockquote>" + content + "</blockquote>";
+            case "code" -> "<pre><code>" + content + "</code></pre>";
+            default -> "<p>" + content + "</p>";
+        };
+    }
+    
+    private String getEntityCssClass(String entityType) {
+        return switch (entityType) {
+            case "PERSON" -> "entity-highlight person";
+            case "ORG" -> "entity-highlight org";
+            case "LOC" -> "entity-highlight location";
+            case "DATE" -> "entity-highlight date";
+            case "NUMBER", "MONEY", "DATA" -> "entity-highlight data";
+            case "CONCEPT" -> "entity-highlight concept";
+            default -> "entity-highlight entity";
+        };
+    }
+    
+    private String escapeHtml(String text) {
+        if (text == null) return "";
+        return text.replace("&", "&amp;")
+                   .replace("<", "&lt;")
+                   .replace(">", "&gt;")
+                   .replace("\"", "&quot;");
+    }
+}

+ 87 - 0
backend/document-service/src/main/java/com/lingyue/document/entity/DocumentEntity.java

@@ -0,0 +1,87 @@
+package com.lingyue.document.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.lingyue.common.domain.entity.SimpleModel;
+import com.lingyue.common.mybatis.PostgreSqlJsonbTypeHandler;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 文档实体标注
+ * 表示文档中被标记的一个实体/要素(人名、机构、数据、地点等)
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@TableName(value = "document_entities", autoResultMap = true)
+@Schema(description = "文档实体标注")
+public class DocumentEntity extends SimpleModel {
+    
+    @Schema(description = "文档ID")
+    @TableField("document_id")
+    private String documentId;
+    
+    @Schema(description = "所属块ID")
+    @TableField("block_id")
+    private String blockId;
+    
+    @Schema(description = "实体名称/文本")
+    @TableField("name")
+    private String name;
+    
+    @Schema(description = "实体类型", example = "PERSON/ORG/LOC/DATE/NUMBER/MONEY/CONCEPT/DATA")
+    @TableField("entity_type")
+    private String entityType;
+    
+    @Schema(description = "实体值(标准化后的值)")
+    @TableField("value")
+    private String value;
+    
+    @Schema(description = "在块内的字符起始位置")
+    @TableField("block_char_start")
+    private Integer blockCharStart;
+    
+    @Schema(description = "在块内的字符结束位置")
+    @TableField("block_char_end")
+    private Integer blockCharEnd;
+    
+    @Schema(description = "在全文中的字符起始位置(快照,编辑后可能失效)")
+    @TableField("global_char_start")
+    private Integer globalCharStart;
+    
+    @Schema(description = "在全文中的字符结束位置(快照,编辑后可能失效)")
+    @TableField("global_char_end")
+    private Integer globalCharEnd;
+    
+    @Schema(description = "实体前文本锚点(用于重定位)")
+    @TableField("anchor_before")
+    private String anchorBefore;
+    
+    @Schema(description = "实体后文本锚点(用于重定位)")
+    @TableField("anchor_after")
+    private String anchorAfter;
+    
+    @Schema(description = "来源", example = "auto/manual")
+    @TableField("source")
+    private String source = "auto";
+    
+    @Schema(description = "置信度 0-1")
+    @TableField("confidence")
+    private Double confidence;
+    
+    @Schema(description = "是否已确认")
+    @TableField("confirmed")
+    private Boolean confirmed = false;
+    
+    @Schema(description = "关联的图节点ID")
+    @TableField("graph_node_id")
+    private String graphNodeId;
+    
+    @Schema(description = "元数据", example = "{\"context\": \"...\", \"suggestion\": \"...\"}")
+    @TableField(value = "metadata", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private Object metadata;
+}

+ 47 - 0
backend/document-service/src/main/java/com/lingyue/document/repository/DocumentBlockRepository.java

@@ -0,0 +1,47 @@
+package com.lingyue.document.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.lingyue.document.entity.DocumentBlock;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Delete;
+
+import java.util.List;
+
+/**
+ * 文档块 Repository
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Mapper
+public interface DocumentBlockRepository extends BaseMapper<DocumentBlock> {
+    
+    /**
+     * 根据文档ID查询所有块(按顺序)
+     */
+    @Select("SELECT * FROM document_blocks WHERE document_id = #{documentId} ORDER BY block_index")
+    List<DocumentBlock> findByDocumentId(@Param("documentId") String documentId);
+    
+    /**
+     * 根据文档ID和块类型查询
+     */
+    @Select("SELECT * FROM document_blocks WHERE document_id = #{documentId} AND block_type = #{blockType} ORDER BY block_index")
+    List<DocumentBlock> findByDocumentIdAndType(@Param("documentId") String documentId, 
+                                                 @Param("blockType") String blockType);
+    
+    /**
+     * 根据字符位置查找所属块
+     */
+    @Select("SELECT * FROM document_blocks WHERE document_id = #{documentId} " +
+            "AND char_start <= #{charPos} AND char_end >= #{charPos} LIMIT 1")
+    DocumentBlock findByCharPosition(@Param("documentId") String documentId, 
+                                     @Param("charPos") Integer charPos);
+    
+    /**
+     * 删除文档的所有块
+     */
+    @Delete("DELETE FROM document_blocks WHERE document_id = #{documentId}")
+    int deleteByDocumentId(@Param("documentId") String documentId);
+}

+ 79 - 0
backend/document-service/src/main/java/com/lingyue/document/repository/DocumentEntityRepository.java

@@ -0,0 +1,79 @@
+package com.lingyue.document.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.lingyue.document.entity.DocumentEntity;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+
+/**
+ * 文档实体 Repository
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Mapper
+public interface DocumentEntityRepository extends BaseMapper<DocumentEntity> {
+    
+    /**
+     * 根据文档ID查询所有实体
+     */
+    @Select("SELECT * FROM document_entities WHERE document_id = #{documentId} ORDER BY global_char_start")
+    List<DocumentEntity> findByDocumentId(@Param("documentId") String documentId);
+    
+    /**
+     * 根据块ID查询实体
+     */
+    @Select("SELECT * FROM document_entities WHERE block_id = #{blockId} ORDER BY block_char_start")
+    List<DocumentEntity> findByBlockId(@Param("blockId") String blockId);
+    
+    /**
+     * 根据文档ID和实体类型查询
+     */
+    @Select("SELECT * FROM document_entities WHERE document_id = #{documentId} AND entity_type = #{entityType} ORDER BY global_char_start")
+    List<DocumentEntity> findByDocumentIdAndType(@Param("documentId") String documentId, 
+                                                  @Param("entityType") String entityType);
+    
+    /**
+     * 根据实体名称模糊查询
+     */
+    @Select("SELECT * FROM document_entities WHERE document_id = #{documentId} AND name LIKE CONCAT('%', #{keyword}, '%')")
+    List<DocumentEntity> searchByName(@Param("documentId") String documentId, 
+                                       @Param("keyword") String keyword);
+    
+    /**
+     * 确认实体
+     */
+    @Update("UPDATE document_entities SET confirmed = true, updated_at = CURRENT_TIMESTAMP WHERE id = #{entityId}")
+    int confirmEntity(@Param("entityId") String entityId);
+    
+    /**
+     * 批量确认实体
+     */
+    @Update("<script>UPDATE document_entities SET confirmed = true, updated_at = CURRENT_TIMESTAMP " +
+            "WHERE id IN <foreach collection='entityIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
+    int batchConfirmEntities(@Param("entityIds") List<String> entityIds);
+    
+    /**
+     * 删除文档的所有实体
+     */
+    @Delete("DELETE FROM document_entities WHERE document_id = #{documentId}")
+    int deleteByDocumentId(@Param("documentId") String documentId);
+    
+    /**
+     * 删除块的所有实体
+     */
+    @Delete("DELETE FROM document_entities WHERE block_id = #{blockId}")
+    int deleteByBlockId(@Param("blockId") String blockId);
+    
+    /**
+     * 统计文档实体数量(按类型分组)
+     */
+    @Select("SELECT entity_type, COUNT(*) as count FROM document_entities " +
+            "WHERE document_id = #{documentId} GROUP BY entity_type")
+    List<java.util.Map<String, Object>> countByType(@Param("documentId") String documentId);
+}

+ 95 - 3
backend/document-service/src/main/java/com/lingyue/document/service/DocumentService.java

@@ -6,17 +6,31 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.lingyue.document.entity.Document;
 import com.lingyue.document.repository.DocumentRepository;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 
 /**
- * 文档服务(基础框架)
+ * 文档服务
+ * 
+ * @author lingyue
+ * @since 2026-01-14
  */
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class DocumentService {
     
     private final DocumentRepository documentRepository;
     
+    @Value("${file.storage.text-path:/data/lingyue/texts}")
+    private String textStoragePath;
+    
     /**
      * 根据ID获取文档
      */
@@ -25,15 +39,63 @@ public class DocumentService {
     }
     
     /**
-     * 根据用户ID分页查询文档
+     * 根据用户ID分页查询文档(支持筛选和搜索)
+     * 
+     * @param userId 用户ID
+     * @param page 分页参数
+     * @param status 状态筛选(可选)
+     * @param keyword 关键词搜索(可选)
+     * @return 分页结果
      */
-    public IPage<Document> getDocumentsByUserId(String userId, Page<Document> page) {
+    public IPage<Document> getDocumentsByUserId(String userId, Page<Document> page, String status, String keyword) {
         LambdaQueryWrapper<Document> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(Document::getUserId, userId);
+        
+        // 状态筛选
+        if (StringUtils.hasText(status)) {
+            wrapper.eq(Document::getStatus, status);
+        }
+        
+        // 关键词搜索(搜索文档名称)
+        if (StringUtils.hasText(keyword)) {
+            wrapper.like(Document::getName, keyword);
+        }
+        
         wrapper.orderByDesc(Document::getCreateTime);
         return documentRepository.selectPage(page, wrapper);
     }
     
+    /**
+     * 根据用户ID分页查询文档(简单版本)
+     */
+    public IPage<Document> getDocumentsByUserId(String userId, Page<Document> page) {
+        return getDocumentsByUserId(userId, page, null, null);
+    }
+    
+    /**
+     * 获取文档提取的文本内容
+     * 
+     * @param documentId 文档ID
+     * @return 文本内容
+     */
+    public String getDocumentText(String documentId) {
+        // 构建文本文件路径
+        String subDir = documentId.substring(0, 2);
+        Path textFilePath = Path.of(textStoragePath, subDir, documentId + ".txt");
+        
+        if (!Files.exists(textFilePath)) {
+            log.warn("文档文本文件不存在: {}", textFilePath);
+            return null;
+        }
+        
+        try {
+            return Files.readString(textFilePath, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            log.error("读取文档文本失败: {}", textFilePath, e);
+            throw new RuntimeException("读取文档文本失败: " + e.getMessage());
+        }
+    }
+    
     /**
      * 保存文档
      */
@@ -63,5 +125,35 @@ public class DocumentService {
      */
     public void deleteDocument(String documentId) {
         documentRepository.deleteById(documentId);
+        log.info("删除文档: documentId={}", documentId);
+        // TODO: 同时删除关联的文本文件、图节点等
+    }
+    
+    /**
+     * 统计用户文档数量
+     */
+    public long countByUserId(String userId) {
+        LambdaQueryWrapper<Document> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Document::getUserId, userId);
+        return documentRepository.selectCount(wrapper);
+    }
+    
+    /**
+     * 统计用户本周新增文档数
+     */
+    public long countWeeklyNewByUserId(String userId) {
+        LambdaQueryWrapper<Document> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Document::getUserId, userId);
+        
+        // 计算本周开始时间
+        java.util.Calendar cal = java.util.Calendar.getInstance();
+        cal.set(java.util.Calendar.DAY_OF_WEEK, cal.getFirstDayOfWeek());
+        cal.set(java.util.Calendar.HOUR_OF_DAY, 0);
+        cal.set(java.util.Calendar.MINUTE, 0);
+        cal.set(java.util.Calendar.SECOND, 0);
+        cal.set(java.util.Calendar.MILLISECOND, 0);
+        
+        wrapper.ge(Document::getCreateTime, cal.getTime());
+        return documentRepository.selectCount(wrapper);
     }
 }

+ 396 - 0
backend/document-service/src/main/java/com/lingyue/document/service/StructuredDocumentService.java

@@ -0,0 +1,396 @@
+package com.lingyue.document.service;
+
+import com.lingyue.document.dto.StructuredDocumentDTO;
+import com.lingyue.document.dto.StructuredDocumentDTO.*;
+import com.lingyue.document.entity.Document;
+import com.lingyue.document.entity.DocumentBlock;
+import com.lingyue.document.entity.DocumentBlock.TextElement;
+import com.lingyue.document.repository.DocumentBlockRepository;
+import com.lingyue.document.repository.DocumentRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 结构化文档服务(参考飞书设计)
+ * 
+ * 核心设计:
+ * - 文档由 Block 树组成,每个 Block 包含 elements 数组
+ * - 实体作为 TextElement(type=entity)嵌入块中
+ * - 编辑时修改 elements 数组,无需处理字符偏移
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class StructuredDocumentService {
+    
+    private final DocumentRepository documentRepository;
+    private final DocumentBlockRepository blockRepository;
+    
+    /**
+     * 获取结构化文档(用于编辑器渲染)
+     */
+    public StructuredDocumentDTO getStructuredDocument(String documentId) {
+        // 1. 获取文档基本信息
+        Document document = documentRepository.selectById(documentId);
+        if (document == null) {
+            return null;
+        }
+        
+        // 2. 获取所有块
+        List<DocumentBlock> blocks = blockRepository.findByDocumentId(documentId);
+        
+        // 3. 构建块 DTO 列表
+        List<BlockDTO> blockDTOs = blocks.stream()
+                .map(this::buildBlockDTO)
+                .collect(Collectors.toList());
+        
+        // 4. 统计实体
+        EntityStats stats = buildEntityStats(blocks);
+        
+        return StructuredDocumentDTO.builder()
+                .documentId(documentId)
+                .revision(1) // TODO: 实现版本控制
+                .title(document.getName())
+                .status(document.getStatus())
+                .blocks(blockDTOs)
+                .entityStats(stats)
+                .updatedAt(document.getUpdateTime())
+                .build();
+    }
+    
+    /**
+     * 构建块 DTO
+     */
+    private BlockDTO buildBlockDTO(DocumentBlock block) {
+        return BlockDTO.builder()
+                .id(block.getId())
+                .parentId(block.getParentId())
+                .children(block.getChildren())
+                .index(block.getBlockIndex())
+                .type(block.getBlockType())
+                .elements(block.getElements())
+                .plainText(block.getPlainText())
+                .html(block.toHtml())
+                .markedHtml(block.toMarkedHtml())
+                .build();
+    }
+    
+    /**
+     * 构建实体统计(从块的 elements 中提取)
+     */
+    private EntityStats buildEntityStats(List<DocumentBlock> blocks) {
+        int total = 0;
+        int confirmed = 0;
+        Map<String, Integer> byType = new HashMap<>();
+        
+        for (DocumentBlock block : blocks) {
+            if (block.getElements() == null) continue;
+            
+            for (TextElement el : block.getElements()) {
+                if ("entity".equals(el.getType())) {
+                    total++;
+                    if (Boolean.TRUE.equals(el.getConfirmed())) {
+                        confirmed++;
+                    }
+                    String entityType = el.getEntityType();
+                    if (entityType != null) {
+                        byType.merge(entityType, 1, Integer::sum);
+                    }
+                }
+            }
+        }
+        
+        return EntityStats.builder()
+                .total(total)
+                .confirmed(confirmed)
+                .byType(byType)
+                .build();
+    }
+    
+    // ==================== 块操作 ====================
+    
+    /**
+     * 更新块的 elements
+     */
+    @Transactional
+    public void updateBlockElements(String blockId, List<TextElement> elements) {
+        DocumentBlock block = blockRepository.selectById(blockId);
+        if (block == null) {
+            throw new RuntimeException("块不存在: " + blockId);
+        }
+        
+        block.setElements(elements);
+        block.setUpdateTime(new Date());
+        blockRepository.updateById(block);
+        
+        log.info("更新块元素: blockId={}, elementCount={}", blockId, elements.size());
+    }
+    
+    /**
+     * 在块内添加实体(将文本片段转为实体元素)
+     * 
+     * @param blockId 块ID
+     * @param elementIndex 要转换的元素索引
+     * @param startOffset 在该元素文本中的起始位置
+     * @param endOffset 在该元素文本中的结束位置
+     * @param entityType 实体类型
+     * @return 新创建的实体ID
+     */
+    @Transactional
+    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("块不存在或没有内容");
+        }
+        
+        List<TextElement> elements = new ArrayList<>(block.getElements());
+        if (elementIndex >= elements.size()) {
+            throw new RuntimeException("元素索引越界");
+        }
+        
+        TextElement targetElement = elements.get(elementIndex);
+        if (!"text_run".equals(targetElement.getType()) || targetElement.getContent() == null) {
+            throw new RuntimeException("只能在文本元素上标记实体");
+        }
+        
+        String content = targetElement.getContent();
+        if (startOffset < 0 || endOffset > content.length() || startOffset >= endOffset) {
+            throw new RuntimeException("偏移量无效");
+        }
+        
+        String entityId = UUID.randomUUID().toString().replace("-", "");
+        String entityText = content.substring(startOffset, endOffset);
+        
+        // 拆分元素:前段文本 + 实体 + 后段文本
+        List<TextElement> newElements = new ArrayList<>();
+        
+        // 前段文本
+        if (startOffset > 0) {
+            TextElement before = new TextElement();
+            before.setType("text_run");
+            before.setContent(content.substring(0, startOffset));
+            before.setStyle(targetElement.getStyle());
+            newElements.add(before);
+        }
+        
+        // 实体元素
+        TextElement entity = new TextElement();
+        entity.setType("entity");
+        entity.setEntityId(entityId);
+        entity.setEntityText(entityText);
+        entity.setEntityType(entityType);
+        entity.setConfirmed(true); // 手动标记的直接确认
+        newElements.add(entity);
+        
+        // 后段文本
+        if (endOffset < content.length()) {
+            TextElement after = new TextElement();
+            after.setType("text_run");
+            after.setContent(content.substring(endOffset));
+            after.setStyle(targetElement.getStyle());
+            newElements.add(after);
+        }
+        
+        // 替换原元素
+        elements.remove(elementIndex);
+        elements.addAll(elementIndex, newElements);
+        
+        block.setElements(elements);
+        block.setUpdateTime(new Date());
+        blockRepository.updateById(block);
+        
+        log.info("标记实体: blockId={}, entityId={}, text={}, type={}", blockId, entityId, entityText, entityType);
+        
+        return entityId;
+    }
+    
+    /**
+     * 删除实体标记(将实体元素还原为文本)
+     */
+    @Transactional
+    public void unmarkEntity(String blockId, String entityId) {
+        DocumentBlock block = blockRepository.selectById(blockId);
+        if (block == null || block.getElements() == null) {
+            return;
+        }
+        
+        List<TextElement> elements = new ArrayList<>(block.getElements());
+        
+        for (int i = 0; i < elements.size(); i++) {
+            TextElement el = elements.get(i);
+            if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
+                // 将实体还原为文本
+                TextElement textEl = new TextElement();
+                textEl.setType("text_run");
+                textEl.setContent(el.getEntityText());
+                elements.set(i, textEl);
+                break;
+            }
+        }
+        
+        // 合并相邻的文本元素
+        elements = mergeAdjacentTextRuns(elements);
+        
+        block.setElements(elements);
+        block.setUpdateTime(new Date());
+        blockRepository.updateById(block);
+        
+        log.info("取消实体标记: blockId={}, entityId={}", blockId, entityId);
+    }
+    
+    /**
+     * 更新实体类型
+     */
+    @Transactional
+    public void updateEntityType(String blockId, String entityId, String newType) {
+        DocumentBlock block = blockRepository.selectById(blockId);
+        if (block == null || block.getElements() == null) {
+            return;
+        }
+        
+        for (TextElement el : block.getElements()) {
+            if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
+                el.setEntityType(newType);
+                break;
+            }
+        }
+        
+        block.setUpdateTime(new Date());
+        blockRepository.updateById(block);
+    }
+    
+    /**
+     * 确认实体
+     */
+    @Transactional
+    public void confirmEntity(String blockId, String entityId) {
+        DocumentBlock block = blockRepository.selectById(blockId);
+        if (block == null || block.getElements() == null) {
+            return;
+        }
+        
+        for (TextElement el : block.getElements()) {
+            if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
+                el.setConfirmed(true);
+                break;
+            }
+        }
+        
+        block.setUpdateTime(new Date());
+        blockRepository.updateById(block);
+    }
+    
+    /**
+     * 合并相邻的文本元素
+     */
+    private List<TextElement> mergeAdjacentTextRuns(List<TextElement> elements) {
+        if (elements.size() <= 1) {
+            return elements;
+        }
+        
+        List<TextElement> merged = new ArrayList<>();
+        TextElement current = null;
+        
+        for (TextElement el : elements) {
+            if ("text_run".equals(el.getType())) {
+                if (current == null) {
+                    current = new TextElement();
+                    current.setType("text_run");
+                    current.setContent(el.getContent());
+                    current.setStyle(el.getStyle());
+                } else {
+                    // 合并文本
+                    current.setContent(current.getContent() + el.getContent());
+                }
+            } else {
+                if (current != null) {
+                    merged.add(current);
+                    current = null;
+                }
+                merged.add(el);
+            }
+        }
+        
+        if (current != null) {
+            merged.add(current);
+        }
+        
+        return merged;
+    }
+    
+    // ==================== 块增删操作 ====================
+    
+    /**
+     * 创建新块
+     */
+    @Transactional
+    public DocumentBlock createBlock(String documentId, String parentId, int index, 
+                                      String blockType, List<TextElement> elements) {
+        DocumentBlock block = new DocumentBlock();
+        block.setId(UUID.randomUUID().toString().replace("-", ""));
+        block.setDocumentId(documentId);
+        block.setParentId(parentId);
+        block.setBlockIndex(index);
+        block.setBlockType(blockType);
+        block.setElements(elements);
+        block.setCreateTime(new Date());
+        block.setUpdateTime(new Date());
+        
+        blockRepository.insert(block);
+        
+        // 更新父块的 children
+        if (parentId != null) {
+            DocumentBlock parent = blockRepository.selectById(parentId);
+            if (parent != null) {
+                List<String> children = parent.getChildren();
+                if (children == null) {
+                    children = new ArrayList<>();
+                }
+                children.add(block.getId());
+                parent.setChildren(children);
+                blockRepository.updateById(parent);
+            }
+        }
+        
+        log.info("创建块: documentId={}, blockId={}, type={}", documentId, block.getId(), blockType);
+        return block;
+    }
+    
+    /**
+     * 删除块
+     */
+    @Transactional
+    public void deleteBlock(String blockId) {
+        DocumentBlock block = blockRepository.selectById(blockId);
+        if (block == null) {
+            return;
+        }
+        
+        // 递归删除子块
+        if (block.getChildren() != null) {
+            for (String childId : block.getChildren()) {
+                deleteBlock(childId);
+            }
+        }
+        
+        // 从父块的 children 中移除
+        if (block.getParentId() != null) {
+            DocumentBlock parent = blockRepository.selectById(block.getParentId());
+            if (parent != null && parent.getChildren() != null) {
+                parent.getChildren().remove(block.getId());
+                blockRepository.updateById(parent);
+            }
+        }
+        
+        blockRepository.deleteById(blockId);
+        log.info("删除块: blockId={}", blockId);
+    }
+}

+ 6 - 0
backend/graph-service/pom.xml

@@ -69,6 +69,12 @@
             <artifactId>lombok</artifactId>
             <optional>true</optional>
         </dependency>
+        
+        <!-- Neo4j Driver -->
+        <dependency>
+            <groupId>org.neo4j.driver</groupId>
+            <artifactId>neo4j-java-driver</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 122 - 0
backend/graph-service/src/main/java/com/lingyue/graph/config/Neo4jConfig.java

@@ -0,0 +1,122 @@
+package com.lingyue.graph.config;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.neo4j.driver.*;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.annotation.PreDestroy;
+
+/**
+ * Neo4j 配置类
+ * 
+ * 使用 Neo4j Java Driver 连接图数据库
+ * 
+ * 配置示例(application.yml):
+ * neo4j:
+ *   enabled: true
+ *   uri: bolt://localhost:7687
+ *   username: neo4j
+ *   password: your-password
+ *   database: neo4j
+ *   connection-timeout: 30s
+ *   max-connection-pool-size: 50
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "neo4j")
+public class Neo4jConfig {
+    
+    /**
+     * 是否启用 Neo4j(默认禁用,便于开发测试)
+     */
+    private boolean enabled = false;
+    
+    /**
+     * Neo4j 连接 URI
+     * 格式: bolt://host:port 或 neo4j://host:port
+     */
+    private String uri = "bolt://localhost:7687";
+    
+    /**
+     * 用户名
+     */
+    private String username = "neo4j";
+    
+    /**
+     * 密码
+     */
+    private String password;
+    
+    /**
+     * 数据库名称(Neo4j 4.0+ 支持多数据库)
+     */
+    private String database = "neo4j";
+    
+    /**
+     * 连接超时时间(秒)
+     */
+    private int connectionTimeoutSeconds = 30;
+    
+    /**
+     * 最大连接池大小
+     */
+    private int maxConnectionPoolSize = 50;
+    
+    private Driver driver;
+    
+    /**
+     * 创建 Neo4j Driver Bean
+     */
+    @Bean
+    @ConditionalOnProperty(name = "neo4j.enabled", havingValue = "true")
+    public Driver neo4jDriver() {
+        log.info("初始化 Neo4j 连接: uri={}, database={}", uri, database);
+        
+        Config config = Config.builder()
+                .withConnectionTimeout(connectionTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)
+                .withMaxConnectionPoolSize(maxConnectionPoolSize)
+                .withDriverMetrics()
+                .build();
+        
+        // 支持无密码模式(当 Neo4j 禁用认证时)
+        AuthToken auth;
+        if (password == null || password.isEmpty()) {
+            auth = AuthTokens.none();
+            log.info("Neo4j 使用无认证模式连接");
+        } else {
+            auth = AuthTokens.basic(username, password);
+        }
+        
+        this.driver = GraphDatabase.driver(uri, auth, config);
+        
+        // 验证连接
+        try {
+            driver.verifyConnectivity();
+            log.info("Neo4j 连接成功");
+        } catch (Exception e) {
+            log.error("Neo4j 连接失败: {}", e.getMessage());
+            throw new RuntimeException("无法连接到 Neo4j: " + e.getMessage(), e);
+        }
+        
+        return driver;
+    }
+    
+    /**
+     * 关闭连接
+     */
+    @PreDestroy
+    public void close() {
+        if (driver != null) {
+            log.info("关闭 Neo4j 连接");
+            driver.close();
+        }
+    }
+}

+ 114 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/KnowledgeGraphController.java

@@ -0,0 +1,114 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.dto.KnowledgeGraphDTO;
+import com.lingyue.graph.service.KnowledgeGraphService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 知识图谱控制器
+ * 
+ * 提供原型中"标记要素关系图谱"功能所需的 API
+ * - 图谱视图:节点 + 关系的可视化数据
+ * - 列表视图:按类型分组的实体列表
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/graph")
+@RequiredArgsConstructor
+@Tag(name = "知识图谱", description = "知识图谱可视化接口")
+public class KnowledgeGraphController {
+    
+    private final KnowledgeGraphService knowledgeGraphService;
+    
+    /**
+     * 获取文档的知识图谱数据
+     * 用于图谱视图渲染
+     */
+    @GetMapping("/documents/{documentId}")
+    @Operation(summary = "获取文档图谱", description = "获取文档的知识图谱数据(节点+关系)")
+    public AjaxResult<?> getDocumentGraph(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        KnowledgeGraphDTO graph = knowledgeGraphService.getDocumentGraph(documentId);
+        if (graph == null) {
+            return AjaxResult.error("文档不存在或无图谱数据");
+        }
+        
+        return AjaxResult.success(graph);
+    }
+    
+    /**
+     * 获取文档的实体列表(按类型分组)
+     * 用于列表视图渲染
+     */
+    @GetMapping("/documents/{documentId}/entities")
+    @Operation(summary = "获取实体列表", description = "获取文档的实体列表,按类型分组")
+    public AjaxResult<?> getEntityList(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId,
+            @Parameter(description = "类型筛选")
+            @RequestParam(required = false) String type) {
+        
+        var entities = knowledgeGraphService.getEntityListGroupedByType(documentId, type);
+        return AjaxResult.success(entities);
+    }
+    
+    /**
+     * 获取实体详情(包含关联实体)
+     */
+    @GetMapping("/entities/{entityId}")
+    @Operation(summary = "获取实体详情", description = "获取实体详情及其关联实体")
+    public AjaxResult<?> getEntityDetail(
+            @Parameter(description = "实体ID", required = true)
+            @PathVariable String entityId) {
+        
+        var detail = knowledgeGraphService.getEntityDetail(entityId);
+        if (detail == null) {
+            return AjaxResult.error("实体不存在");
+        }
+        
+        return AjaxResult.success(detail);
+    }
+    
+    /**
+     * 获取用户的全局知识图谱(跨文档)
+     */
+    @GetMapping("/users/{userId}")
+    @Operation(summary = "获取用户图谱", description = "获取用户的全局知识图谱(跨文档)")
+    public AjaxResult<?> getUserGraph(
+            @Parameter(description = "用户ID", required = true)
+            @PathVariable String userId,
+            @Parameter(description = "限制节点数量")
+            @RequestParam(defaultValue = "100") Integer limit) {
+        
+        KnowledgeGraphDTO graph = knowledgeGraphService.getUserGraph(userId, limit);
+        return AjaxResult.success(graph);
+    }
+    
+    /**
+     * 搜索实体
+     */
+    @GetMapping("/search")
+    @Operation(summary = "搜索实体", description = "按名称搜索实体")
+    public AjaxResult<?> searchEntities(
+            @Parameter(description = "搜索关键词", required = true)
+            @RequestParam String keyword,
+            @Parameter(description = "文档ID(可选,限定范围)")
+            @RequestParam(required = false) String documentId,
+            @Parameter(description = "限制数量")
+            @RequestParam(defaultValue = "20") Integer limit) {
+        
+        var results = knowledgeGraphService.searchEntities(keyword, documentId, limit);
+        return AjaxResult.success(results);
+    }
+}

+ 121 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/Neo4jGraphController.java

@@ -0,0 +1,121 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.dto.KnowledgeGraphDTO;
+import com.lingyue.graph.neo4j.Neo4jGraphService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Neo4j 图数据库控制器
+ * 
+ * 提供基于 Neo4j 的高级图查询功能
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/neo4j")
+@RequiredArgsConstructor
+@ConditionalOnBean(Neo4jGraphService.class)
+@Tag(name = "Neo4j 图数据库", description = "Neo4j 图查询接口")
+public class Neo4jGraphController {
+    
+    private final Neo4jGraphService neo4jGraphService;
+    
+    /**
+     * 获取文档的知识图谱(从 Neo4j)
+     */
+    @GetMapping("/documents/{documentId}/graph")
+    @Operation(summary = "获取文档图谱", description = "从 Neo4j 获取文档的知识图谱")
+    public AjaxResult<?> getDocumentGraph(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        KnowledgeGraphDTO graph = neo4jGraphService.getDocumentGraph(documentId);
+        if (graph == null) {
+            return AjaxResult.error("文档不存在或无图谱数据");
+        }
+        
+        return AjaxResult.success(graph);
+    }
+    
+    /**
+     * 查找最短路径
+     */
+    @GetMapping("/path")
+    @Operation(summary = "查找最短路径", description = "查找两个节点之间的最短路径")
+    public AjaxResult<?> findShortestPath(
+            @Parameter(description = "起始节点ID", required = true)
+            @RequestParam String fromNodeId,
+            @Parameter(description = "目标节点ID", required = true)
+            @RequestParam String toNodeId,
+            @Parameter(description = "最大深度")
+            @RequestParam(defaultValue = "5") int maxDepth) {
+        
+        List<Map<String, Object>> path = neo4jGraphService.findShortestPath(fromNodeId, toNodeId, maxDepth);
+        return AjaxResult.success(path);
+    }
+    
+    /**
+     * 获取节点邻域图
+     */
+    @GetMapping("/nodes/{nodeId}/neighborhood")
+    @Operation(summary = "获取邻域图", description = "获取节点的 N 跳邻居形成的子图")
+    public AjaxResult<?> getNeighborhood(
+            @Parameter(description = "节点ID", required = true)
+            @PathVariable String nodeId,
+            @Parameter(description = "邻域深度")
+            @RequestParam(defaultValue = "2") int depth) {
+        
+        KnowledgeGraphDTO graph = neo4jGraphService.getNeighborhood(nodeId, depth);
+        return AjaxResult.success(graph);
+    }
+    
+    /**
+     * 获取核心节点(按关联度排序)
+     */
+    @GetMapping("/documents/{documentId}/top-nodes")
+    @Operation(summary = "获取核心节点", description = "获取文档中最重要的节点(按关联度)")
+    public AjaxResult<?> getTopNodes(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId,
+            @Parameter(description = "返回数量")
+            @RequestParam(defaultValue = "10") int limit) {
+        
+        List<Map<String, Object>> nodes = neo4jGraphService.getTopNodesByPageRank(documentId, limit);
+        return AjaxResult.success(nodes);
+    }
+    
+    /**
+     * 删除文档图数据
+     */
+    @DeleteMapping("/documents/{documentId}")
+    @Operation(summary = "删除文档图数据", description = "删除文档在 Neo4j 中的所有节点和关系")
+    public AjaxResult<?> deleteDocumentGraph(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        neo4jGraphService.deleteDocumentGraph(documentId);
+        return AjaxResult.success("删除成功");
+    }
+    
+    /**
+     * 初始化索引
+     */
+    @PostMapping("/init-indexes")
+    @Operation(summary = "初始化索引", description = "创建 Neo4j 数据库索引")
+    public AjaxResult<?> initializeIndexes() {
+        neo4jGraphService.initializeIndexes();
+        return AjaxResult.success("索引初始化完成");
+    }
+}

+ 110 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/NerBlockController.java

@@ -0,0 +1,110 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.service.DocumentBlockGeneratorService;
+import com.lingyue.graph.service.DocumentBlockGeneratorService.BlockDTO;
+import com.lingyue.graph.service.GraphNerService;
+import com.lingyue.graph.service.NerToBlockService;
+import com.lingyue.graph.service.NerToBlockService.TextElementDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * NER 到 Block 转换控制器
+ * 
+ * 提供将 NER 结果转换为结构化 Block 的接口
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/ner")
+@RequiredArgsConstructor
+@Tag(name = "NER Block 转换", description = "NER 结果转换为结构化 Block")
+public class NerBlockController {
+    
+    private final GraphNerService graphNerService;
+    private final NerToBlockService nerToBlockService;
+    private final DocumentBlockGeneratorService blockGeneratorService;
+    
+    /**
+     * 将文本和实体转换为 TextElement 列表
+     */
+    @PostMapping("/convert-to-elements")
+    @Operation(summary = "转换为 TextElement", description = "将文本和 NER 实体转换为 TextElement 列表")
+    public AjaxResult<?> convertToElements(@RequestBody ConvertRequest request) {
+        try {
+            List<TextElementDTO> elements = nerToBlockService.convertToTextElements(
+                    request.getText(), request.getEntities());
+            return AjaxResult.success(elements);
+        } catch (Exception e) {
+            log.error("转换失败: {}", e.getMessage(), e);
+            return AjaxResult.error("转换失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 生成完整的文档块结构
+     */
+    @PostMapping("/generate-blocks")
+    @Operation(summary = "生成文档块", description = "将文本和 NER 实体生成完整的 Block 结构")
+    public AjaxResult<?> generateBlocks(@RequestBody GenerateBlocksRequest request) {
+        try {
+            List<BlockDTO> blocks = blockGeneratorService.generateBlocks(
+                    request.getDocumentId(), request.getText(), request.getEntities());
+            return AjaxResult.success(blocks);
+        } catch (Exception e) {
+            log.error("生成块失败: {}", e.getMessage(), e);
+            return AjaxResult.error("生成块失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 从已存储的文档生成块结构
+     */
+    @PostMapping("/documents/{documentId}/generate-blocks")
+    @Operation(summary = "从文档生成块", description = "读取已存储的文档文本,结合 NER 结果生成 Block 结构")
+    public AjaxResult<?> generateBlocksFromDocument(
+            @PathVariable String documentId,
+            @RequestBody(required = false) List<Map<String, Object>> entities) {
+        try {
+            // 获取文档文本
+            String text = graphNerService.getDocumentText(documentId);
+            
+            // 如果没有提供实体,尝试从图数据库获取
+            if (entities == null || entities.isEmpty()) {
+                // TODO: 从 GraphNode 表获取实体并转换
+                log.info("未提供实体,生成纯文本块: documentId={}", documentId);
+            }
+            
+            List<BlockDTO> blocks = blockGeneratorService.generateBlocks(documentId, text, entities);
+            return AjaxResult.success(blocks);
+        } catch (Exception e) {
+            log.error("从文档生成块失败: documentId={}, error={}", documentId, e.getMessage(), e);
+            return AjaxResult.error("生成失败: " + e.getMessage());
+        }
+    }
+    
+    // ==================== 请求 DTO ====================
+    
+    @Data
+    public static class ConvertRequest {
+        private String text;
+        private List<Map<String, Object>> entities;
+    }
+    
+    @Data
+    public static class GenerateBlocksRequest {
+        private String documentId;
+        private String text;
+        private List<Map<String, Object>> entities;
+    }
+}

+ 322 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/KnowledgeGraphDTO.java

@@ -0,0 +1,322 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识图谱 DTO
+ * 用于前端图谱可视化渲染
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "知识图谱")
+public class KnowledgeGraphDTO {
+    
+    @Schema(description = "文档ID(单文档图谱时)")
+    private String documentId;
+    
+    @Schema(description = "图谱标题")
+    private String title;
+    
+    @Schema(description = "节点列表")
+    private List<NodeDTO> nodes;
+    
+    @Schema(description = "关系/边列表")
+    private List<EdgeDTO> edges;
+    
+    @Schema(description = "统计信息")
+    private GraphStats stats;
+    
+    /**
+     * 节点 DTO
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "图谱节点")
+    public static class NodeDTO {
+        
+        @Schema(description = "节点ID")
+        private String id;
+        
+        @Schema(description = "节点名称(显示文本)")
+        private String name;
+        
+        @Schema(description = "节点类型", example = "entity/concept/data/location/person/org")
+        private String type;
+        
+        @Schema(description = "显示图标(emoji)")
+        private String icon;
+        
+        @Schema(description = "节点颜色")
+        private String color;
+        
+        @Schema(description = "节点大小(基于关联数量)")
+        private Integer size;
+        
+        @Schema(description = "关联实体数量")
+        private Integer relationCount;
+        
+        @Schema(description = "在文档中出现次数")
+        private Integer occurrenceCount;
+        
+        @Schema(description = "节点值(如数据类型的具体值)")
+        private String value;
+        
+        @Schema(description = "来源文档ID列表")
+        private List<String> documentIds;
+        
+        @Schema(description = "位置信息")
+        private Map<String, Object> position;
+        
+        @Schema(description = "元数据")
+        private Map<String, Object> metadata;
+    }
+    
+    /**
+     * 边/关系 DTO
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "图谱边/关系")
+    public static class EdgeDTO {
+        
+        @Schema(description = "边ID")
+        private String id;
+        
+        @Schema(description = "源节点ID")
+        private String source;
+        
+        @Schema(description = "源节点名称")
+        private String sourceName;
+        
+        @Schema(description = "目标节点ID")
+        private String target;
+        
+        @Schema(description = "目标节点名称")
+        private String targetName;
+        
+        @Schema(description = "关系类型", example = "属于/包含/位于/负责")
+        private String relationType;
+        
+        @Schema(description = "关系标签(显示文本)")
+        private String label;
+        
+        @Schema(description = "边的权重/强度")
+        private Double weight;
+        
+        @Schema(description = "元数据")
+        private Map<String, Object> metadata;
+    }
+    
+    /**
+     * 图谱统计
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "图谱统计")
+    public static class GraphStats {
+        
+        @Schema(description = "总节点数")
+        private Integer totalNodes;
+        
+        @Schema(description = "总关系数")
+        private Integer totalEdges;
+        
+        @Schema(description = "按类型统计节点数")
+        private Map<String, Integer> nodesByType;
+        
+        @Schema(description = "按类型统计关系数")
+        private Map<String, Integer> edgesByType;
+    }
+    
+    /**
+     * 实体列表项 DTO(用于列表视图)
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "实体列表项")
+    public static class EntityListItemDTO {
+        
+        @Schema(description = "实体ID")
+        private String id;
+        
+        @Schema(description = "实体名称")
+        private String name;
+        
+        @Schema(description = "实体类型")
+        private String type;
+        
+        @Schema(description = "类型显示名称")
+        private String typeName;
+        
+        @Schema(description = "图标")
+        private String icon;
+        
+        @Schema(description = "颜色")
+        private String color;
+        
+        @Schema(description = "出现次数")
+        private Integer occurrenceCount;
+        
+        @Schema(description = "关联实体数量")
+        private Integer relationCount;
+        
+        @Schema(description = "关联实体预览(前几个)")
+        private List<RelatedEntityDTO> relatedEntities;
+    }
+    
+    /**
+     * 关联实体预览
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "关联实体")
+    public static class RelatedEntityDTO {
+        
+        @Schema(description = "实体ID")
+        private String id;
+        
+        @Schema(description = "实体名称")
+        private String name;
+        
+        @Schema(description = "关系类型")
+        private String relationType;
+    }
+    
+    /**
+     * 实体列表分组
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "实体列表分组")
+    public static class EntityGroupDTO {
+        
+        @Schema(description = "分组类型")
+        private String type;
+        
+        @Schema(description = "分组名称")
+        private String typeName;
+        
+        @Schema(description = "分组颜色")
+        private String color;
+        
+        @Schema(description = "实体数量")
+        private Integer count;
+        
+        @Schema(description = "实体列表")
+        private List<EntityListItemDTO> entities;
+    }
+    
+    /**
+     * 实体详情
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "实体详情")
+    public static class EntityDetailDTO {
+        
+        @Schema(description = "实体ID")
+        private String id;
+        
+        @Schema(description = "实体名称")
+        private String name;
+        
+        @Schema(description = "实体类型")
+        private String type;
+        
+        @Schema(description = "类型显示名称")
+        private String typeName;
+        
+        @Schema(description = "实体值")
+        private String value;
+        
+        @Schema(description = "图标")
+        private String icon;
+        
+        @Schema(description = "颜色")
+        private String color;
+        
+        @Schema(description = "出现次数")
+        private Integer occurrenceCount;
+        
+        @Schema(description = "所有关联实体")
+        private List<RelatedEntityDTO> allRelations;
+        
+        @Schema(description = "出现位置列表")
+        private List<OccurrenceDTO> occurrences;
+        
+        @Schema(description = "来源文档列表")
+        private List<DocumentRefDTO> documents;
+        
+        @Schema(description = "元数据")
+        private Map<String, Object> metadata;
+    }
+    
+    /**
+     * 出现位置
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "出现位置")
+    public static class OccurrenceDTO {
+        
+        @Schema(description = "文档ID")
+        private String documentId;
+        
+        @Schema(description = "文档名称")
+        private String documentName;
+        
+        @Schema(description = "上下文片段")
+        private String context;
+        
+        @Schema(description = "位置信息")
+        private Map<String, Object> position;
+    }
+    
+    /**
+     * 文档引用
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "文档引用")
+    public static class DocumentRefDTO {
+        
+        @Schema(description = "文档ID")
+        private String documentId;
+        
+        @Schema(description = "文档名称")
+        private String documentName;
+        
+        @Schema(description = "在该文档中的出现次数")
+        private Integer count;
+    }
+}

+ 15 - 1
backend/graph-service/src/main/java/com/lingyue/graph/listener/DocumentParsedEventListener.java

@@ -2,6 +2,8 @@ package com.lingyue.graph.listener;
 
 import com.lingyue.common.event.DocumentParsedEvent;
 import com.lingyue.graph.service.GraphNerService;
+import com.lingyue.graph.service.NerToBlockService;
+import com.lingyue.graph.service.NerToBlockService.TextElementDTO;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -16,6 +18,8 @@ import java.util.*;
 /**
  * 文档解析完成事件监听器
  * 监听文档解析完成事件,自动触发 NER 提取并保存到图数据库
+ * 
+ * 2026-01-21 更新:增加将 NER 结果转换为 TextElement 的能力
  *
  * @author lingyue
  * @since 2026-01-19
@@ -26,6 +30,7 @@ import java.util.*;
 public class DocumentParsedEventListener {
 
     private final GraphNerService graphNerService;
+    private final NerToBlockService nerToBlockService;
     private final RestTemplate restTemplate;
 
     @Value("${ner.auto-extract.enabled:true}")
@@ -98,13 +103,22 @@ public class DocumentParsedEventListener {
             @SuppressWarnings("unchecked")
             List<Map<String, Object>> relations = (List<Map<String, Object>>) nerResponse.get("relations");
             int relationCount = graphNerService.saveRelationsToGraph(relations, tempIdToNodeId);
+            
+            // 5. 将 NER 结果转换为 TextElement 格式(用于结构化文档)
+            List<TextElementDTO> textElements = nerToBlockService.convertToTextElements(text, entities);
+            log.info("NER 结果已转换为 TextElement: documentId={}, elementCount={}", 
+                    documentId, textElements.size());
+            
+            // TODO: 将 textElements 保存到 DocumentBlock 表
+            // 这需要调用 document-service 的 API 或通过事件通知
 
             long processingTime = System.currentTimeMillis() - startTime;
             
-            log.info("NER 自动提取完成: documentId={}, entityCount={}, relationCount={}, time={}ms",
+            log.info("NER 自动提取完成: documentId={}, entityCount={}, relationCount={}, textElements={}, time={}ms",
                     documentId,
                     entities != null ? entities.size() : 0,
                     relationCount,
+                    textElements.size(),
                     processingTime);
 
         } catch (Exception e) {

+ 441 - 0
backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jGraphService.java

@@ -0,0 +1,441 @@
+package com.lingyue.graph.neo4j;
+
+import com.lingyue.graph.dto.KnowledgeGraphDTO;
+import com.lingyue.graph.dto.KnowledgeGraphDTO.*;
+import com.lingyue.graph.entity.GraphNode;
+import com.lingyue.graph.entity.GraphRelation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.Result;
+import org.neo4j.driver.Session;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Neo4j 图服务
+ * 
+ * 提供高级图操作功能:
+ * - 同步 PostgreSQL 数据到 Neo4j
+ * - 图遍历和路径查询
+ * - 图分析(中心性、社区检测等)
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnBean(Driver.class)
+public class Neo4jGraphService {
+    
+    private final Driver driver;
+    private final Neo4jNodeRepository nodeRepository;
+    private final Neo4jRelationRepository relationRepository;
+    
+    // 类型映射
+    private 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")
+    );
+    
+    private 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", "🏷️")
+    );
+    
+    private 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")
+    );
+    
+    // ==================== 同步方法 ====================
+    
+    /**
+     * 同步单个节点到 Neo4j
+     */
+    public void syncNode(GraphNode node) {
+        String type = node.getType() != null ? node.getType().toLowerCase() : "entity";
+        String label = TYPE_LABELS.getOrDefault(type, "Entity");
+        
+        Map<String, Object> properties = new HashMap<>();
+        properties.put("id", node.getId());
+        properties.put("name", node.getName());
+        properties.put("type", type);
+        properties.put("value", node.getValue());
+        properties.put("documentId", node.getDocumentId());
+        properties.put("userId", node.getUserId());
+        properties.put("createdAt", node.getCreateTime() != null ? node.getCreateTime().toString() : null);
+        
+        // 添加位置信息
+        if (node.getPosition() instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> pos = (Map<String, Object>) node.getPosition();
+            properties.put("charStart", pos.get("charStart"));
+            properties.put("charEnd", pos.get("charEnd"));
+        }
+        
+        nodeRepository.createOrUpdateNode(node.getId(), List.of("Entity", label), properties);
+        log.debug("同步节点到 Neo4j: id={}, name={}", node.getId(), node.getName());
+    }
+    
+    /**
+     * 批量同步节点
+     */
+    public void syncNodes(List<GraphNode> nodes) {
+        List<Map<String, Object>> nodeDataList = nodes.stream()
+                .map(node -> {
+                    String type = node.getType() != null ? node.getType().toLowerCase() : "entity";
+                    String label = TYPE_LABELS.getOrDefault(type, "Entity");
+                    
+                    Map<String, Object> data = new HashMap<>();
+                    data.put("id", node.getId());
+                    data.put("labels", List.of("Entity", label));
+                    
+                    Map<String, Object> props = new HashMap<>();
+                    props.put("name", node.getName());
+                    props.put("type", type);
+                    props.put("value", node.getValue());
+                    props.put("documentId", node.getDocumentId());
+                    props.put("userId", node.getUserId());
+                    data.put("properties", props);
+                    
+                    return data;
+                })
+                .collect(Collectors.toList());
+        
+        nodeRepository.batchCreateNodes(nodeDataList);
+        log.info("批量同步节点到 Neo4j: count={}", nodes.size());
+    }
+    
+    /**
+     * 同步单个关系到 Neo4j
+     */
+    public void syncRelation(GraphRelation relation) {
+        Map<String, Object> properties = new HashMap<>();
+        properties.put("id", relation.getId());
+        properties.put("relationType", relation.getRelationType());
+        properties.put("actionType", relation.getActionType());
+        properties.put("orderIndex", relation.getOrderIndex());
+        
+        String relType = relation.getRelationType() != null ? relation.getRelationType() : "RELATED";
+        
+        relationRepository.createRelation(
+                relation.getFromNodeId(),
+                relation.getToNodeId(),
+                relType,
+                properties
+        );
+        
+        log.debug("同步关系到 Neo4j: from={}, to={}, type={}", 
+                relation.getFromNodeId(), relation.getToNodeId(), relType);
+    }
+    
+    /**
+     * 批量同步关系
+     */
+    public void syncRelations(List<GraphRelation> relations) {
+        List<Map<String, Object>> relDataList = relations.stream()
+                .map(rel -> {
+                    Map<String, Object> data = new HashMap<>();
+                    data.put("fromId", rel.getFromNodeId());
+                    data.put("toId", rel.getToNodeId());
+                    data.put("type", rel.getRelationType() != null ? rel.getRelationType() : "RELATED");
+                    
+                    Map<String, Object> props = new HashMap<>();
+                    props.put("id", rel.getId());
+                    props.put("actionType", rel.getActionType());
+                    props.put("orderIndex", rel.getOrderIndex());
+                    data.put("properties", props);
+                    
+                    return data;
+                })
+                .collect(Collectors.toList());
+        
+        relationRepository.batchCreateRelations(relDataList);
+        log.info("批量同步关系到 Neo4j: count={}", relations.size());
+    }
+    
+    // ==================== 图查询方法 ====================
+    
+    /**
+     * 获取文档的完整知识图谱(从 Neo4j)
+     */
+    public KnowledgeGraphDTO getDocumentGraph(String documentId) {
+        List<Map<String, Object>> nodes = nodeRepository.findByDocumentId(documentId);
+        if (nodes.isEmpty()) {
+            return null;
+        }
+        
+        List<Map<String, Object>> relations = relationRepository.findByDocumentId(documentId);
+        
+        // 转换节点
+        List<NodeDTO> nodeDTOs = nodes.stream()
+                .map(this::convertToNodeDTO)
+                .collect(Collectors.toList());
+        
+        // 计算关联数量
+        Map<String, Integer> relationCounts = new HashMap<>();
+        for (Map<String, Object> rel : relations) {
+            String fromId = (String) rel.get("fromId");
+            String toId = (String) rel.get("toId");
+            relationCounts.merge(fromId, 1, Integer::sum);
+            relationCounts.merge(toId, 1, Integer::sum);
+        }
+        
+        // 更新节点的关联数量和大小
+        for (NodeDTO node : nodeDTOs) {
+            int count = relationCounts.getOrDefault(node.getId(), 0);
+            node.setRelationCount(count);
+            node.setSize(40 + Math.min(count * 5, 30));
+        }
+        
+        // 转换边
+        List<EdgeDTO> edgeDTOs = relations.stream()
+                .map(rel -> EdgeDTO.builder()
+                        .id((String) rel.get("id"))
+                        .source((String) rel.get("fromId"))
+                        .sourceName((String) rel.get("fromName"))
+                        .target((String) rel.get("toId"))
+                        .targetName((String) rel.get("toName"))
+                        .relationType((String) rel.get("type"))
+                        .label((String) rel.get("type"))
+                        .weight(1.0)
+                        .build())
+                .collect(Collectors.toList());
+        
+        // 统计
+        GraphStats stats = GraphStats.builder()
+                .totalNodes(nodeDTOs.size())
+                .totalEdges(edgeDTOs.size())
+                .nodesByType(nodes.stream()
+                        .collect(Collectors.groupingBy(
+                                n -> (String) n.getOrDefault("type", "other"),
+                                Collectors.collectingAndThen(Collectors.counting(), Long::intValue))))
+                .edgesByType(relations.stream()
+                        .collect(Collectors.groupingBy(
+                                r -> (String) r.getOrDefault("type", "RELATED"),
+                                Collectors.collectingAndThen(Collectors.counting(), Long::intValue))))
+                .build();
+        
+        return KnowledgeGraphDTO.builder()
+                .documentId(documentId)
+                .title("标记要素关系图谱")
+                .nodes(nodeDTOs)
+                .edges(edgeDTOs)
+                .stats(stats)
+                .build();
+    }
+    
+    /**
+     * 查找两节点之间的最短路径
+     */
+    public List<Map<String, Object>> findShortestPath(String fromNodeId, String toNodeId, int maxDepth) {
+        String cypher = 
+                "MATCH path = shortestPath((a {id: $fromId})-[*1.." + maxDepth + "]-(b {id: $toId})) " +
+                "RETURN [n IN nodes(path) | {id: n.id, name: n.name, type: n.type}] AS nodes, " +
+                "       [r IN relationships(path) | {type: type(r)}] AS relations";
+        
+        List<Map<String, Object>> result = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result queryResult = session.run(cypher, Map.of("fromId", fromNodeId, "toId", toNodeId));
+            if (queryResult.hasNext()) {
+                var record = queryResult.next();
+                result = record.get("nodes").asList(v -> v.asMap());
+            }
+        }
+        
+        return result;
+    }
+    
+    /**
+     * 获取节点的 N 跳邻居
+     */
+    public KnowledgeGraphDTO getNeighborhood(String nodeId, int depth) {
+        String cypher = 
+                "MATCH path = (center {id: $nodeId})-[*1.." + depth + "]-(neighbor) " +
+                "WITH center, neighbor, relationships(path) AS rels " +
+                "RETURN DISTINCT neighbor, labels(neighbor) AS labels, " +
+                "       [r IN rels | {type: type(r), from: startNode(r).id, to: endNode(r).id}] AS relInfo";
+        
+        Set<String> nodeIds = new HashSet<>();
+        nodeIds.add(nodeId);
+        
+        List<NodeDTO> nodes = new ArrayList<>();
+        List<EdgeDTO> edges = new ArrayList<>();
+        Set<String> edgeKeys = new HashSet<>();
+        
+        // 添加中心节点
+        Map<String, Object> centerNode = nodeRepository.findById(nodeId);
+        if (centerNode != null) {
+            nodes.add(convertToNodeDTO(centerNode));
+        }
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("nodeId", nodeId));
+            
+            while (result.hasNext()) {
+                var record = result.next();
+                var neighbor = record.get("neighbor").asNode();
+                var labels = record.get("labels").asList(v -> v.asString());
+                var relInfo = record.get("relInfo").asList(v -> v.asMap());
+                
+                String neighborId = neighbor.get("id").asString();
+                
+                if (!nodeIds.contains(neighborId)) {
+                    nodeIds.add(neighborId);
+                    
+                    Map<String, Object> nodeMap = new HashMap<>(neighbor.asMap());
+                    nodeMap.put("_labels", labels);
+                    nodes.add(convertToNodeDTO(nodeMap));
+                }
+                
+                // 处理关系
+                for (var rel : relInfo) {
+                    String from = (String) rel.get("from");
+                    String to = (String) rel.get("to");
+                    String type = (String) rel.get("type");
+                    String edgeKey = from + "-" + to + "-" + type;
+                    
+                    if (!edgeKeys.contains(edgeKey)) {
+                        edgeKeys.add(edgeKey);
+                        edges.add(EdgeDTO.builder()
+                                .id(edgeKey)
+                                .source(from)
+                                .target(to)
+                                .relationType(type)
+                                .label(type)
+                                .build());
+                    }
+                }
+            }
+        }
+        
+        return KnowledgeGraphDTO.builder()
+                .title("邻域图谱")
+                .nodes(nodes)
+                .edges(edges)
+                .stats(GraphStats.builder()
+                        .totalNodes(nodes.size())
+                        .totalEdges(edges.size())
+                        .build())
+                .build();
+    }
+    
+    /**
+     * 获取高度中心性节点(PageRank)
+     * 需要 Graph Data Science 插件
+     */
+    public List<Map<String, Object>> getTopNodesByPageRank(String documentId, int limit) {
+        // 简化版:按关系数量排序
+        String cypher = 
+                "MATCH (n {documentId: $documentId})-[r]-() " +
+                "WITH n, count(r) AS degree " +
+                "ORDER BY degree DESC " +
+                "LIMIT $limit " +
+                "RETURN n.id AS id, n.name AS name, n.type AS type, degree";
+        
+        List<Map<String, Object>> result = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result queryResult = session.run(cypher, Map.of("documentId", documentId, "limit", limit));
+            while (queryResult.hasNext()) {
+                var record = queryResult.next();
+                result.add(Map.of(
+                        "id", record.get("id").asString(),
+                        "name", record.get("name").asString(),
+                        "type", record.get("type").asString(),
+                        "degree", record.get("degree").asInt()
+                ));
+            }
+        }
+        
+        return result;
+    }
+    
+    // ==================== 辅助方法 ====================
+    
+    private NodeDTO convertToNodeDTO(Map<String, Object> nodeMap) {
+        String type = (String) nodeMap.getOrDefault("type", "entity");
+        if (type == null) type = "entity";
+        type = type.toLowerCase();
+        
+        return NodeDTO.builder()
+                .id((String) nodeMap.get("id"))
+                .name((String) nodeMap.get("name"))
+                .type(type)
+                .icon(TYPE_ICONS.getOrDefault(type, "📌"))
+                .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                .size(40)
+                .value((String) nodeMap.get("value"))
+                .documentIds(nodeMap.get("documentId") != null 
+                        ? List.of((String) nodeMap.get("documentId")) 
+                        : Collections.emptyList())
+                .build();
+    }
+    
+    /**
+     * 删除文档的所有图数据
+     */
+    public void deleteDocumentGraph(String documentId) {
+        nodeRepository.deleteByDocumentId(documentId);
+        log.info("删除文档图数据: documentId={}", documentId);
+    }
+    
+    /**
+     * 初始化图数据库索引
+     */
+    public void initializeIndexes() {
+        String[] indexes = {
+                "CREATE INDEX node_id IF NOT EXISTS FOR (n:Entity) ON (n.id)",
+                "CREATE INDEX node_document IF NOT EXISTS FOR (n:Entity) ON (n.documentId)",
+                "CREATE INDEX node_user IF NOT EXISTS FOR (n:Entity) ON (n.userId)",
+                "CREATE INDEX node_name IF NOT EXISTS FOR (n:Entity) ON (n.name)"
+        };
+        
+        try (Session session = driver.session()) {
+            for (String index : indexes) {
+                try {
+                    session.run(index);
+                    log.info("创建索引成功: {}", index);
+                } catch (Exception e) {
+                    log.warn("创建索引失败(可能已存在): {}", e.getMessage());
+                }
+            }
+        }
+    }
+}

+ 313 - 0
backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jNodeRepository.java

@@ -0,0 +1,313 @@
+package com.lingyue.graph.neo4j;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.Result;
+import org.neo4j.driver.Session;
+import org.neo4j.driver.Value;
+import org.neo4j.driver.types.Node;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Repository;
+
+import java.util.*;
+
+/**
+ * Neo4j 节点仓库
+ * 
+ * 提供图节点的 CRUD 操作
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+@ConditionalOnBean(Driver.class)
+public class Neo4jNodeRepository {
+    
+    private final Driver driver;
+    
+    /**
+     * 创建或更新节点
+     * 
+     * @param nodeId 节点ID
+     * @param labels 节点标签列表(如 Entity, Person, Concept)
+     * @param properties 节点属性
+     * @return 创建的节点ID
+     */
+    public String createOrUpdateNode(String nodeId, List<String> labels, Map<String, Object> properties) {
+        String labelString = String.join(":", labels);
+        
+        // 使用 MERGE 确保幂等性
+        String cypher = String.format(
+                "MERGE (n:%s {id: $id}) " +
+                "SET n += $props " +
+                "RETURN n.id AS id",
+                labelString
+        );
+        
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", nodeId);
+        params.put("props", properties);
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, params);
+            if (result.hasNext()) {
+                return result.next().get("id").asString();
+            }
+        } catch (Exception e) {
+            log.error("创建节点失败: nodeId={}, error={}", nodeId, e.getMessage());
+            throw new RuntimeException("Neo4j 创建节点失败", e);
+        }
+        
+        return nodeId;
+    }
+    
+    /**
+     * 批量创建节点(使用 UNWIND 提高性能)
+     * 
+     * @param nodes 节点列表,每个节点包含 id, labels, properties
+     */
+    public void batchCreateNodes(List<Map<String, Object>> nodes) {
+        if (nodes == null || nodes.isEmpty()) {
+            return;
+        }
+        
+        String cypher = 
+                "UNWIND $nodes AS node " +
+                "CALL apoc.merge.node(node.labels, {id: node.id}, node.properties) YIELD node AS n " +
+                "RETURN count(n) AS count";
+        
+        // 如果没有 APOC 插件,使用简单版本
+        String simpleCypher = 
+                "UNWIND $nodes AS node " +
+                "MERGE (n:Entity {id: node.id}) " +
+                "SET n += node.properties " +
+                "RETURN count(n) AS count";
+        
+        try (Session session = driver.session()) {
+            try {
+                Result result = session.run(cypher, Map.of("nodes", nodes));
+                log.info("批量创建节点完成: count={}", result.single().get("count").asInt());
+            } catch (Exception e) {
+                // 回退到简单版本
+                log.warn("APOC 不可用,使用简单模式");
+                Result result = session.run(simpleCypher, Map.of("nodes", nodes));
+                log.info("批量创建节点完成: count={}", result.single().get("count").asInt());
+            }
+        }
+    }
+    
+    /**
+     * 根据ID查找节点
+     */
+    public Map<String, Object> findById(String nodeId) {
+        String cypher = "MATCH (n {id: $id}) RETURN n, labels(n) AS labels";
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("id", nodeId));
+            if (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("n").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                return nodeMap;
+            }
+        }
+        
+        return null;
+    }
+    
+    /**
+     * 根据文档ID查找所有节点
+     */
+    public List<Map<String, Object>> findByDocumentId(String documentId) {
+        String cypher = 
+                "MATCH (n) WHERE n.documentId = $documentId " +
+                "RETURN n, labels(n) AS labels";
+        
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("n").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                nodes.add(nodeMap);
+            }
+        }
+        
+        return nodes;
+    }
+    
+    /**
+     * 根据用户ID查找所有节点
+     */
+    public List<Map<String, Object>> findByUserId(String userId, int limit) {
+        String cypher = 
+                "MATCH (n) WHERE n.userId = $userId " +
+                "RETURN n, labels(n) AS labels " +
+                "LIMIT $limit";
+        
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("userId", userId, "limit", limit));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("n").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                nodes.add(nodeMap);
+            }
+        }
+        
+        return nodes;
+    }
+    
+    /**
+     * 按类型查找节点
+     */
+    public List<Map<String, Object>> findByType(String documentId, String type) {
+        String cypher = 
+                "MATCH (n) WHERE n.documentId = $documentId AND n.type = $type " +
+                "RETURN n, labels(n) AS labels";
+        
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId, "type", type));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("n").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                nodes.add(nodeMap);
+            }
+        }
+        
+        return nodes;
+    }
+    
+    /**
+     * 搜索节点(按名称模糊匹配)
+     */
+    public List<Map<String, Object>> searchByName(String keyword, String documentId, int limit) {
+        String cypher;
+        Map<String, Object> params = new HashMap<>();
+        params.put("keyword", "(?i).*" + keyword + ".*");
+        params.put("limit", limit);
+        
+        if (documentId != null && !documentId.isEmpty()) {
+            cypher = 
+                    "MATCH (n) WHERE n.documentId = $documentId AND n.name =~ $keyword " +
+                    "RETURN n, labels(n) AS labels " +
+                    "LIMIT $limit";
+            params.put("documentId", documentId);
+        } else {
+            cypher = 
+                    "MATCH (n) WHERE n.name =~ $keyword " +
+                    "RETURN n, labels(n) AS labels " +
+                    "LIMIT $limit";
+        }
+        
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, params);
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("n").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                nodes.add(nodeMap);
+            }
+        }
+        
+        return nodes;
+    }
+    
+    /**
+     * 删除节点
+     */
+    public void deleteById(String nodeId) {
+        String cypher = "MATCH (n {id: $id}) DETACH DELETE n";
+        
+        try (Session session = driver.session()) {
+            session.run(cypher, Map.of("id", nodeId));
+            log.info("删除节点: nodeId={}", nodeId);
+        }
+    }
+    
+    /**
+     * 删除文档的所有节点和关系
+     */
+    public void deleteByDocumentId(String documentId) {
+        String cypher = "MATCH (n) WHERE n.documentId = $documentId DETACH DELETE n";
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            log.info("删除文档节点: documentId={}", documentId);
+        }
+    }
+    
+    /**
+     * 获取节点的所有关联节点
+     */
+    public List<Map<String, Object>> findRelatedNodes(String nodeId) {
+        String cypher = 
+                "MATCH (n {id: $id})-[r]-(m) " +
+                "RETURN m, labels(m) AS labels, type(r) AS relationType, " +
+                "       CASE WHEN startNode(r) = n THEN 'outgoing' ELSE 'incoming' END AS direction";
+        
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("id", nodeId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Node node = record.get("m").asNode();
+                List<String> labels = record.get("labels").asList(Value::asString);
+                String relationType = record.get("relationType").asString();
+                String direction = record.get("direction").asString();
+                
+                Map<String, Object> nodeMap = new HashMap<>(node.asMap());
+                nodeMap.put("_labels", labels);
+                nodeMap.put("_relationType", relationType);
+                nodeMap.put("_direction", direction);
+                nodes.add(nodeMap);
+            }
+        }
+        
+        return nodes;
+    }
+    
+    /**
+     * 统计节点数量
+     */
+    public long countByDocumentId(String documentId) {
+        String cypher = "MATCH (n) WHERE n.documentId = $documentId RETURN count(n) AS count";
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            if (result.hasNext()) {
+                return result.next().get("count").asLong();
+            }
+        }
+        
+        return 0;
+    }
+}

+ 271 - 0
backend/graph-service/src/main/java/com/lingyue/graph/neo4j/Neo4jRelationRepository.java

@@ -0,0 +1,271 @@
+package com.lingyue.graph.neo4j;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.Result;
+import org.neo4j.driver.Session;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Repository;
+
+import java.util.*;
+
+/**
+ * Neo4j 关系仓库
+ * 
+ * 提供图关系的 CRUD 操作
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+@ConditionalOnBean(Driver.class)
+public class Neo4jRelationRepository {
+    
+    private final Driver driver;
+    
+    /**
+     * 创建关系
+     * 
+     * @param fromNodeId 源节点ID
+     * @param toNodeId 目标节点ID
+     * @param relationType 关系类型
+     * @param properties 关系属性
+     * @return 关系ID
+     */
+    public String createRelation(String fromNodeId, String toNodeId, String relationType, 
+                                  Map<String, Object> properties) {
+        // 动态关系类型需要使用 APOC 或字符串拼接
+        String cypher = String.format(
+                "MATCH (a {id: $fromId}), (b {id: $toId}) " +
+                "MERGE (a)-[r:%s]->(b) " +
+                "SET r += $props " +
+                "RETURN id(r) AS relId",
+                sanitizeRelationType(relationType)
+        );
+        
+        Map<String, Object> params = new HashMap<>();
+        params.put("fromId", fromNodeId);
+        params.put("toId", toNodeId);
+        params.put("props", properties != null ? properties : Collections.emptyMap());
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, params);
+            if (result.hasNext()) {
+                long relId = result.next().get("relId").asLong();
+                return String.valueOf(relId);
+            }
+        } catch (Exception e) {
+            log.error("创建关系失败: from={}, to={}, type={}, error={}", 
+                    fromNodeId, toNodeId, relationType, e.getMessage());
+            throw new RuntimeException("Neo4j 创建关系失败", e);
+        }
+        
+        return null;
+    }
+    
+    /**
+     * 批量创建关系
+     * 
+     * @param relations 关系列表,每个包含 fromId, toId, type, properties
+     */
+    public void batchCreateRelations(List<Map<String, Object>> relations) {
+        if (relations == null || relations.isEmpty()) {
+            return;
+        }
+        
+        // 按关系类型分组处理(因为 Cypher 不支持动态关系类型)
+        Map<String, List<Map<String, Object>>> byType = new HashMap<>();
+        for (Map<String, Object> rel : relations) {
+            String type = (String) rel.getOrDefault("type", "RELATED");
+            byType.computeIfAbsent(type, k -> new ArrayList<>()).add(rel);
+        }
+        
+        try (Session session = driver.session()) {
+            for (Map.Entry<String, List<Map<String, Object>>> entry : byType.entrySet()) {
+                String relationType = sanitizeRelationType(entry.getKey());
+                List<Map<String, Object>> typeRelations = entry.getValue();
+                
+                String cypher = String.format(
+                        "UNWIND $relations AS rel " +
+                        "MATCH (a {id: rel.fromId}), (b {id: rel.toId}) " +
+                        "MERGE (a)-[r:%s]->(b) " +
+                        "SET r += rel.properties " +
+                        "RETURN count(r) AS count",
+                        relationType
+                );
+                
+                Result result = session.run(cypher, Map.of("relations", typeRelations));
+                log.info("批量创建关系完成: type={}, count={}", 
+                        relationType, result.single().get("count").asInt());
+            }
+        }
+    }
+    
+    /**
+     * 查找节点的所有关系
+     */
+    public List<Map<String, Object>> findByNodeId(String nodeId) {
+        String cypher = 
+                "MATCH (n {id: $nodeId})-[r]-(m) " +
+                "RETURN id(r) AS relId, type(r) AS relationType, " +
+                "       startNode(r).id AS fromId, endNode(r).id AS toId, " +
+                "       properties(r) AS properties";
+        
+        List<Map<String, Object>> relations = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("nodeId", nodeId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Map<String, Object> rel = new HashMap<>();
+                rel.put("id", String.valueOf(record.get("relId").asLong()));
+                rel.put("type", record.get("relationType").asString());
+                rel.put("fromId", record.get("fromId").asString());
+                rel.put("toId", record.get("toId").asString());
+                rel.put("properties", record.get("properties").asMap());
+                relations.add(rel);
+            }
+        }
+        
+        return relations;
+    }
+    
+    /**
+     * 查找两个节点之间的关系
+     */
+    public List<Map<String, Object>> findBetween(String fromNodeId, String toNodeId) {
+        String cypher = 
+                "MATCH (a {id: $fromId})-[r]->(b {id: $toId}) " +
+                "RETURN id(r) AS relId, type(r) AS relationType, properties(r) AS properties";
+        
+        List<Map<String, Object>> relations = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("fromId", fromNodeId, "toId", toNodeId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Map<String, Object> rel = new HashMap<>();
+                rel.put("id", String.valueOf(record.get("relId").asLong()));
+                rel.put("type", record.get("relationType").asString());
+                rel.put("fromId", fromNodeId);
+                rel.put("toId", toNodeId);
+                rel.put("properties", record.get("properties").asMap());
+                relations.add(rel);
+            }
+        }
+        
+        return relations;
+    }
+    
+    /**
+     * 按文档查找所有关系
+     */
+    public List<Map<String, Object>> findByDocumentId(String documentId) {
+        String cypher = 
+                "MATCH (a)-[r]->(b) " +
+                "WHERE a.documentId = $documentId AND b.documentId = $documentId " +
+                "RETURN id(r) AS relId, type(r) AS relationType, " +
+                "       a.id AS fromId, a.name AS fromName, " +
+                "       b.id AS toId, b.name AS toName, " +
+                "       properties(r) AS properties";
+        
+        List<Map<String, Object>> relations = new ArrayList<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                Map<String, Object> rel = new HashMap<>();
+                rel.put("id", String.valueOf(record.get("relId").asLong()));
+                rel.put("type", record.get("relationType").asString());
+                rel.put("fromId", record.get("fromId").asString());
+                rel.put("fromName", record.get("fromName").asString());
+                rel.put("toId", record.get("toId").asString());
+                rel.put("toName", record.get("toName").asString());
+                rel.put("properties", record.get("properties").asMap());
+                relations.add(rel);
+            }
+        }
+        
+        return relations;
+    }
+    
+    /**
+     * 删除关系
+     */
+    public void deleteById(long relationId) {
+        String cypher = "MATCH ()-[r]->() WHERE id(r) = $relId DELETE r";
+        
+        try (Session session = driver.session()) {
+            session.run(cypher, Map.of("relId", relationId));
+            log.info("删除关系: relId={}", relationId);
+        }
+    }
+    
+    /**
+     * 删除两节点间的所有关系
+     */
+    public void deleteBetween(String fromNodeId, String toNodeId) {
+        String cypher = "MATCH (a {id: $fromId})-[r]->(b {id: $toId}) DELETE r";
+        
+        try (Session session = driver.session()) {
+            session.run(cypher, Map.of("fromId", fromNodeId, "toId", toNodeId));
+            log.info("删除关系: from={}, to={}", fromNodeId, toNodeId);
+        }
+    }
+    
+    /**
+     * 统计关系数量
+     */
+    public long countByDocumentId(String documentId) {
+        String cypher = 
+                "MATCH (a {documentId: $documentId})-[r]->(b {documentId: $documentId}) " +
+                "RETURN count(r) AS count";
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            if (result.hasNext()) {
+                return result.next().get("count").asLong();
+            }
+        }
+        
+        return 0;
+    }
+    
+    /**
+     * 获取关系类型统计
+     */
+    public Map<String, Long> getRelationTypeStats(String documentId) {
+        String cypher = 
+                "MATCH (a {documentId: $documentId})-[r]->(b {documentId: $documentId}) " +
+                "RETURN type(r) AS relationType, count(r) AS count";
+        
+        Map<String, Long> stats = new HashMap<>();
+        
+        try (Session session = driver.session()) {
+            Result result = session.run(cypher, Map.of("documentId", documentId));
+            while (result.hasNext()) {
+                org.neo4j.driver.Record record = result.next();
+                stats.put(record.get("relationType").asString(), record.get("count").asLong());
+            }
+        }
+        
+        return stats;
+    }
+    
+    /**
+     * 清理关系类型名称(Neo4j 关系类型有限制)
+     */
+    private String sanitizeRelationType(String type) {
+        if (type == null || type.isEmpty()) {
+            return "RELATED";
+        }
+        // 只保留字母、数字和下划线,转大写
+        return type.toUpperCase()
+                .replaceAll("[^A-Z0-9_]", "_")
+                .replaceAll("^[0-9]", "_$0"); // 不能以数字开头
+    }
+}

+ 315 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/DocumentBlockGeneratorService.java

@@ -0,0 +1,315 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.graph.service.NerToBlockService.TextElementDTO;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 文档块生成服务
+ * 
+ * 职责:
+ * 1. 将纯文本拆分为段落块
+ * 2. 识别标题、列表等结构
+ * 3. 结合 NER 结果生成完整的 Block 结构
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DocumentBlockGeneratorService {
+    
+    private final NerToBlockService nerToBlockService;
+    
+    // 标题模式:一、二、三... 或 1. 2. 3...
+    private static final Pattern HEADING_PATTERN_CN = Pattern.compile("^[一二三四五六七八九十]+、.+");
+    private static final Pattern HEADING_PATTERN_NUM = Pattern.compile("^(\\d+\\.)+\\s*.+");
+    private static final Pattern SUBHEADING_PATTERN = Pattern.compile("^(\\d+\\.\\d+)\\s+.+");
+    
+    // 列表模式
+    private static final Pattern BULLET_PATTERN = Pattern.compile("^[•●○▪\\-\\*]\\s+.+");
+    private static final Pattern ORDERED_PATTERN = Pattern.compile("^\\d+[.、))]\\s+.+");
+    
+    /**
+     * 将纯文本和 NER 实体生成完整的 Block 结构
+     * 
+     * @param documentId 文档ID
+     * @param text 纯文本内容
+     * @param entities NER 提取的实体列表
+     * @return Block 列表
+     */
+    public List<BlockDTO> generateBlocks(String documentId, String text, List<Map<String, Object>> entities) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+        
+        // 1. 将文本拆分为段落
+        List<ParagraphInfo> paragraphs = splitIntoParagraphs(text);
+        
+        // 2. 为每个段落生成 Block
+        List<BlockDTO> blocks = new ArrayList<>();
+        String rootBlockId = UUID.randomUUID().toString().replace("-", "");
+        
+        // 创建根块(page)
+        BlockDTO rootBlock = new BlockDTO();
+        rootBlock.setBlockId(rootBlockId);
+        rootBlock.setDocumentId(documentId);
+        rootBlock.setBlockType("page");
+        rootBlock.setBlockIndex(0);
+        rootBlock.setChildren(new ArrayList<>());
+        blocks.add(rootBlock);
+        
+        // 为每个段落创建 Block
+        int blockIndex = 1;
+        for (ParagraphInfo para : paragraphs) {
+            if (para.getText().trim().isEmpty()) {
+                continue; // 跳过空段落
+            }
+            
+            BlockDTO block = new BlockDTO();
+            block.setBlockId(UUID.randomUUID().toString().replace("-", ""));
+            block.setDocumentId(documentId);
+            block.setParentId(rootBlockId);
+            block.setBlockIndex(blockIndex++);
+            block.setBlockType(detectBlockType(para.getText()));
+            block.setCharStart(para.getCharStart());
+            block.setCharEnd(para.getCharEnd());
+            
+            // 筛选属于该段落的实体
+            List<Map<String, Object>> paragraphEntities = filterEntitiesForParagraph(
+                    entities, para.getCharStart(), para.getCharEnd());
+            
+            // 将实体偏移转换为段落内偏移
+            List<Map<String, Object>> localizedEntities = localizeEntities(
+                    paragraphEntities, para.getCharStart());
+            
+            // 生成 TextElement 列表
+            List<TextElementDTO> elements = nerToBlockService.convertToTextElements(
+                    para.getText(), localizedEntities);
+            block.setElements(elements);
+            
+            blocks.add(block);
+            rootBlock.getChildren().add(block.getBlockId());
+        }
+        
+        log.info("生成文档块完成: documentId={}, blockCount={}", documentId, blocks.size());
+        return blocks;
+    }
+    
+    /**
+     * 将文本拆分为段落
+     */
+    private List<ParagraphInfo> splitIntoParagraphs(String text) {
+        List<ParagraphInfo> paragraphs = new ArrayList<>();
+        
+        // 按换行符拆分
+        String[] lines = text.split("\n");
+        int currentPos = 0;
+        StringBuilder currentParagraph = new StringBuilder();
+        int paragraphStart = 0;
+        
+        for (String line : lines) {
+            if (line.trim().isEmpty()) {
+                // 空行,结束当前段落
+                if (currentParagraph.length() > 0) {
+                    ParagraphInfo para = new ParagraphInfo();
+                    para.setText(currentParagraph.toString());
+                    para.setCharStart(paragraphStart);
+                    para.setCharEnd(currentPos);
+                    paragraphs.add(para);
+                    currentParagraph = new StringBuilder();
+                }
+                paragraphStart = currentPos + line.length() + 1;
+            } else {
+                // 检查是否是新段落的开始(标题、列表等)
+                if (isNewParagraphStart(line) && currentParagraph.length() > 0) {
+                    // 保存当前段落
+                    ParagraphInfo para = new ParagraphInfo();
+                    para.setText(currentParagraph.toString());
+                    para.setCharStart(paragraphStart);
+                    para.setCharEnd(currentPos);
+                    paragraphs.add(para);
+                    
+                    currentParagraph = new StringBuilder();
+                    paragraphStart = currentPos;
+                }
+                
+                if (currentParagraph.length() > 0) {
+                    currentParagraph.append(" "); // 多行合并时用空格连接
+                }
+                currentParagraph.append(line.trim());
+            }
+            
+            currentPos += line.length() + 1; // +1 for newline
+        }
+        
+        // 处理最后一个段落
+        if (currentParagraph.length() > 0) {
+            ParagraphInfo para = new ParagraphInfo();
+            para.setText(currentParagraph.toString());
+            para.setCharStart(paragraphStart);
+            para.setCharEnd(text.length());
+            paragraphs.add(para);
+        }
+        
+        return paragraphs;
+    }
+    
+    /**
+     * 判断是否是新段落的开始
+     */
+    private boolean isNewParagraphStart(String line) {
+        String trimmed = line.trim();
+        return HEADING_PATTERN_CN.matcher(trimmed).matches()
+                || HEADING_PATTERN_NUM.matcher(trimmed).matches()
+                || SUBHEADING_PATTERN.matcher(trimmed).matches()
+                || BULLET_PATTERN.matcher(trimmed).matches()
+                || ORDERED_PATTERN.matcher(trimmed).matches();
+    }
+    
+    /**
+     * 检测块类型
+     */
+    private String detectBlockType(String text) {
+        String trimmed = text.trim();
+        
+        // 标题检测
+        if (HEADING_PATTERN_CN.matcher(trimmed).matches()) {
+            // 一、二、三... 格式的一级标题
+            return "heading1";
+        }
+        
+        Matcher numMatcher = HEADING_PATTERN_NUM.matcher(trimmed);
+        if (numMatcher.matches()) {
+            // 计算数字层级
+            String prefix = trimmed.split("\\s")[0];
+            int dotCount = (int) prefix.chars().filter(c -> c == '.').count();
+            if (dotCount <= 1) return "heading1";
+            if (dotCount == 2) return "heading2";
+            return "heading3";
+        }
+        
+        if (SUBHEADING_PATTERN.matcher(trimmed).matches()) {
+            return "heading2";
+        }
+        
+        // 列表检测
+        if (BULLET_PATTERN.matcher(trimmed).matches()) {
+            return "bullet";
+        }
+        
+        if (ORDERED_PATTERN.matcher(trimmed).matches()) {
+            return "ordered";
+        }
+        
+        // 默认为普通段落
+        return "text";
+    }
+    
+    /**
+     * 筛选属于指定段落范围的实体
+     */
+    private List<Map<String, Object>> filterEntitiesForParagraph(List<Map<String, Object>> entities,
+                                                                   int paragraphStart, int paragraphEnd) {
+        if (entities == null) {
+            return Collections.emptyList();
+        }
+        
+        List<Map<String, Object>> result = new ArrayList<>();
+        
+        for (Map<String, Object> entity : entities) {
+            int charStart = getEntityCharStart(entity);
+            int charEnd = getEntityCharEnd(entity);
+            
+            // 实体完全在段落范围内
+            if (charStart >= paragraphStart && charEnd <= paragraphEnd) {
+                result.add(entity);
+            }
+        }
+        
+        return result;
+    }
+    
+    /**
+     * 将实体的全局偏移转换为段落内偏移
+     */
+    private List<Map<String, Object>> localizeEntities(List<Map<String, Object>> entities, int offset) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        
+        for (Map<String, Object> entity : entities) {
+            Map<String, Object> localized = new HashMap<>(entity);
+            
+            Object positionObj = entity.get("position");
+            if (positionObj instanceof Map) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> posMap = new HashMap<>((Map<String, Object>) positionObj);
+                int charStart = getIntValue(posMap, "charStart", 0);
+                int charEnd = getIntValue(posMap, "charEnd", 0);
+                posMap.put("charStart", charStart - offset);
+                posMap.put("charEnd", charEnd - offset);
+                localized.put("position", posMap);
+            }
+            
+            result.add(localized);
+        }
+        
+        return result;
+    }
+    
+    private int getEntityCharStart(Map<String, Object> entity) {
+        Object positionObj = entity.get("position");
+        if (positionObj instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> posMap = (Map<String, Object>) positionObj;
+            return getIntValue(posMap, "charStart", -1);
+        }
+        return -1;
+    }
+    
+    private int getEntityCharEnd(Map<String, Object> entity) {
+        Object positionObj = entity.get("position");
+        if (positionObj instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> posMap = (Map<String, Object>) positionObj;
+            return getIntValue(posMap, "charEnd", -1);
+        }
+        return -1;
+    }
+    
+    private int getIntValue(Map<String, Object> map, String key, int defaultValue) {
+        Object value = map.get(key);
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        return defaultValue;
+    }
+    
+    // ==================== 数据传输对象 ====================
+    
+    @Data
+    public static class BlockDTO {
+        private String blockId;
+        private String documentId;
+        private String parentId;
+        private List<String> children;
+        private Integer blockIndex;
+        private String blockType;
+        private Integer charStart;
+        private Integer charEnd;
+        private List<TextElementDTO> elements;
+    }
+    
+    @Data
+    private static class ParagraphInfo {
+        private String text;
+        private int charStart;
+        private int charEnd;
+    }
+}

+ 11 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/GraphNerService.java

@@ -34,6 +34,7 @@ public class GraphNerService {
     private final TextStorageRepository textStorageRepository;
     private final GraphNodeRepository graphNodeRepository;
     private final GraphRelationRepository graphRelationRepository;
+    private final GraphSyncService graphSyncService;
 
     /**
      * 获取文档的文本内容
@@ -124,6 +125,9 @@ public class GraphNerService {
             
             graphNodeRepository.insert(node);
             
+            // 同步到 Neo4j
+            graphSyncService.syncNode(node);
+            
             // 记录 tempId 到 nodeId 的映射
             String tempId = getStringValue(entity, "tempId");
             if (tempId != null) {
@@ -189,6 +193,10 @@ public class GraphNerService {
             graphRelation.setMetadata(metadata);
             
             graphRelationRepository.insert(graphRelation);
+            
+            // 同步到 Neo4j
+            graphSyncService.syncRelation(graphRelation);
+            
             savedCount++;
         }
         
@@ -227,6 +235,9 @@ public class GraphNerService {
             }
         }
         
+        // 从 Neo4j 删除
+        graphSyncService.deleteDocumentFromNeo4j(documentId);
+        
         log.info("NER 结果删除完成: documentId={}, deletedCount={}", documentId, deletedCount);
         return deletedCount;
     }

+ 183 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/GraphSyncService.java

@@ -0,0 +1,183 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.graph.entity.GraphNode;
+import com.lingyue.graph.entity.GraphRelation;
+import com.lingyue.graph.neo4j.Neo4jGraphService;
+import com.lingyue.graph.repository.GraphNodeRepository;
+import com.lingyue.graph.repository.GraphRelationRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 图数据同步服务
+ * 
+ * 负责将 PostgreSQL 中的图数据同步到 Neo4j
+ * 
+ * 同步策略:
+ * 1. 实时同步:每次 CRUD 操作后立即同步
+ * 2. 批量同步:定时任务或手动触发全量同步
+ * 3. 按需同步:查询时如果 Neo4j 无数据则同步
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GraphSyncService {
+    
+    private final GraphNodeRepository nodeRepository;
+    private final GraphRelationRepository relationRepository;
+    
+    // Neo4j 服务可能不存在(未启用时)
+    @Autowired(required = false)
+    private Neo4jGraphService neo4jGraphService;
+    
+    /**
+     * 检查 Neo4j 是否可用
+     */
+    public boolean isNeo4jEnabled() {
+        return neo4jGraphService != null;
+    }
+    
+    /**
+     * 同步单个节点到 Neo4j
+     */
+    public void syncNode(GraphNode node) {
+        if (!isNeo4jEnabled()) {
+            return;
+        }
+        
+        try {
+            neo4jGraphService.syncNode(node);
+        } catch (Exception e) {
+            log.error("同步节点到 Neo4j 失败: nodeId={}, error={}", node.getId(), e.getMessage());
+        }
+    }
+    
+    /**
+     * 同步单个关系到 Neo4j
+     */
+    public void syncRelation(GraphRelation relation) {
+        if (!isNeo4jEnabled()) {
+            return;
+        }
+        
+        try {
+            neo4jGraphService.syncRelation(relation);
+        } catch (Exception e) {
+            log.error("同步关系到 Neo4j 失败: relId={}, error={}", relation.getId(), e.getMessage());
+        }
+    }
+    
+    /**
+     * 同步文档的所有数据到 Neo4j
+     */
+    @Async
+    public void syncDocument(String documentId) {
+        if (!isNeo4jEnabled()) {
+            log.debug("Neo4j 未启用,跳过同步");
+            return;
+        }
+        
+        log.info("开始同步文档到 Neo4j: documentId={}", documentId);
+        
+        try {
+            // 1. 先删除旧数据
+            neo4jGraphService.deleteDocumentGraph(documentId);
+            
+            // 2. 同步节点
+            List<GraphNode> nodes = nodeRepository.findByDocumentId(documentId);
+            if (!nodes.isEmpty()) {
+                neo4jGraphService.syncNodes(nodes);
+            }
+            
+            // 3. 同步关系
+            // 获取所有节点ID
+            for (GraphNode node : nodes) {
+                List<GraphRelation> relations = relationRepository.findByNodeId(node.getId());
+                if (!relations.isEmpty()) {
+                    neo4jGraphService.syncRelations(relations);
+                }
+            }
+            
+            log.info("文档同步到 Neo4j 完成: documentId={}, nodes={}", documentId, nodes.size());
+            
+        } catch (Exception e) {
+            log.error("同步文档到 Neo4j 失败: documentId={}, error={}", documentId, e.getMessage(), e);
+        }
+    }
+    
+    /**
+     * 同步用户的所有数据到 Neo4j
+     */
+    @Async
+    public void syncUser(String userId) {
+        if (!isNeo4jEnabled()) {
+            return;
+        }
+        
+        log.info("开始同步用户数据到 Neo4j: userId={}", userId);
+        
+        try {
+            // 获取用户的所有节点
+            List<GraphNode> nodes = nodeRepository.findByUserId(userId);
+            if (!nodes.isEmpty()) {
+                neo4jGraphService.syncNodes(nodes);
+            }
+            
+            // 同步关系
+            for (GraphNode node : nodes) {
+                List<GraphRelation> relations = relationRepository.findByNodeId(node.getId());
+                if (!relations.isEmpty()) {
+                    neo4jGraphService.syncRelations(relations);
+                }
+            }
+            
+            log.info("用户数据同步到 Neo4j 完成: userId={}, nodes={}", userId, nodes.size());
+            
+        } catch (Exception e) {
+            log.error("同步用户数据到 Neo4j 失败: userId={}, error={}", userId, e.getMessage(), e);
+        }
+    }
+    
+    /**
+     * 全量同步(谨慎使用)
+     */
+    public void fullSync() {
+        if (!isNeo4jEnabled()) {
+            log.warn("Neo4j 未启用,无法执行全量同步");
+            return;
+        }
+        
+        log.info("开始全量同步到 Neo4j...");
+        
+        // 初始化索引
+        neo4jGraphService.initializeIndexes();
+        
+        // TODO: 分批查询所有节点和关系进行同步
+        // 这里需要添加分页逻辑以避免内存溢出
+        
+        log.info("全量同步完成");
+    }
+    
+    /**
+     * 删除文档在 Neo4j 中的数据
+     */
+    public void deleteDocumentFromNeo4j(String documentId) {
+        if (!isNeo4jEnabled()) {
+            return;
+        }
+        
+        try {
+            neo4jGraphService.deleteDocumentGraph(documentId);
+        } catch (Exception e) {
+            log.error("从 Neo4j 删除文档失败: documentId={}, error={}", documentId, e.getMessage());
+        }
+    }
+}

+ 478 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/KnowledgeGraphService.java

@@ -0,0 +1,478 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.graph.dto.KnowledgeGraphDTO;
+import com.lingyue.graph.dto.KnowledgeGraphDTO.*;
+import com.lingyue.graph.entity.GraphNode;
+import com.lingyue.graph.entity.GraphRelation;
+import com.lingyue.graph.repository.GraphNodeRepository;
+import com.lingyue.graph.repository.GraphRelationRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 知识图谱服务
+ * 
+ * 提供知识图谱的查询和可视化数据转换功能
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KnowledgeGraphService {
+    
+    private final GraphNodeRepository nodeRepository;
+    private final GraphRelationRepository relationRepository;
+    
+    // 类型到显示名称的映射
+    private 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("other", "其他")
+    );
+    
+    // 类型到图标的映射
+    private 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("other", "📌")
+    );
+    
+    // 类型到颜色的映射
+    private 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("other", "#8c8c8c")
+    );
+    
+    /**
+     * 获取文档的知识图谱
+     */
+    public KnowledgeGraphDTO getDocumentGraph(String documentId) {
+        // 1. 获取文档的所有节点
+        List<GraphNode> nodes = nodeRepository.findByDocumentId(documentId);
+        if (nodes.isEmpty()) {
+            return null;
+        }
+        
+        // 2. 获取节点ID集合
+        Set<String> nodeIds = nodes.stream()
+                .map(GraphNode::getId)
+                .collect(Collectors.toSet());
+        
+        // 3. 获取节点间的关系
+        List<GraphRelation> relations = new ArrayList<>();
+        for (String nodeId : nodeIds) {
+            List<GraphRelation> nodeRelations = relationRepository.findByNodeId(nodeId);
+            for (GraphRelation rel : nodeRelations) {
+                // 只保留两端都在当前节点集中的关系
+                if (nodeIds.contains(rel.getFromNodeId()) && nodeIds.contains(rel.getToNodeId())) {
+                    relations.add(rel);
+                }
+            }
+        }
+        
+        // 4. 去重关系
+        relations = relations.stream()
+                .collect(Collectors.collectingAndThen(
+                        Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(GraphRelation::getId))),
+                        ArrayList::new));
+        
+        // 5. 计算每个节点的关联数量
+        Map<String, Integer> relationCounts = new HashMap<>();
+        for (GraphRelation rel : relations) {
+            relationCounts.merge(rel.getFromNodeId(), 1, Integer::sum);
+            relationCounts.merge(rel.getToNodeId(), 1, Integer::sum);
+        }
+        
+        // 6. 构建节点 DTO
+        Map<String, GraphNode> nodeMap = nodes.stream()
+                .collect(Collectors.toMap(GraphNode::getId, n -> n));
+        
+        List<NodeDTO> nodeDTOs = nodes.stream()
+                .map(node -> buildNodeDTO(node, relationCounts.getOrDefault(node.getId(), 0)))
+                .collect(Collectors.toList());
+        
+        // 7. 构建边 DTO
+        List<EdgeDTO> edgeDTOs = relations.stream()
+                .map(rel -> buildEdgeDTO(rel, nodeMap))
+                .collect(Collectors.toList());
+        
+        // 8. 统计信息
+        GraphStats stats = buildGraphStats(nodes, relations);
+        
+        return KnowledgeGraphDTO.builder()
+                .documentId(documentId)
+                .title("标记要素关系图谱")
+                .nodes(nodeDTOs)
+                .edges(edgeDTOs)
+                .stats(stats)
+                .build();
+    }
+    
+    /**
+     * 获取用户的全局知识图谱(跨文档)
+     */
+    public KnowledgeGraphDTO getUserGraph(String userId, int limit) {
+        // 获取用户的所有节点
+        List<GraphNode> allNodes = nodeRepository.findByUserId(userId);
+        
+        // 限制数量(取关联最多的)
+        if (allNodes.size() > limit) {
+            // TODO: 按重要性排序后截取
+            allNodes = allNodes.subList(0, limit);
+        }
+        
+        if (allNodes.isEmpty()) {
+            return KnowledgeGraphDTO.builder()
+                    .title("我的知识图谱")
+                    .nodes(Collections.emptyList())
+                    .edges(Collections.emptyList())
+                    .stats(GraphStats.builder()
+                            .totalNodes(0)
+                            .totalEdges(0)
+                            .nodesByType(Collections.emptyMap())
+                            .edgesByType(Collections.emptyMap())
+                            .build())
+                    .build();
+        }
+        
+        Set<String> nodeIds = allNodes.stream()
+                .map(GraphNode::getId)
+                .collect(Collectors.toSet());
+        
+        List<GraphRelation> relations = new ArrayList<>();
+        for (String nodeId : nodeIds) {
+            List<GraphRelation> nodeRelations = relationRepository.findByNodeId(nodeId);
+            for (GraphRelation rel : nodeRelations) {
+                if (nodeIds.contains(rel.getFromNodeId()) && nodeIds.contains(rel.getToNodeId())) {
+                    relations.add(rel);
+                }
+            }
+        }
+        
+        // 去重
+        relations = relations.stream()
+                .collect(Collectors.collectingAndThen(
+                        Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(GraphRelation::getId))),
+                        ArrayList::new));
+        
+        Map<String, Integer> relationCounts = new HashMap<>();
+        for (GraphRelation rel : relations) {
+            relationCounts.merge(rel.getFromNodeId(), 1, Integer::sum);
+            relationCounts.merge(rel.getToNodeId(), 1, Integer::sum);
+        }
+        
+        Map<String, GraphNode> nodeMap = allNodes.stream()
+                .collect(Collectors.toMap(GraphNode::getId, n -> n));
+        
+        List<NodeDTO> nodeDTOs = allNodes.stream()
+                .map(node -> buildNodeDTO(node, relationCounts.getOrDefault(node.getId(), 0)))
+                .collect(Collectors.toList());
+        
+        List<EdgeDTO> edgeDTOs = relations.stream()
+                .map(rel -> buildEdgeDTO(rel, nodeMap))
+                .collect(Collectors.toList());
+        
+        GraphStats stats = buildGraphStats(allNodes, relations);
+        
+        return KnowledgeGraphDTO.builder()
+                .title("我的知识图谱")
+                .nodes(nodeDTOs)
+                .edges(edgeDTOs)
+                .stats(stats)
+                .build();
+    }
+    
+    /**
+     * 获取实体列表(按类型分组)
+     */
+    public List<EntityGroupDTO> getEntityListGroupedByType(String documentId, String filterType) {
+        List<GraphNode> nodes = nodeRepository.findByDocumentId(documentId);
+        
+        if (filterType != null && !filterType.isEmpty() && !"all".equals(filterType)) {
+            nodes = nodes.stream()
+                    .filter(n -> filterType.equalsIgnoreCase(n.getType()))
+                    .collect(Collectors.toList());
+        }
+        
+        // 按类型分组
+        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());
+        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);
+                if (otherNode != null) {
+                    count++;
+                    if (related.size() < 3) { // 只取前3个预览
+                        related.add(RelatedEntityDTO.builder()
+                                .id(otherNode.getId())
+                                .name(otherNode.getName())
+                                .relationType(rel.getRelationType())
+                                .build());
+                    }
+                }
+            }
+            
+            relatedEntitiesMap.put(nodeId, related);
+            relationCountMap.put(nodeId, count);
+        }
+        
+        // 构建分组列表
+        List<EntityGroupDTO> groups = new ArrayList<>();
+        
+        for (Map.Entry<String, List<GraphNode>> entry : nodesByType.entrySet()) {
+            String type = entry.getKey();
+            List<GraphNode> typeNodes = entry.getValue();
+            
+            List<EntityListItemDTO> items = typeNodes.stream()
+                    .map(node -> EntityListItemDTO.builder()
+                            .id(node.getId())
+                            .name(node.getName())
+                            .type(type)
+                            .typeName(TYPE_NAMES.getOrDefault(type, type))
+                            .icon(TYPE_ICONS.getOrDefault(type, "📌"))
+                            .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                            .occurrenceCount(1) // TODO: 计算实际出现次数
+                            .relationCount(relationCountMap.getOrDefault(node.getId(), 0))
+                            .relatedEntities(relatedEntitiesMap.getOrDefault(node.getId(), Collections.emptyList()))
+                            .build())
+                    .collect(Collectors.toList());
+            
+            groups.add(EntityGroupDTO.builder()
+                    .type(type)
+                    .typeName(TYPE_NAMES.getOrDefault(type, type))
+                    .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                    .count(items.size())
+                    .entities(items)
+                    .build());
+        }
+        
+        // 按预定义顺序排序
+        List<String> typeOrder = List.of("entity", "concept", "data", "number", "money", "person", "org", "loc", "location", "date", "device", "term", "other");
+        groups.sort((a, b) -> {
+            int indexA = typeOrder.indexOf(a.getType());
+            int indexB = typeOrder.indexOf(b.getType());
+            if (indexA < 0) indexA = 999;
+            if (indexB < 0) indexB = 999;
+            return indexA - indexB;
+        });
+        
+        return groups;
+    }
+    
+    /**
+     * 获取实体详情
+     */
+    public EntityDetailDTO getEntityDetail(String entityId) {
+        GraphNode node = nodeRepository.selectById(entityId);
+        if (node == null) {
+            return null;
+        }
+        
+        String type = node.getType() != null ? node.getType().toLowerCase() : "other";
+        
+        // 获取所有关联实体
+        List<GraphRelation> relations = relationRepository.findByNodeId(entityId);
+        List<RelatedEntityDTO> allRelations = new ArrayList<>();
+        
+        for (GraphRelation rel : relations) {
+            String otherId = rel.getFromNodeId().equals(entityId) ? rel.getToNodeId() : rel.getFromNodeId();
+            GraphNode otherNode = nodeRepository.selectById(otherId);
+            if (otherNode != null) {
+                allRelations.add(RelatedEntityDTO.builder()
+                        .id(otherNode.getId())
+                        .name(otherNode.getName())
+                        .relationType(rel.getRelationType())
+                        .build());
+            }
+        }
+        
+        // 获取出现位置
+        List<OccurrenceDTO> occurrences = new ArrayList<>();
+        if (node.getPosition() instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> pos = (Map<String, Object>) node.getPosition();
+            occurrences.add(OccurrenceDTO.builder()
+                    .documentId(node.getDocumentId())
+                    .position(pos)
+                    .build());
+        }
+        
+        // 提取上下文
+        if (node.getMetadata() instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> meta = (Map<String, Object>) node.getMetadata();
+            String context = (String) meta.get("context");
+            if (context != null && !occurrences.isEmpty()) {
+                occurrences.get(0).setContext(context);
+            }
+        }
+        
+        return EntityDetailDTO.builder()
+                .id(node.getId())
+                .name(node.getName())
+                .type(type)
+                .typeName(TYPE_NAMES.getOrDefault(type, type))
+                .value(node.getValue())
+                .icon(TYPE_ICONS.getOrDefault(type, "📌"))
+                .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                .occurrenceCount(1)
+                .allRelations(allRelations)
+                .occurrences(occurrences)
+                .metadata(node.getMetadata() instanceof Map ? (Map<String, Object>) node.getMetadata() : null)
+                .build();
+    }
+    
+    /**
+     * 搜索实体
+     */
+    public List<EntityListItemDTO> searchEntities(String keyword, String documentId, int limit) {
+        List<GraphNode> nodes;
+        
+        if (documentId != null && !documentId.isEmpty()) {
+            nodes = nodeRepository.findByDocumentId(documentId);
+        } else {
+            // 全局搜索需要另外的查询方法
+            // TODO: 添加全局搜索的 Repository 方法
+            nodes = new ArrayList<>();
+        }
+        
+        // 过滤匹配的节点
+        String lowerKeyword = keyword.toLowerCase();
+        List<GraphNode> matchedNodes = nodes.stream()
+                .filter(n -> n.getName() != null && n.getName().toLowerCase().contains(lowerKeyword))
+                .limit(limit)
+                .collect(Collectors.toList());
+        
+        return matchedNodes.stream()
+                .map(node -> {
+                    String type = node.getType() != null ? node.getType().toLowerCase() : "other";
+                    return EntityListItemDTO.builder()
+                            .id(node.getId())
+                            .name(node.getName())
+                            .type(type)
+                            .typeName(TYPE_NAMES.getOrDefault(type, type))
+                            .icon(TYPE_ICONS.getOrDefault(type, "📌"))
+                            .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                            .build();
+                })
+                .collect(Collectors.toList());
+    }
+    
+    // ==================== 辅助方法 ====================
+    
+    private NodeDTO buildNodeDTO(GraphNode node, int relationCount) {
+        String type = node.getType() != null ? node.getType().toLowerCase() : "other";
+        
+        // 根据关联数量计算节点大小
+        int size = 40 + Math.min(relationCount * 5, 30);
+        
+        return NodeDTO.builder()
+                .id(node.getId())
+                .name(node.getName())
+                .type(type)
+                .icon(TYPE_ICONS.getOrDefault(type, "📌"))
+                .color(TYPE_COLORS.getOrDefault(type, "#8c8c8c"))
+                .size(size)
+                .relationCount(relationCount)
+                .occurrenceCount(1)
+                .value(node.getValue())
+                .documentIds(node.getDocumentId() != null ? List.of(node.getDocumentId()) : Collections.emptyList())
+                .position(node.getPosition() instanceof Map ? (Map<String, Object>) node.getPosition() : null)
+                .metadata(node.getMetadata() instanceof Map ? (Map<String, Object>) node.getMetadata() : null)
+                .build();
+    }
+    
+    private EdgeDTO buildEdgeDTO(GraphRelation relation, Map<String, GraphNode> nodeMap) {
+        GraphNode fromNode = nodeMap.get(relation.getFromNodeId());
+        GraphNode toNode = nodeMap.get(relation.getToNodeId());
+        
+        String label = relation.getRelationType();
+        if (relation.getMetadata() instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> meta = (Map<String, Object>) relation.getMetadata();
+            String originalType = (String) meta.get("originalType");
+            if (originalType != null) {
+                label = originalType;
+            }
+        }
+        
+        return EdgeDTO.builder()
+                .id(relation.getId())
+                .source(relation.getFromNodeId())
+                .sourceName(fromNode != null ? fromNode.getName() : null)
+                .target(relation.getToNodeId())
+                .targetName(toNode != null ? toNode.getName() : null)
+                .relationType(relation.getRelationType())
+                .label(label)
+                .weight(1.0)
+                .metadata(relation.getMetadata() instanceof Map ? (Map<String, Object>) relation.getMetadata() : null)
+                .build();
+    }
+    
+    private GraphStats buildGraphStats(List<GraphNode> nodes, List<GraphRelation> relations) {
+        Map<String, Integer> nodesByType = nodes.stream()
+                .collect(Collectors.groupingBy(
+                        n -> n.getType() != null ? n.getType().toLowerCase() : "other",
+                        Collectors.collectingAndThen(Collectors.counting(), Long::intValue)));
+        
+        Map<String, Integer> edgesByType = relations.stream()
+                .collect(Collectors.groupingBy(
+                        r -> r.getRelationType() != null ? r.getRelationType() : "unknown",
+                        Collectors.collectingAndThen(Collectors.counting(), Long::intValue)));
+        
+        return GraphStats.builder()
+                .totalNodes(nodes.size())
+                .totalEdges(relations.size())
+                .nodesByType(nodesByType)
+                .edgesByType(edgesByType)
+                .build();
+    }
+}

+ 392 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/NerToBlockService.java

@@ -0,0 +1,392 @@
+package com.lingyue.graph.service;
+
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * NER 结果转换为 Block TextElement 的服务
+ * 
+ * 核心职责:
+ * 1. 将 NER 提取的实体(带字符偏移)转换为 TextElement 结构
+ * 2. 将纯文本拆分为 text_run 和 entity 元素的混合列表
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NerToBlockService {
+    
+    /**
+     * 将纯文本和 NER 实体转换为 TextElement 列表
+     * 
+     * @param text 纯文本内容
+     * @param entities NER 提取的实体列表,每个实体需包含 name, type, charStart, charEnd
+     * @return TextElement 列表
+     */
+    public List<TextElementDTO> convertToTextElements(String text, List<Map<String, Object>> entities) {
+        if (text == null || text.isEmpty()) {
+            return Collections.emptyList();
+        }
+        
+        if (entities == null || entities.isEmpty()) {
+            // 无实体,整个文本作为一个 text_run
+            TextElementDTO element = new TextElementDTO();
+            element.setType("text_run");
+            element.setContent(text);
+            return Collections.singletonList(element);
+        }
+        
+        // 1. 提取并排序实体位置
+        List<EntityPosition> positions = extractEntityPositions(text, entities);
+        
+        // 2. 根据实体位置拆分文本
+        return splitTextByEntities(text, positions);
+    }
+    
+    /**
+     * 将文档级别的 NER 结果分配到各个 Block
+     * 
+     * @param blocks 文档的所有块(需包含 charStart, charEnd, text)
+     * @param entities 文档级别的 NER 实体(globalCharStart, globalCharEnd)
+     * @return 每个 blockId 对应的 TextElement 列表
+     */
+    public Map<String, List<TextElementDTO>> distributeEntitiesToBlocks(
+            List<BlockInfo> blocks, List<Map<String, Object>> entities) {
+        
+        Map<String, List<TextElementDTO>> result = new LinkedHashMap<>();
+        
+        if (blocks == null || blocks.isEmpty()) {
+            return result;
+        }
+        
+        // 按 charStart 排序块
+        List<BlockInfo> sortedBlocks = blocks.stream()
+                .sorted(Comparator.comparingInt(BlockInfo::getCharStart))
+                .collect(Collectors.toList());
+        
+        // 为每个块分配实体
+        for (BlockInfo block : sortedBlocks) {
+            // 找出落在该块范围内的实体
+            List<Map<String, Object>> blockEntities = filterEntitiesForBlock(
+                    entities, block.getCharStart(), block.getCharEnd());
+            
+            // 将全局偏移转换为块内偏移
+            List<Map<String, Object>> localizedEntities = localizeEntityPositions(
+                    blockEntities, block.getCharStart());
+            
+            // 转换为 TextElement
+            List<TextElementDTO> elements = convertToTextElements(block.getText(), localizedEntities);
+            
+            result.put(block.getBlockId(), elements);
+        }
+        
+        return result;
+    }
+    
+    /**
+     * 将单个段落文本和实体转换为 TextElement(简化版,用于单块处理)
+     */
+    public List<TextElementDTO> convertParagraphToElements(String paragraphText, 
+                                                            List<Map<String, Object>> entities) {
+        return convertToTextElements(paragraphText, entities);
+    }
+    
+    // ==================== 内部方法 ====================
+    
+    /**
+     * 从实体列表中提取位置信息
+     */
+    private List<EntityPosition> extractEntityPositions(String text, List<Map<String, Object>> entities) {
+        List<EntityPosition> positions = new ArrayList<>();
+        
+        for (Map<String, Object> entity : entities) {
+            EntityPosition pos = new EntityPosition();
+            
+            // 获取实体基本信息
+            pos.setName(getStringValue(entity, "name"));
+            pos.setType(getStringValue(entity, "type", "ENTITY"));
+            pos.setEntityId(getStringValue(entity, "tempId", UUID.randomUUID().toString().replace("-", "")));
+            
+            // 获取位置信息
+            Object positionObj = entity.get("position");
+            if (positionObj instanceof Map) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> posMap = (Map<String, Object>) positionObj;
+                pos.setCharStart(getIntValue(posMap, "charStart", -1));
+                pos.setCharEnd(getIntValue(posMap, "charEnd", -1));
+            } else {
+                // 直接从实体中获取
+                pos.setCharStart(getIntValue(entity, "charStart", -1));
+                pos.setCharEnd(getIntValue(entity, "charEnd", -1));
+            }
+            
+            // 验证位置有效性
+            if (pos.getCharStart() >= 0 && pos.getCharEnd() > pos.getCharStart() 
+                    && pos.getCharEnd() <= text.length()) {
+                
+                // 验证实体名称与文本位置是否匹配
+                String textAtPosition = text.substring(pos.getCharStart(), pos.getCharEnd());
+                if (pos.getName() != null && pos.getName().equals(textAtPosition)) {
+                    positions.add(pos);
+                } else {
+                    // 位置不匹配,尝试通过名称查找
+                    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);
+                    } else {
+                        log.warn("实体位置无效且无法查找: name={}, charStart={}, charEnd={}", 
+                                pos.getName(), pos.getCharStart(), pos.getCharEnd());
+                    }
+                }
+            } else if (pos.getName() != null) {
+                // 没有有效位置,尝试通过名称查找
+                int foundIndex = text.indexOf(pos.getName());
+                if (foundIndex >= 0) {
+                    pos.setCharStart(foundIndex);
+                    pos.setCharEnd(foundIndex + pos.getName().length());
+                    positions.add(pos);
+                }
+            }
+        }
+        
+        // 按位置排序
+        positions.sort(Comparator.comparingInt(EntityPosition::getCharStart));
+        
+        // 去重和处理重叠
+        return removeOverlappingEntities(positions);
+    }
+    
+    /**
+     * 移除重叠的实体(保留较长的)
+     */
+    private List<EntityPosition> removeOverlappingEntities(List<EntityPosition> positions) {
+        if (positions.size() <= 1) {
+            return positions;
+        }
+        
+        List<EntityPosition> result = new ArrayList<>();
+        EntityPosition prev = null;
+        
+        for (EntityPosition curr : positions) {
+            if (prev == null) {
+                prev = curr;
+            } else if (curr.getCharStart() >= prev.getCharEnd()) {
+                // 不重叠
+                result.add(prev);
+                prev = curr;
+            } else {
+                // 重叠,保留较长的
+                if (curr.getCharEnd() - curr.getCharStart() > prev.getCharEnd() - prev.getCharStart()) {
+                    prev = curr;
+                }
+                // 否则保持 prev
+            }
+        }
+        
+        if (prev != null) {
+            result.add(prev);
+        }
+        
+        return result;
+    }
+    
+    /**
+     * 根据实体位置拆分文本为 TextElement 列表
+     */
+    private List<TextElementDTO> splitTextByEntities(String text, List<EntityPosition> positions) {
+        List<TextElementDTO> elements = new ArrayList<>();
+        int currentPos = 0;
+        
+        for (EntityPosition entityPos : positions) {
+            // 实体前的普通文本
+            if (entityPos.getCharStart() > currentPos) {
+                String beforeText = text.substring(currentPos, entityPos.getCharStart());
+                if (!beforeText.isEmpty()) {
+                    TextElementDTO textEl = new TextElementDTO();
+                    textEl.setType("text_run");
+                    textEl.setContent(beforeText);
+                    elements.add(textEl);
+                }
+            }
+            
+            // 实体元素
+            TextElementDTO entityEl = new TextElementDTO();
+            entityEl.setType("entity");
+            entityEl.setEntityId(entityPos.getEntityId());
+            entityEl.setEntityText(text.substring(entityPos.getCharStart(), entityPos.getCharEnd()));
+            entityEl.setEntityType(entityPos.getType().toUpperCase());
+            entityEl.setConfirmed(false); // NER 自动识别的默认未确认
+            elements.add(entityEl);
+            
+            currentPos = entityPos.getCharEnd();
+        }
+        
+        // 最后剩余的普通文本
+        if (currentPos < text.length()) {
+            String afterText = text.substring(currentPos);
+            if (!afterText.isEmpty()) {
+                TextElementDTO textEl = new TextElementDTO();
+                textEl.setType("text_run");
+                textEl.setContent(afterText);
+                elements.add(textEl);
+            }
+        }
+        
+        return elements;
+    }
+    
+    /**
+     * 筛选落在指定块范围内的实体
+     */
+    private List<Map<String, Object>> filterEntitiesForBlock(List<Map<String, Object>> entities,
+                                                               int blockStart, int blockEnd) {
+        if (entities == null) {
+            return Collections.emptyList();
+        }
+        
+        return entities.stream()
+                .filter(entity -> {
+                    int charStart = getEntityCharStart(entity);
+                    int charEnd = getEntityCharEnd(entity);
+                    // 实体完全在块范围内
+                    return charStart >= blockStart && charEnd <= blockEnd;
+                })
+                .collect(Collectors.toList());
+    }
+    
+    /**
+     * 将实体的全局偏移转换为块内偏移
+     */
+    private List<Map<String, Object>> localizeEntityPositions(List<Map<String, Object>> entities, 
+                                                                int blockStart) {
+        return entities.stream()
+                .map(entity -> {
+                    Map<String, Object> localized = new HashMap<>(entity);
+                    
+                    // 调整位置
+                    Object positionObj = entity.get("position");
+                    if (positionObj instanceof Map) {
+                        @SuppressWarnings("unchecked")
+                        Map<String, Object> posMap = new HashMap<>((Map<String, Object>) positionObj);
+                        int charStart = getIntValue(posMap, "charStart", 0);
+                        int charEnd = getIntValue(posMap, "charEnd", 0);
+                        posMap.put("charStart", charStart - blockStart);
+                        posMap.put("charEnd", charEnd - blockStart);
+                        localized.put("position", posMap);
+                    } else {
+                        int charStart = getIntValue(entity, "charStart", 0);
+                        int charEnd = getIntValue(entity, "charEnd", 0);
+                        localized.put("charStart", charStart - blockStart);
+                        localized.put("charEnd", charEnd - blockStart);
+                    }
+                    
+                    return localized;
+                })
+                .collect(Collectors.toList());
+    }
+    
+    private int getEntityCharStart(Map<String, Object> entity) {
+        Object positionObj = entity.get("position");
+        if (positionObj instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> posMap = (Map<String, Object>) positionObj;
+            return getIntValue(posMap, "charStart", -1);
+        }
+        return getIntValue(entity, "charStart", -1);
+    }
+    
+    private int getEntityCharEnd(Map<String, Object> entity) {
+        Object positionObj = entity.get("position");
+        if (positionObj instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> posMap = (Map<String, Object>) positionObj;
+            return getIntValue(posMap, "charEnd", -1);
+        }
+        return getIntValue(entity, "charEnd", -1);
+    }
+    
+    // ==================== 工具方法 ====================
+    
+    private String getStringValue(Map<String, Object> map, String key) {
+        return getStringValue(map, key, null);
+    }
+    
+    private String getStringValue(Map<String, Object> map, String key, String defaultValue) {
+        Object value = map.get(key);
+        return value != null ? value.toString() : defaultValue;
+    }
+    
+    private int getIntValue(Map<String, Object> map, String key, int defaultValue) {
+        Object value = map.get(key);
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        if (value instanceof String) {
+            try {
+                return Integer.parseInt((String) value);
+            } catch (NumberFormatException e) {
+                return defaultValue;
+            }
+        }
+        return defaultValue;
+    }
+    
+    // ==================== 数据传输对象 ====================
+    
+    /**
+     * TextElement DTO(与 DocumentBlock.TextElement 对应)
+     */
+    @Data
+    public static class TextElementDTO {
+        private String type;  // text_run / entity / link / mention_doc
+        
+        // text_run
+        private String content;
+        
+        // entity
+        private String entityId;
+        private String entityText;
+        private String entityType;
+        private Boolean confirmed;
+        
+        // link
+        private String url;
+        
+        // mention_doc
+        private String refDocId;
+        private String refDocTitle;
+    }
+    
+    /**
+     * 块信息(用于分配实体)
+     */
+    @Data
+    public static class BlockInfo {
+        private String blockId;
+        private int charStart;
+        private int charEnd;
+        private String text;
+    }
+    
+    /**
+     * 实体位置(内部使用)
+     */
+    @Data
+    private static class EntityPosition {
+        private String entityId;
+        private String name;
+        private String type;
+        private int charStart;
+        private int charEnd;
+    }
+}

+ 19 - 1
backend/lingyue-starter/src/main/resources/application.properties

@@ -203,4 +203,22 @@ spring.data.redis.password=geek
 spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
 spring.rabbitmq.port=${RABBITMQ_PORT:5672}
 spring.rabbitmq.username=${RABBITMQ_USERNAME:admin}
-spring.rabbitmq.password=${RABBITMQ_PASSWORD:admin123}
+spring.rabbitmq.password=${RABBITMQ_PASSWORD:admin123}
+
+# ============================================
+# Neo4j 图数据库配置
+# ============================================
+# 服务器已安装 Neo4j 2025.03.0
+neo4j.enabled=${NEO4J_ENABLED:true}
+# Neo4j 连接地址(Bolt 协议)
+neo4j.uri=${NEO4J_URI:bolt://localhost:7687}
+# Neo4j 用户名
+neo4j.username=${NEO4J_USERNAME:neo4j}
+# Neo4j 密码
+neo4j.password=${NEO4J_PASSWORD:geekgeek}
+# Neo4j 数据库名称(4.0+ 支持多数据库)
+neo4j.database=${NEO4J_DATABASE:neo4j}
+# 连接超时时间(秒)
+neo4j.connection-timeout-seconds=30
+# 最大连接池大小
+neo4j.max-connection-pool-size=50

+ 8 - 0
backend/pom.xml

@@ -54,6 +54,7 @@
         <webjars-locator.version>0.52</webjars-locator.version>
         <pdfbox.version>3.0.1</pdfbox.version>
         <poi.version>5.2.5</poi.version>
+        <neo4j-driver.version>5.15.0</neo4j-driver.version>
     </properties>
 
     <dependencyManagement>
@@ -181,6 +182,13 @@
                 <version>${webjars-locator.version}</version>
             </dependency>
             
+            <!-- Neo4j Driver -->
+            <dependency>
+                <groupId>org.neo4j.driver</groupId>
+                <artifactId>neo4j-java-driver</artifactId>
+                <version>${neo4j-driver.version}</version>
+            </dependency>
+            
             <!-- Common Module -->
             <dependency>
                 <groupId>com.lingyue</groupId>

+ 61 - 0
database/migrations/V2026_01_21__add_document_blocks_and_entities.sql

@@ -0,0 +1,61 @@
+-- 文档块表:存储文档的结构化内容(参考飞书Block设计)
+-- 核心设计:块内容由 elements 数组(TextElement)组成,实体作为元素嵌入
+CREATE TABLE IF NOT EXISTS document_blocks (
+    id VARCHAR(64) PRIMARY KEY,
+    document_id VARCHAR(64) NOT NULL,
+    parent_id VARCHAR(64),
+    children JSONB,
+    block_index INTEGER NOT NULL,
+    block_type VARCHAR(32) NOT NULL,
+    elements JSONB,
+    style JSONB,
+    metadata JSONB,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_document_blocks_document_id ON document_blocks(document_id);
+CREATE INDEX IF NOT EXISTS idx_document_blocks_parent_id ON document_blocks(parent_id);
+CREATE INDEX IF NOT EXISTS idx_document_blocks_block_type ON document_blocks(block_type);
+-- 全文搜索索引(对elements中的文本内容)
+CREATE INDEX IF NOT EXISTS idx_document_blocks_elements_gin ON document_blocks USING GIN (elements jsonb_path_ops);
+
+-- 文档实体标注表:存储文档中的实体/要素标记
+CREATE TABLE IF NOT EXISTS document_entities (
+    id VARCHAR(64) PRIMARY KEY,
+    document_id VARCHAR(64) NOT NULL,
+    block_id VARCHAR(64),
+    name VARCHAR(512) NOT NULL,
+    entity_type VARCHAR(32) NOT NULL,
+    value TEXT,
+    block_char_start INTEGER,
+    block_char_end INTEGER,
+    global_char_start INTEGER,
+    global_char_end INTEGER,
+    anchor_before VARCHAR(100),
+    anchor_after VARCHAR(100),
+    source VARCHAR(16) DEFAULT 'auto',
+    confidence DECIMAL(5,4),
+    confirmed BOOLEAN DEFAULT FALSE,
+    graph_node_id VARCHAR(64),
+    metadata JSONB,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_document_entities_document_id ON document_entities(document_id);
+CREATE INDEX IF NOT EXISTS idx_document_entities_block_id ON document_entities(block_id);
+CREATE INDEX IF NOT EXISTS idx_document_entities_type ON document_entities(entity_type);
+CREATE INDEX IF NOT EXISTS idx_document_entities_name ON document_entities(name);
+CREATE INDEX IF NOT EXISTS idx_document_entities_global_char ON document_entities(document_id, global_char_start, global_char_end);
+CREATE INDEX IF NOT EXISTS idx_document_entities_graph_node ON document_entities(graph_node_id);
+
+-- 注释
+COMMENT ON TABLE document_blocks IS '文档块 - 存储文档的结构化内容块';
+COMMENT ON TABLE document_entities IS '文档实体 - 存储文档中标记的实体/要素';
+
+COMMENT ON COLUMN document_blocks.block_type IS '块类型: heading1/heading2/heading3/paragraph/table/list/image/quote';
+COMMENT ON COLUMN document_entities.entity_type IS '实体类型: PERSON/ORG/LOC/DATE/NUMBER/MONEY/CONCEPT/DATA/DEVICE/TERM';
+COMMENT ON COLUMN document_entities.source IS '来源: auto=自动识别, manual=手动标注';

+ 362 - 0
docs/neo4j-local-install.md

@@ -0,0 +1,362 @@
+# Neo4j 本地部署指南
+
+## 1. 系统要求
+
+### 硬件要求
+- **CPU**: Intel/AMD x86-64 或 ARM
+- **内存**: 最低 2GB,推荐 16GB+
+- **存储**: 最低 10GB,推荐 SSD
+
+### 软件要求
+- **操作系统**: Ubuntu 22.04/24.04, Debian 11-13, RHEL 8.x/9.x, CentOS 等
+- **Java**: Java 21 或更高版本
+
+---
+
+## 2. 安装方法
+
+### 方法 A: Debian/Ubuntu 系统(推荐)
+
+#### 步骤 1: 安装 Java 21
+
+```bash
+# 检查是否已安装 Java
+java -version
+
+# 如果没有,安装 OpenJDK 21
+sudo apt update
+sudo apt install openjdk-21-jdk -y
+
+# 验证安装
+java -version
+```
+
+#### 步骤 2: 添加 Neo4j 仓库
+
+```bash
+# 创建密钥目录
+sudo mkdir -p /etc/apt/keyrings
+
+# 添加 GPG 密钥
+wget -O - https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/neotechnology.gpg
+sudo chmod a+r /etc/apt/keyrings/neotechnology.gpg
+
+# 添加仓库
+echo 'deb [signed-by=/etc/apt/keyrings/neotechnology.gpg] https://debian.neo4j.com stable latest' | sudo tee /etc/apt/sources.list.d/neo4j.list
+
+# 更新包列表
+sudo apt update
+```
+
+#### 步骤 3: 安装 Neo4j
+
+```bash
+# 安装 Neo4j Community Edition(免费)
+sudo apt install neo4j -y
+
+# 或者指定版本
+# sudo apt install neo4j=1:5.15.0 -y
+```
+
+#### 步骤 4: 配置并启动服务
+
+```bash
+# 设置初始密码(替换 your-password)
+sudo neo4j-admin dbms set-initial-password your-password
+
+# 启用开机自启
+sudo systemctl enable neo4j
+
+# 启动服务
+sudo systemctl start neo4j
+
+# 查看状态
+sudo systemctl status neo4j
+```
+
+---
+
+### 方法 B: Tarball 安装(更灵活)
+
+#### 步骤 1: 下载并解压
+
+```bash
+# 下载 Neo4j Community Edition
+wget https://neo4j.com/artifact.php?name=neo4j-community-5.15.0-unix.tar.gz -O neo4j-community-5.15.0-unix.tar.gz
+
+# 解压
+tar -xzf neo4j-community-5.15.0-unix.tar.gz
+
+# 移动到 /opt
+sudo mv neo4j-community-5.15.0 /opt/neo4j
+```
+
+#### 步骤 2: 创建用户和设置权限
+
+```bash
+# 创建 neo4j 用户
+sudo useradd -r -s /bin/false neo4j
+
+# 设置目录权限
+sudo chown -R neo4j:neo4j /opt/neo4j
+```
+
+#### 步骤 3: 配置环境变量
+
+```bash
+# 添加到 ~/.bashrc 或 /etc/profile
+export NEO4J_HOME=/opt/neo4j
+export PATH=$NEO4J_HOME/bin:$PATH
+```
+
+#### 步骤 4: 创建 systemd 服务
+
+```bash
+sudo tee /etc/systemd/system/neo4j.service > /dev/null <<EOF
+[Unit]
+Description=Neo4j Graph Database
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+ExecStart=/opt/neo4j/bin/neo4j console
+Restart=on-abnormal
+User=neo4j
+Group=neo4j
+Environment="NEO4J_CONF=/opt/neo4j/conf"
+Environment="NEO4J_HOME=/opt/neo4j"
+LimitNOFILE=60000
+TimeoutSec=120
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+# 重新加载 systemd
+sudo systemctl daemon-reload
+
+# 设置初始密码
+sudo -u neo4j /opt/neo4j/bin/neo4j-admin dbms set-initial-password your-password
+
+# 启用并启动
+sudo systemctl enable neo4j
+sudo systemctl start neo4j
+```
+
+---
+
+## 3. 配置 Neo4j
+
+配置文件位置: `/etc/neo4j/neo4j.conf` 或 `/opt/neo4j/conf/neo4j.conf`
+
+### 3.1 允许远程连接
+
+```bash
+sudo nano /etc/neo4j/neo4j.conf
+```
+
+找到并取消注释以下行:
+
+```properties
+# 监听所有接口(生产环境请谨慎)
+server.default_listen_address=0.0.0.0
+
+# HTTP 端口(浏览器访问)
+server.http.listen_address=:7474
+
+# Bolt 端口(应用连接)
+server.bolt.listen_address=:7687
+```
+
+### 3.2 内存配置(可选)
+
+```properties
+# 堆内存设置
+server.memory.heap.initial_size=512m
+server.memory.heap.max_size=2g
+
+# 页面缓存(建议设置为可用内存的 50%)
+server.memory.pagecache.size=1g
+```
+
+### 3.3 重启服务
+
+```bash
+sudo systemctl restart neo4j
+```
+
+---
+
+## 4. 验证安装
+
+### 4.1 浏览器访问
+
+打开浏览器访问: http://localhost:7474
+
+- 用户名: `neo4j`
+- 密码: 你设置的密码
+
+### 4.2 命令行测试
+
+```bash
+# 使用 cypher-shell 连接
+cypher-shell -u neo4j -p your-password
+
+# 运行测试查询
+RETURN "Hello Neo4j!" AS message;
+
+# 退出
+:exit
+```
+
+### 4.3 查看日志
+
+```bash
+# 查看日志
+sudo journalctl -u neo4j -f
+
+# 或者
+tail -f /var/log/neo4j/neo4j.log
+```
+
+---
+
+## 5. 灵越智报项目配置
+
+### 5.1 修改环境变量
+
+创建或编辑 `.env` 文件:
+
+```bash
+# Neo4j 配置
+NEO4J_ENABLED=true
+NEO4J_URI=bolt://localhost:7687
+NEO4J_USERNAME=neo4j
+NEO4J_PASSWORD=your-password
+NEO4J_DATABASE=neo4j
+```
+
+### 5.2 或直接修改 application.properties
+
+```properties
+# Neo4j 图数据库配置
+neo4j.enabled=true
+neo4j.uri=bolt://localhost:7687
+neo4j.username=neo4j
+neo4j.password=your-password
+neo4j.database=neo4j
+neo4j.connection-timeout-seconds=30
+neo4j.max-connection-pool-size=50
+```
+
+### 5.3 启动项目后初始化索引
+
+```bash
+# 调用 API 初始化 Neo4j 索引
+curl -X POST http://localhost:5232/api/v1/neo4j/init-indexes
+```
+
+---
+
+## 6. 常用命令
+
+```bash
+# 启动 Neo4j
+sudo systemctl start neo4j
+
+# 停止 Neo4j
+sudo systemctl stop neo4j
+
+# 重启 Neo4j
+sudo systemctl restart neo4j
+
+# 查看状态
+sudo systemctl status neo4j
+
+# 查看日志
+sudo journalctl -u neo4j -f
+
+# 进入 Cypher Shell
+cypher-shell -u neo4j -p your-password
+```
+
+---
+
+## 7. 常用 Cypher 查询
+
+```cypher
+-- 查看所有节点数量
+MATCH (n) RETURN count(n);
+
+-- 查看所有关系数量
+MATCH ()-[r]->() RETURN count(r);
+
+-- 查看节点标签统计
+MATCH (n) RETURN labels(n), count(*) ORDER BY count(*) DESC;
+
+-- 查看关系类型统计
+MATCH ()-[r]->() RETURN type(r), count(*) ORDER BY count(*) DESC;
+
+-- 删除所有数据(谨慎使用!)
+MATCH (n) DETACH DELETE n;
+
+-- 查看某文档的所有节点
+MATCH (n {documentId: 'your-doc-id'}) RETURN n;
+
+-- 查看某节点的所有关系
+MATCH (n {id: 'your-node-id'})-[r]-(m) RETURN n, r, m;
+```
+
+---
+
+## 8. 故障排查
+
+### 问题 1: 服务启动失败
+
+```bash
+# 查看详细错误
+sudo journalctl -u neo4j --no-pager -n 50
+
+# 检查端口占用
+sudo netstat -tlnp | grep 7687
+sudo netstat -tlnp | grep 7474
+```
+
+### 问题 2: 连接被拒绝
+
+1. 检查服务是否运行: `sudo systemctl status neo4j`
+2. 检查防火墙: `sudo ufw status`
+3. 检查配置文件中的 `listen_address`
+
+### 问题 3: 内存不足
+
+修改 `neo4j.conf` 降低内存配置:
+
+```properties
+server.memory.heap.initial_size=256m
+server.memory.heap.max_size=512m
+server.memory.pagecache.size=256m
+```
+
+### 问题 4: 忘记密码
+
+```bash
+# 停止服务
+sudo systemctl stop neo4j
+
+# 重置密码
+sudo neo4j-admin dbms set-initial-password new-password --require-password-change=false
+
+# 启动服务
+sudo systemctl start neo4j
+```
+
+---
+
+## 9. 生产环境建议
+
+1. **使用强密码**: 不要使用默认密码
+2. **限制访问**: 配置防火墙只允许应用服务器访问
+3. **定期备份**: 使用 `neo4j-admin database dump` 备份数据
+4. **监控**: 配置 Prometheus + Grafana 监控
+5. **日志轮转**: 配置日志轮转避免磁盘占满