소스 검색

fix: 修复 NER 服务模块依赖和编译错误

架构调整:
- 将 NerServiceImpl 的图数据库操作移至 graph-service
- 在 graph-service 新增 GraphNerService 处理 NER 结果持久化
- 将 DocumentParsedEventListener 从 ai-service 移至 graph-service
- 添加 RestTemplate Bean 用于调用 Python NER 服务

修复:
- 移除 ai-service 对 graph-service 实体/仓库的直接依赖
- 修复 GraphController 的 AjaxResult 泛型类型兼容问题
- 修复 GraphNerService 中不存在的 deleteByNodeId 方法

增强:
- 完善 server-deploy.sh 部署脚本
- 添加 NER Python 服务部署支持
- 添加数据库备份/恢复功能
- 添加日志管理和清理功能
何文松 1 개월 전
부모
커밋
9862be4f23

+ 0 - 67
backend/ai-service/src/main/java/com/lingyue/ai/listener/DocumentParsedEventListener.java

@@ -1,67 +0,0 @@
-package com.lingyue.ai.listener;
-
-import com.lingyue.ai.dto.ner.NerResponse;
-import com.lingyue.ai.service.NerService;
-import com.lingyue.common.event.DocumentParsedEvent;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.event.EventListener;
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Component;
-
-/**
- * 文档解析完成事件监听器
- * 监听文档解析完成事件,自动触发 NER 提取
- *
- * @author lingyue
- * @since 2026-01-19
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class DocumentParsedEventListener {
-
-    private final NerService nerService;
-
-    @Value("${ner.auto-extract.enabled:true}")
-    private boolean nerAutoExtractEnabled;
-
-    /**
-     * 处理文档解析完成事件
-     * 异步执行 NER 提取,不阻塞主流程
-     */
-    @Async
-    @EventListener
-    public void handleDocumentParsedEvent(DocumentParsedEvent event) {
-        if (!nerAutoExtractEnabled) {
-            log.debug("NER 自动提取已禁用,跳过: documentId={}", event.getDocumentId());
-            return;
-        }
-
-        String documentId = event.getDocumentId();
-        String userId = event.getUserId();
-
-        log.info("收到文档解析完成事件,开始 NER 提取: documentId={}, userId={}", documentId, userId);
-
-        try {
-            // 调用 NER 服务提取实体和关系并保存
-            NerResponse response = nerService.extractAndSaveForDocument(documentId, userId);
-
-            if (response.getSuccess()) {
-                log.info("NER 自动提取完成: documentId={}, entityCount={}, relationCount={}, time={}ms",
-                        documentId,
-                        response.getEntityCount(),
-                        response.getRelationCount(),
-                        response.getProcessingTime());
-            } else {
-                log.warn("NER 自动提取失败: documentId={}, error={}",
-                        documentId, response.getErrorMessage());
-            }
-
-        } catch (Exception e) {
-            log.error("NER 自动提取异常: documentId={}", documentId, e);
-            // 异常不向上抛出,不影响其他处理
-        }
-    }
-}

+ 7 - 220
backend/ai-service/src/main/java/com/lingyue/ai/service/impl/NerServiceImpl.java

@@ -3,28 +3,20 @@ package com.lingyue.ai.service.impl;
 import com.lingyue.ai.client.PythonNerClient;
 import com.lingyue.ai.dto.ner.*;
 import com.lingyue.ai.service.NerService;
-import com.lingyue.common.exception.ServiceException;
-import com.lingyue.graph.entity.GraphNode;
-import com.lingyue.graph.entity.GraphRelation;
-import com.lingyue.graph.entity.TextStorage;
-import com.lingyue.graph.repository.GraphNodeRepository;
-import com.lingyue.graph.repository.GraphRelationRepository;
-import com.lingyue.graph.repository.TextStorageRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 
 /**
  * NER 服务实现
+ * 
+ * 负责调用 Python NER 服务进行实体提取和关系抽取。
+ * 图数据库的保存操作由 graph-service 模块的 GraphNerService 负责。
  *
  * @author lingyue
  * @since 2026-01-19
@@ -35,9 +27,6 @@ import java.util.stream.Collectors;
 public class NerServiceImpl implements NerService {
 
     private final PythonNerClient pythonNerClient;
-    private final TextStorageRepository textStorageRepository;
-    private final GraphNodeRepository graphNodeRepository;
-    private final GraphRelationRepository graphRelationRepository;
 
     @Override
     public NerResponse extractEntities(NerRequest request) {
@@ -104,55 +93,11 @@ public class NerServiceImpl implements NerService {
     }
 
     @Override
-    @Transactional
     public NerResponse extractAndSaveForDocument(String documentId, String userId) {
-        log.info("开始对文档执行 NER 并保存: documentId={}, userId={}", documentId, userId);
-        
-        long startTime = System.currentTimeMillis();
-        
-        try {
-            // 1. 获取文档的文本内容
-            String text = getDocumentText(documentId);
-            if (text == null || text.isEmpty()) {
-                return NerResponse.error(documentId, "文档文本内容为空");
-            }
-            
-            // 2. 调用 NER 提取
-            NerRequest request = NerRequest.builder()
-                    .documentId(documentId)
-                    .text(text)
-                    .userId(userId)
-                    .extractRelations(true)
-                    .build();
-            
-            NerResponse nerResponse = pythonNerClient.extractEntities(request);
-            
-            if (!nerResponse.getSuccess()) {
-                return nerResponse;
-            }
-            
-            // 3. 保存实体到图数据库
-            Map<String, String> tempIdToNodeId = saveEntitiesToGraph(
-                    documentId, userId, nerResponse.getEntities());
-            
-            // 4. 保存关系到图数据库
-            if (nerResponse.getRelations() != null && !nerResponse.getRelations().isEmpty()) {
-                saveRelationsToGraph(nerResponse.getRelations(), tempIdToNodeId);
-            }
-            
-            long processingTime = System.currentTimeMillis() - startTime;
-            
-            log.info("文档 NER 完成并保存: documentId={}, entityCount={}, relationCount={}, time={}ms",
-                    documentId, nerResponse.getEntityCount(), nerResponse.getRelationCount(), processingTime);
-            
-            // 更新响应的处理时间
-            nerResponse.setProcessingTime(processingTime);
-            return nerResponse;
-            
-        } catch (Exception e) {
-            log.error("文档 NER 失败: documentId={}", documentId, e);
-            return NerResponse.error(documentId, e.getMessage());
-        }
+        // 此方法由 graph-service 的 GraphNerService 实现
+        // ai-service 只提供基础的 NER 能力
+        log.warn("extractAndSaveForDocument 应由 graph-service 的 GraphNerService 调用");
+        return NerResponse.error(documentId, "请使用 GraphNerService 进行文档级 NER 处理");
     }
 
     @Override
@@ -181,162 +126,4 @@ public class NerServiceImpl implements NerService {
                 })
                 .collect(Collectors.toList());
     }
-
-    /**
-     * 获取文档的文本内容
-     */
-    private String getDocumentText(String documentId) {
-        TextStorage textStorage = textStorageRepository.findByDocumentId(documentId);
-        if (textStorage == null) {
-            throw new ServiceException("文档文本存储记录不存在: documentId=" + documentId);
-        }
-        
-        String filePath = textStorage.getFilePath();
-        try {
-            return Files.readString(Path.of(filePath), StandardCharsets.UTF_8);
-        } catch (Exception e) {
-            throw new ServiceException("读取文档文本失败: " + e.getMessage(), e);
-        }
-    }
-
-    /**
-     * 保存实体到图数据库
-     *
-     * @return tempId 到实际 nodeId 的映射
-     */
-    private Map<String, String> saveEntitiesToGraph(String documentId, String userId, 
-                                                     List<EntityInfo> entities) {
-        Map<String, String> tempIdToNodeId = new HashMap<>();
-        
-        if (entities == null || entities.isEmpty()) {
-            return tempIdToNodeId;
-        }
-        
-        for (EntityInfo entity : entities) {
-            GraphNode node = new GraphNode();
-            node.setId(UUID.randomUUID().toString().replace("-", ""));
-            node.setDocumentId(documentId);
-            node.setUserId(userId != null ? userId : "system");
-            node.setName(entity.getName());
-            node.setType(entity.getType() != null ? entity.getType().toLowerCase() : "other");
-            node.setValue(entity.getValue());
-            node.setLevel(0);
-            node.setCreateTime(new Date());
-            node.setUpdateTime(new Date());
-            
-            // 转换位置信息
-            if (entity.getPosition() != null) {
-                Map<String, Object> position = new HashMap<>();
-                position.put("charStart", entity.getPosition().getCharStart());
-                position.put("charEnd", entity.getPosition().getCharEnd());
-                if (entity.getPosition().getLine() != null) {
-                    position.put("line", entity.getPosition().getLine());
-                }
-                if (entity.getPosition().getPage() != null) {
-                    position.put("page", entity.getPosition().getPage());
-                }
-                node.setPosition(position);
-            }
-            
-            // 保存元数据
-            Map<String, Object> metadata = new HashMap<>();
-            if (entity.getContext() != null) {
-                metadata.put("context", entity.getContext());
-            }
-            if (entity.getConfidence() != null) {
-                metadata.put("confidence", entity.getConfidence());
-            }
-            metadata.put("source", "ner");
-            node.setMetadata(metadata);
-            
-            graphNodeRepository.insert(node);
-            
-            // 记录 tempId 到 nodeId 的映射
-            if (entity.getTempId() != null) {
-                tempIdToNodeId.put(entity.getTempId(), node.getId());
-            }
-        }
-        
-        log.debug("保存实体到图数据库完成: count={}", entities.size());
-        return tempIdToNodeId;
-    }
-
-    /**
-     * 保存关系到图数据库
-     */
-    private void saveRelationsToGraph(List<RelationInfo> relations, Map<String, String> tempIdToNodeId) {
-        if (relations == null || relations.isEmpty()) {
-            return;
-        }
-        
-        int savedCount = 0;
-        for (RelationInfo relation : relations) {
-            // 通过 tempId 获取实际的 nodeId
-            String fromNodeId = relation.getFromEntityId() != null ? 
-                    tempIdToNodeId.get(relation.getFromEntityId()) : null;
-            String toNodeId = relation.getToEntityId() != null ? 
-                    tempIdToNodeId.get(relation.getToEntityId()) : null;
-            
-            // 如果无法找到对应的节点,跳过
-            if (fromNodeId == null || toNodeId == null) {
-                log.debug("跳过关系保存(节点不存在): from={}, to={}", 
-                        relation.getFromEntity(), relation.getToEntity());
-                continue;
-            }
-            
-            GraphRelation graphRelation = new GraphRelation();
-            graphRelation.setId(UUID.randomUUID().toString().replace("-", ""));
-            graphRelation.setFromNodeId(fromNodeId);
-            graphRelation.setToNodeId(toNodeId);
-            graphRelation.setRelationType(mapRelationType(relation.getRelationType()));
-            graphRelation.setOrderIndex(0);
-            graphRelation.setCreateTime(new Date());
-            graphRelation.setUpdateTime(new Date());
-            
-            // 保存元数据
-            Map<String, Object> metadata = new HashMap<>();
-            if (relation.getConfidence() != null) {
-                metadata.put("confidence", relation.getConfidence());
-            }
-            metadata.put("originalType", relation.getRelationType());
-            metadata.put("source", "ner");
-            graphRelation.setMetadata(metadata);
-            
-            graphRelationRepository.insert(graphRelation);
-            savedCount++;
-        }
-        
-        log.debug("保存关系到图数据库完成: count={}", savedCount);
-    }
-
-    /**
-     * 映射关系类型到系统定义的类型
-     */
-    private String mapRelationType(String originalType) {
-        if (originalType == null) {
-            return "DEP";
-        }
-        
-        // 映射常见关系类型
-        switch (originalType) {
-            case "负责":
-            case "管理":
-            case "承担":
-                return "DEP";  // 依赖关系
-            case "属于":
-            case "隶属":
-                return "DEP";
-            case "包含":
-            case "包括":
-                return "DEP";
-            case "位于":
-            case "在":
-                return "DEP";
-            case "使用":
-            case "采用":
-                return "DEP";
-            default:
-                return "DEP";  // 默认为依赖关系
-        }
-    }
 }

+ 17 - 1
backend/graph-service/src/main/java/com/lingyue/graph/config/VectorConfig.java

@@ -2,16 +2,20 @@ package com.lingyue.graph.config;
 
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
+import org.springframework.web.client.RestTemplate;
 import org.springframework.web.reactive.function.client.ExchangeStrategies;
 import org.springframework.web.reactive.function.client.WebClient;
 
+import java.time.Duration;
+
 /**
  * 向量化服务配置
- * 配置 Ollama WebClient
+ * 配置 Ollama WebClient 和通用 RestTemplate
  *
  * @author lingyue
  * @since 2026-01-15
@@ -47,4 +51,16 @@ public class VectorConfig {
                 .exchangeStrategies(strategies)
                 .build();
     }
+
+    /**
+     * 通用 RestTemplate Bean
+     * 用于调用外部 HTTP 服务(如 Python NER 服务)
+     */
+    @Bean
+    public RestTemplate restTemplate(RestTemplateBuilder builder) {
+        return builder
+                .setConnectTimeout(Duration.ofSeconds(10))
+                .setReadTimeout(Duration.ofSeconds(60))
+                .build();
+    }
 }

+ 13 - 13
backend/graph-service/src/main/java/com/lingyue/graph/controller/GraphController.java

@@ -40,7 +40,7 @@ public class GraphController {
      */
     @PostMapping("/nodes")
     @Operation(summary = "创建节点", description = "创建单个图节点")
-    public AjaxResult<GraphNode> createNode(@RequestBody CreateNodeRequest request) {
+    public AjaxResult<?> createNode(@RequestBody CreateNodeRequest request) {
         try {
             GraphNode node = graphNodeService.createNode(request);
             return AjaxResult.success(node);
@@ -55,7 +55,7 @@ public class GraphController {
      */
     @PostMapping("/nodes/batch")
     @Operation(summary = "批量创建节点", description = "批量创建图节点")
-    public AjaxResult<List<GraphNode>> batchCreateNodes(@RequestBody BatchCreateNodesRequest request) {
+    public AjaxResult<?> batchCreateNodes(@RequestBody BatchCreateNodesRequest request) {
         try {
             List<GraphNode> nodes = graphNodeService.batchCreateNodes(request);
             return AjaxResult.success(nodes);
@@ -70,7 +70,7 @@ public class GraphController {
      */
     @GetMapping("/nodes/{nodeId}")
     @Operation(summary = "获取节点", description = "根据ID获取节点详情")
-    public AjaxResult<GraphNode> getNode(
+    public AjaxResult<?> getNode(
             @Parameter(description = "节点ID") @PathVariable String nodeId) {
         GraphNode node = graphNodeService.getNodeById(nodeId);
         if (node == null) {
@@ -84,7 +84,7 @@ public class GraphController {
      */
     @GetMapping("/documents/{documentId}/nodes")
     @Operation(summary = "获取文档节点", description = "获取指定文档的所有节点")
-    public AjaxResult<List<GraphNode>> getNodesByDocument(
+    public AjaxResult<?> getNodesByDocument(
             @Parameter(description = "文档ID") @PathVariable String documentId,
             @Parameter(description = "节点类型(可选)") @RequestParam(required = false) String type) {
         List<GraphNode> nodes;
@@ -101,7 +101,7 @@ public class GraphController {
      */
     @PutMapping("/nodes/{nodeId}")
     @Operation(summary = "更新节点", description = "更新节点信息")
-    public AjaxResult<GraphNode> updateNode(
+    public AjaxResult<?> updateNode(
             @Parameter(description = "节点ID") @PathVariable String nodeId,
             @RequestBody CreateNodeRequest request) {
         try {
@@ -118,7 +118,7 @@ public class GraphController {
      */
     @DeleteMapping("/nodes/{nodeId}")
     @Operation(summary = "删除节点", description = "删除节点及其相关关系")
-    public AjaxResult<Void> deleteNode(
+    public AjaxResult<?> deleteNode(
             @Parameter(description = "节点ID") @PathVariable String nodeId) {
         try {
             graphNodeService.deleteNode(nodeId);
@@ -134,7 +134,7 @@ public class GraphController {
      */
     @DeleteMapping("/documents/{documentId}/nodes")
     @Operation(summary = "删除文档节点", description = "删除指定文档的所有节点及关系")
-    public AjaxResult<Integer> deleteNodesByDocument(
+    public AjaxResult<?> deleteNodesByDocument(
             @Parameter(description = "文档ID") @PathVariable String documentId) {
         try {
             int count = graphNodeService.deleteNodesByDocumentId(documentId);
@@ -152,7 +152,7 @@ public class GraphController {
      */
     @PostMapping("/relations")
     @Operation(summary = "创建关系", description = "创建节点间的关系")
-    public AjaxResult<GraphRelation> createRelation(@RequestBody CreateRelationRequest request) {
+    public AjaxResult<?> createRelation(@RequestBody CreateRelationRequest request) {
         try {
             GraphRelation relation = graphNodeService.createRelation(request);
             return AjaxResult.success(relation);
@@ -167,7 +167,7 @@ public class GraphController {
      */
     @PostMapping("/relations/batch")
     @Operation(summary = "批量创建关系", description = "批量创建节点间的关系")
-    public AjaxResult<List<GraphRelation>> batchCreateRelations(@RequestBody BatchCreateRelationsRequest request) {
+    public AjaxResult<?> batchCreateRelations(@RequestBody BatchCreateRelationsRequest request) {
         try {
             List<GraphRelation> relations = graphNodeService.batchCreateRelations(request);
             return AjaxResult.success(relations);
@@ -182,7 +182,7 @@ public class GraphController {
      */
     @GetMapping("/relations/{relationId}")
     @Operation(summary = "获取关系", description = "根据ID获取关系详情")
-    public AjaxResult<GraphRelation> getRelation(
+    public AjaxResult<?> getRelation(
             @Parameter(description = "关系ID") @PathVariable String relationId) {
         GraphRelation relation = graphNodeService.getRelationById(relationId);
         if (relation == null) {
@@ -196,7 +196,7 @@ public class GraphController {
      */
     @GetMapping("/nodes/{nodeId}/relations")
     @Operation(summary = "获取节点关系", description = "获取指定节点的所有关系")
-    public AjaxResult<List<GraphRelation>> getRelationsByNode(
+    public AjaxResult<?> getRelationsByNode(
             @Parameter(description = "节点ID") @PathVariable String nodeId) {
         List<GraphRelation> relations = graphNodeService.getRelationsByNodeId(nodeId);
         return AjaxResult.success(relations);
@@ -207,7 +207,7 @@ public class GraphController {
      */
     @DeleteMapping("/relations/{relationId}")
     @Operation(summary = "删除关系", description = "删除指定关系")
-    public AjaxResult<Void> deleteRelation(
+    public AjaxResult<?> deleteRelation(
             @Parameter(description = "关系ID") @PathVariable String relationId) {
         try {
             graphNodeService.deleteRelation(relationId);
@@ -225,7 +225,7 @@ public class GraphController {
      */
     @GetMapping("/documents/{documentId}/stats")
     @Operation(summary = "获取图统计", description = "获取指定文档的节点和关系统计信息")
-    public AjaxResult<Map<String, Object>> getGraphStats(
+    public AjaxResult<?> getGraphStats(
             @Parameter(description = "文档ID") @PathVariable String documentId) {
         Map<String, Object> nodeStats = graphNodeService.getNodeStatsByDocumentId(documentId);
         Map<String, Object> relationStats = graphNodeService.getRelationStatsByDocumentId(documentId);

+ 136 - 0
backend/graph-service/src/main/java/com/lingyue/graph/listener/DocumentParsedEventListener.java

@@ -0,0 +1,136 @@
+package com.lingyue.graph.listener;
+
+import com.lingyue.common.event.DocumentParsedEvent;
+import com.lingyue.graph.service.GraphNerService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.event.EventListener;
+import org.springframework.http.*;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.*;
+
+/**
+ * 文档解析完成事件监听器
+ * 监听文档解析完成事件,自动触发 NER 提取并保存到图数据库
+ *
+ * @author lingyue
+ * @since 2026-01-19
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DocumentParsedEventListener {
+
+    private final GraphNerService graphNerService;
+    private final RestTemplate restTemplate;
+
+    @Value("${ner.auto-extract.enabled:true}")
+    private boolean nerAutoExtractEnabled;
+
+    @Value("${ner.python-service.url:http://localhost:8001}")
+    private String nerServiceUrl;
+
+    /**
+     * 处理文档解析完成事件
+     * 异步执行 NER 提取,不阻塞主流程
+     */
+    @Async
+    @EventListener
+    public void handleDocumentParsedEvent(DocumentParsedEvent event) {
+        if (!nerAutoExtractEnabled) {
+            log.debug("NER 自动提取已禁用,跳过: documentId={}", event.getDocumentId());
+            return;
+        }
+
+        String documentId = event.getDocumentId();
+        String userId = event.getUserId();
+
+        log.info("收到文档解析完成事件,开始 NER 提取: documentId={}, userId={}", documentId, userId);
+
+        long startTime = System.currentTimeMillis();
+
+        try {
+            // 1. 获取文档文本
+            if (!graphNerService.hasDocumentText(documentId)) {
+                log.warn("文档文本不存在,跳过 NER: documentId={}", documentId);
+                return;
+            }
+            
+            String text = graphNerService.getDocumentText(documentId);
+            if (text == null || text.isEmpty()) {
+                log.warn("文档文本为空,跳过 NER: documentId={}", documentId);
+                return;
+            }
+
+            // 2. 调用 Python NER 服务
+            Map<String, Object> nerResponse = callPythonNerService(documentId, text, userId);
+            
+            if (nerResponse == null || !Boolean.TRUE.equals(nerResponse.get("success"))) {
+                log.warn("NER 服务调用失败: documentId={}, error={}", 
+                        documentId, nerResponse != null ? nerResponse.get("errorMessage") : "null response");
+                return;
+            }
+
+            // 3. 保存实体到图数据库
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> entities = (List<Map<String, Object>>) nerResponse.get("entities");
+            Map<String, String> tempIdToNodeId = graphNerService.saveEntitiesToGraph(documentId, userId, entities);
+
+            // 4. 保存关系到图数据库
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> relations = (List<Map<String, Object>>) nerResponse.get("relations");
+            int relationCount = graphNerService.saveRelationsToGraph(relations, tempIdToNodeId);
+
+            long processingTime = System.currentTimeMillis() - startTime;
+            
+            log.info("NER 自动提取完成: documentId={}, entityCount={}, relationCount={}, time={}ms",
+                    documentId,
+                    entities != null ? entities.size() : 0,
+                    relationCount,
+                    processingTime);
+
+        } catch (Exception e) {
+            log.error("NER 自动提取异常: documentId={}", documentId, e);
+            // 异常不向上抛出,不影响其他处理
+        }
+    }
+
+    /**
+     * 调用 Python NER 服务
+     */
+    private Map<String, Object> callPythonNerService(String documentId, String text, String userId) {
+        try {
+            String url = nerServiceUrl + "/ner/extract";
+            
+            Map<String, Object> request = new HashMap<>();
+            request.put("documentId", documentId);
+            request.put("text", text);
+            request.put("userId", userId);
+            request.put("extractRelations", true);
+            
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            
+            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
+            
+            @SuppressWarnings("unchecked")
+            ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
+            
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> body = response.getBody();
+                return body;
+            }
+            
+            return null;
+            
+        } catch (Exception e) {
+            log.error("调用 Python NER 服务失败: {}", e.getMessage());
+            return null;
+        }
+    }
+}

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

@@ -0,0 +1,305 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.common.exception.ServiceException;
+import com.lingyue.graph.dto.*;
+import com.lingyue.graph.entity.GraphNode;
+import com.lingyue.graph.entity.GraphRelation;
+import com.lingyue.graph.entity.TextStorage;
+import com.lingyue.graph.repository.GraphNodeRepository;
+import com.lingyue.graph.repository.GraphRelationRepository;
+import com.lingyue.graph.repository.TextStorageRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * 图数据库 NER 服务
+ * 
+ * 负责将 NER 提取结果保存到图数据库。
+ * 此服务位于 graph-service 模块,可以直接访问图数据库相关的实体和仓库。
+ *
+ * @author lingyue
+ * @since 2026-01-19
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GraphNerService {
+
+    private final TextStorageRepository textStorageRepository;
+    private final GraphNodeRepository graphNodeRepository;
+    private final GraphRelationRepository graphRelationRepository;
+
+    /**
+     * 获取文档的文本内容
+     *
+     * @param documentId 文档ID
+     * @return 文本内容
+     */
+    public String getDocumentText(String documentId) {
+        TextStorage textStorage = textStorageRepository.findByDocumentId(documentId);
+        if (textStorage == null) {
+            throw new ServiceException("文档文本存储记录不存在: documentId=" + documentId);
+        }
+        
+        String filePath = textStorage.getFilePath();
+        try {
+            return Files.readString(Path.of(filePath), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new ServiceException("读取文档文本失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 检查文档是否存在文本存储
+     *
+     * @param documentId 文档ID
+     * @return 是否存在
+     */
+    public boolean hasDocumentText(String documentId) {
+        TextStorage textStorage = textStorageRepository.findByDocumentId(documentId);
+        return textStorage != null;
+    }
+
+    /**
+     * 批量保存 NER 实体到图数据库
+     *
+     * @param documentId 文档ID
+     * @param userId     用户ID
+     * @param entities   实体列表(包含 name, type, value, position, context, confidence, tempId)
+     * @return tempId 到实际 nodeId 的映射
+     */
+    @Transactional
+    public Map<String, String> saveEntitiesToGraph(String documentId, String userId, 
+                                                    List<Map<String, Object>> entities) {
+        Map<String, String> tempIdToNodeId = new HashMap<>();
+        
+        if (entities == null || entities.isEmpty()) {
+            log.debug("无实体需要保存");
+            return tempIdToNodeId;
+        }
+        
+        log.info("开始保存实体到图数据库: documentId={}, count={}", documentId, entities.size());
+        
+        for (Map<String, Object> entity : entities) {
+            GraphNode node = new GraphNode();
+            node.setId(UUID.randomUUID().toString().replace("-", ""));
+            node.setDocumentId(documentId);
+            node.setUserId(userId != null ? userId : "system");
+            node.setName(getStringValue(entity, "name"));
+            node.setType(getStringValue(entity, "type", "other").toLowerCase());
+            node.setValue(getStringValue(entity, "value"));
+            node.setLevel(0);
+            node.setCreateTime(new Date());
+            node.setUpdateTime(new Date());
+            
+            // 转换位置信息
+            Object positionObj = entity.get("position");
+            if (positionObj instanceof Map) {
+                node.setPosition(positionObj);
+            }
+            
+            // 保存元数据
+            Map<String, Object> metadata = new HashMap<>();
+            String context = getStringValue(entity, "context");
+            if (context != null) {
+                metadata.put("context", context);
+            }
+            Object confidence = entity.get("confidence");
+            if (confidence != null) {
+                metadata.put("confidence", confidence);
+            }
+            metadata.put("source", "ner");
+            node.setMetadata(metadata);
+            
+            graphNodeRepository.insert(node);
+            
+            // 记录 tempId 到 nodeId 的映射
+            String tempId = getStringValue(entity, "tempId");
+            if (tempId != null) {
+                tempIdToNodeId.put(tempId, node.getId());
+            }
+        }
+        
+        log.info("实体保存完成: documentId={}, savedCount={}", documentId, entities.size());
+        return tempIdToNodeId;
+    }
+
+    /**
+     * 批量保存 NER 关系到图数据库
+     *
+     * @param relations       关系列表
+     * @param tempIdToNodeId  tempId 到 nodeId 的映射
+     * @return 保存成功的数量
+     */
+    @Transactional
+    public int saveRelationsToGraph(List<Map<String, Object>> relations, 
+                                     Map<String, String> tempIdToNodeId) {
+        if (relations == null || relations.isEmpty()) {
+            log.debug("无关系需要保存");
+            return 0;
+        }
+        
+        log.info("开始保存关系到图数据库: count={}", relations.size());
+        
+        int savedCount = 0;
+        for (Map<String, Object> relation : relations) {
+            // 通过 tempId 获取实际的 nodeId
+            String fromEntityId = getStringValue(relation, "fromEntityId");
+            String toEntityId = getStringValue(relation, "toEntityId");
+            
+            String fromNodeId = fromEntityId != null ? tempIdToNodeId.get(fromEntityId) : null;
+            String toNodeId = toEntityId != null ? tempIdToNodeId.get(toEntityId) : null;
+            
+            // 如果无法找到对应的节点,跳过
+            if (fromNodeId == null || toNodeId == null) {
+                log.debug("跳过关系保存(节点不存在): from={}, to={}", 
+                        getStringValue(relation, "fromEntity"), 
+                        getStringValue(relation, "toEntity"));
+                continue;
+            }
+            
+            GraphRelation graphRelation = new GraphRelation();
+            graphRelation.setId(UUID.randomUUID().toString().replace("-", ""));
+            graphRelation.setFromNodeId(fromNodeId);
+            graphRelation.setToNodeId(toNodeId);
+            graphRelation.setRelationType(mapRelationType(getStringValue(relation, "relationType")));
+            graphRelation.setOrderIndex(0);
+            graphRelation.setCreateTime(new Date());
+            graphRelation.setUpdateTime(new Date());
+            
+            // 保存元数据
+            Map<String, Object> metadata = new HashMap<>();
+            Object confidence = relation.get("confidence");
+            if (confidence != null) {
+                metadata.put("confidence", confidence);
+            }
+            metadata.put("originalType", getStringValue(relation, "relationType"));
+            metadata.put("source", "ner");
+            graphRelation.setMetadata(metadata);
+            
+            graphRelationRepository.insert(graphRelation);
+            savedCount++;
+        }
+        
+        log.info("关系保存完成: savedCount={}", savedCount);
+        return savedCount;
+    }
+
+    /**
+     * 删除文档的所有 NER 生成的节点和关系
+     *
+     * @param documentId 文档ID
+     * @return 删除的节点数量
+     */
+    @Transactional
+    public int deleteNerResultsByDocumentId(String documentId) {
+        log.info("删除文档 NER 结果: documentId={}", documentId);
+        
+        // 获取文档的所有节点
+        List<GraphNode> nodes = graphNodeRepository.findByDocumentId(documentId);
+        
+        int deletedCount = 0;
+        for (GraphNode node : nodes) {
+            // 检查是否是 NER 生成的节点
+            if (node.getMetadata() instanceof Map) {
+                Map<?, ?> metadata = (Map<?, ?>) node.getMetadata();
+                if ("ner".equals(metadata.get("source"))) {
+                    // 先删除相关的关系
+                    List<GraphRelation> relations = graphRelationRepository.findByNodeId(node.getId());
+                    for (GraphRelation relation : relations) {
+                        graphRelationRepository.deleteById(relation.getId());
+                    }
+                    // 再删除节点
+                    graphNodeRepository.deleteById(node.getId());
+                    deletedCount++;
+                }
+            }
+        }
+        
+        log.info("NER 结果删除完成: documentId={}, deletedCount={}", documentId, deletedCount);
+        return deletedCount;
+    }
+
+    /**
+     * 获取文档的 NER 统计信息
+     *
+     * @param documentId 文档ID
+     * @return 统计信息
+     */
+    public Map<String, Object> getNerStatsByDocumentId(String documentId) {
+        List<GraphNode> nodes = graphNodeRepository.findByDocumentId(documentId);
+        
+        int nerNodeCount = 0;
+        Map<String, Integer> typeStats = new HashMap<>();
+        
+        for (GraphNode node : nodes) {
+            if (node.getMetadata() instanceof Map) {
+                Map<?, ?> metadata = (Map<?, ?>) node.getMetadata();
+                if ("ner".equals(metadata.get("source"))) {
+                    nerNodeCount++;
+                    String type = node.getType();
+                    typeStats.put(type, typeStats.getOrDefault(type, 0) + 1);
+                }
+            }
+        }
+        
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("documentId", documentId);
+        stats.put("totalNerNodes", nerNodeCount);
+        stats.put("typeStats", typeStats);
+        
+        return stats;
+    }
+
+    /**
+     * 映射关系类型到系统定义的类型
+     */
+    private String mapRelationType(String originalType) {
+        if (originalType == null) {
+            return "DEP";
+        }
+        
+        // 映射常见关系类型
+        switch (originalType) {
+            case "负责":
+            case "管理":
+            case "承担":
+            case "属于":
+            case "隶属":
+            case "包含":
+            case "包括":
+            case "位于":
+            case "在":
+            case "使用":
+            case "采用":
+                return "DEP";  // 依赖关系
+            default:
+                return "DEP";  // 默认为依赖关系
+        }
+    }
+
+    /**
+     * 从 Map 中获取字符串值
+     */
+    private String getStringValue(Map<String, Object> map, String key) {
+        return getStringValue(map, key, null);
+    }
+
+    /**
+     * 从 Map 中获取字符串值,带默认值
+     */
+    private String getStringValue(Map<String, Object> map, String key, String defaultValue) {
+        Object value = map.get(key);
+        if (value == null) {
+            return defaultValue;
+        }
+        return value.toString();
+    }
+}

+ 570 - 46
server-deploy.sh

@@ -4,6 +4,8 @@
 # 灵越智报 2.0 - 服务器一键部署脚本
 # 适用于: Ubuntu 22.04 LTS
 # 服务器: lanaipc
+# 版本: 2.1.0
+# 更新日期: 2026-01-19
 # ============================================
 
 set -e
@@ -13,26 +15,37 @@ RED='\033[0;31m'
 GREEN='\033[0;32m'
 YELLOW='\033[1;33m'
 BLUE='\033[0;34m'
+CYAN='\033[0;36m'
 NC='\033[0m'
 
 # 配置
 PROJECT_DIR="/mnt/win_home/lingyue-zhibao"
 LOG_DIR="/var/log/lingyue"
 DATA_DIR="/mnt/win_home/lingyue-data"
+BACKUP_DIR="/mnt/win_home/lingyue-backup"
+PYTHON_VENV_DIR="${PROJECT_DIR}/python-services/ner-service/venv"
 
 # 数据库配置
 DB_NAME="lingyue_zhibao"
 DB_USER="lingyue"
 DB_PASS="123123"
+DB_HOST="localhost"
+DB_PORT="5432"
 
 # RabbitMQ 配置
 MQ_USER="admin"
 MQ_PASS="admin123"
 
-log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
-log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
-log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+# NER 服务配置
+NER_SERVICE_PORT="8001"
+NER_SERVICE_HOST="localhost"
+
+# 日志函数
+log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
+log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
 log_title() { echo -e "\n${BLUE}========== $1 ==========${NC}\n"; }
+log_step() { echo -e "${CYAN}[STEP]${NC} $1"; }
 
 # 检查 root 权限
 check_root() {
@@ -42,6 +55,49 @@ check_root() {
     fi
 }
 
+# 检查必要的服务是否运行
+check_services() {
+    log_title "检查服务状态"
+    
+    local all_ok=true
+    
+    if systemctl is-active --quiet postgresql; then
+        log_info "PostgreSQL: 运行中"
+    else
+        log_warn "PostgreSQL: 未运行"
+        all_ok=false
+    fi
+    
+    if systemctl is-active --quiet redis-server; then
+        log_info "Redis: 运行中"
+    else
+        log_warn "Redis: 未运行"
+        all_ok=false
+    fi
+    
+    if systemctl is-active --quiet rabbitmq-server; then
+        log_info "RabbitMQ: 运行中"
+    else
+        log_warn "RabbitMQ: 未运行"
+        all_ok=false
+    fi
+    
+    if [ "$all_ok" = false ]; then
+        log_error "部分服务未运行,请先启动依赖服务"
+        return 1
+    fi
+    
+    return 0
+}
+
+# 确保目录存在
+ensure_directories() {
+    mkdir -p ${LOG_DIR}
+    mkdir -p ${DATA_DIR}
+    mkdir -p ${BACKUP_DIR}
+    chmod 755 ${LOG_DIR} ${DATA_DIR} ${BACKUP_DIR}
+}
+
 # 安装基础依赖
 install_dependencies() {
     log_title "安装基础依赖"
@@ -144,12 +200,34 @@ install_ollama() {
         curl -fsSL https://ollama.ai/install.sh | sh
     fi
     
-    # 启动 Ollama
-    nohup ollama serve > /var/log/ollama.log 2>&1 &
+    # 创建 Ollama systemd 服务
+    if [ ! -f /etc/systemd/system/ollama.service ]; then
+        cat > /etc/systemd/system/ollama.service <<EOF
+[Unit]
+Description=Ollama Service
+After=network.target
+
+[Service]
+Type=simple
+User=root
+ExecStart=/usr/local/bin/ollama serve
+Restart=always
+RestartSec=10
+StandardOutput=append:/var/log/ollama.log
+StandardError=append:/var/log/ollama-error.log
+
+[Install]
+WantedBy=multi-user.target
+EOF
+        systemctl daemon-reload
+    fi
+    
+    systemctl enable ollama
+    systemctl restart ollama
     sleep 5
     
     # 下载 Embedding 模型
-    if ! ollama list | grep -q "nomic-embed-text"; then
+    if ! ollama list 2>/dev/null | grep -q "nomic-embed-text"; then
         log_info "下载 nomic-embed-text 模型..."
         ollama pull nomic-embed-text
     fi
@@ -157,13 +235,118 @@ install_ollama() {
     log_info "Ollama 已安装"
 }
 
+# 安装 Python 环境(用于 NER 服务)
+install_python_env() {
+    log_title "安装 Python 环境"
+    
+    # 安装 Python 和 pip
+    apt install -y python3 python3-pip python3-venv
+    
+    log_info "Python 版本: $(python3 --version)"
+}
+
+# 部署 NER Python 服务
+deploy_ner_service() {
+    log_title "部署 NER Python 服务"
+    
+    local ner_dir="${PROJECT_DIR}/python-services/ner-service"
+    
+    if [ ! -d "${ner_dir}" ]; then
+        log_warn "NER 服务目录不存在: ${ner_dir}"
+        return 0
+    fi
+    
+    cd ${ner_dir}
+    
+    # 创建虚拟环境
+    if [ ! -d "${PYTHON_VENV_DIR}" ]; then
+        log_step "创建 Python 虚拟环境..."
+        python3 -m venv ${PYTHON_VENV_DIR}
+    fi
+    
+    # 激活虚拟环境并安装依赖
+    log_step "安装 Python 依赖..."
+    source ${PYTHON_VENV_DIR}/bin/activate
+    pip install --upgrade pip -q
+    pip install -r requirements.txt -q
+    deactivate
+    
+    log_info "NER 服务依赖安装完成"
+}
+
+# 创建 NER 服务的 Systemd 配置
+create_ner_systemd_service() {
+    log_title "创建 NER Systemd 服务"
+    
+    local ner_dir="${PROJECT_DIR}/python-services/ner-service"
+    
+    if [ ! -d "${ner_dir}" ]; then
+        log_warn "NER 服务目录不存在,跳过"
+        return 0
+    fi
+    
+    cat > /etc/systemd/system/lingyue-ner.service <<EOF
+[Unit]
+Description=Lingyue NER Service
+After=network.target
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=${ner_dir}
+ExecStart=${PYTHON_VENV_DIR}/bin/uvicorn app.main:app --host 0.0.0.0 --port ${NER_SERVICE_PORT}
+Restart=always
+RestartSec=10
+StandardOutput=append:${LOG_DIR}/ner-service.log
+StandardError=append:${LOG_DIR}/ner-service-error.log
+
+Environment=NER_MODEL=rule
+Environment=LOG_LEVEL=INFO
+Environment=MAX_TEXT_LENGTH=50000
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    systemctl daemon-reload
+    log_info "NER Systemd 服务已创建"
+}
+
+# 启动 NER 服务
+start_ner_service() {
+    log_title "启动 NER 服务"
+    
+    if [ ! -f /etc/systemd/system/lingyue-ner.service ]; then
+        log_warn "NER 服务未配置,跳过"
+        return 0
+    fi
+    
+    systemctl enable lingyue-ner
+    systemctl restart lingyue-ner
+    
+    sleep 5
+    
+    if systemctl is-active --quiet lingyue-ner; then
+        log_info "NER 服务启动成功: http://localhost:${NER_SERVICE_PORT}"
+    else
+        log_warn "NER 服务启动失败,查看日志: journalctl -u lingyue-ner -f"
+    fi
+}
+
+# 停止 NER 服务
+stop_ner_service() {
+    if systemctl is-active --quiet lingyue-ner; then
+        systemctl stop lingyue-ner
+        log_info "NER 服务已停止"
+    fi
+}
+
 # 部署项目
 deploy_project() {
     log_title "部署项目"
     
     # 创建目录
-    mkdir -p ${LOG_DIR}
-    mkdir -p ${DATA_DIR}
+    ensure_directories
     
     # 进入项目目录
     if [ ! -d "${PROJECT_DIR}" ]; then
@@ -175,16 +358,54 @@ deploy_project() {
     cd ${PROJECT_DIR}
     
     # 初始化数据库表
-    log_info "初始化数据库表..."
-    PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -d ${DB_NAME} -h localhost -f backend/sql/init.sql
-    PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -d ${DB_NAME} -h localhost -f backend/sql/rag_tables.sql 2>/dev/null || true
+    init_database_tables
     
     # 编译项目
-    log_info "编译项目..."
-    cd backend
+    build_java_project
+    
+    log_info "项目部署完成"
+}
+
+# 初始化数据库表
+init_database_tables() {
+    log_step "初始化数据库表..."
+    
+    cd ${PROJECT_DIR}
+    
+    # 执行初始化脚本
+    if [ -f "backend/sql/init.sql" ]; then
+        PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -d ${DB_NAME} -h ${DB_HOST} -f backend/sql/init.sql 2>/dev/null || true
+        log_info "init.sql 执行完成"
+    fi
+    
+    # 执行 RAG 表脚本
+    if [ -f "backend/sql/rag_tables.sql" ]; then
+        PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -d ${DB_NAME} -h ${DB_HOST} -f backend/sql/rag_tables.sql 2>/dev/null || true
+        log_info "rag_tables.sql 执行完成"
+    fi
+    
+    # 执行补充表脚本
+    if [ -f "backend/sql/supplement_tables.sql" ]; then
+        PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -d ${DB_NAME} -h ${DB_HOST} -f backend/sql/supplement_tables.sql 2>/dev/null || true
+        log_info "supplement_tables.sql 执行完成"
+    fi
+}
+
+# 编译 Java 项目
+build_java_project() {
+    log_step "编译 Java 项目..."
+    
+    cd ${PROJECT_DIR}/backend
+    
+    # 清理并编译
     mvn clean package -DskipTests -q
     
-    log_info "项目编译完成"
+    if [ -f "lingyue-starter/target/lingyue-starter.jar" ]; then
+        log_info "Java 项目编译成功"
+    else
+        log_error "Java 项目编译失败"
+        exit 1
+    fi
 }
 
 # 创建 Systemd 服务
@@ -206,9 +427,30 @@ RestartSec=10
 StandardOutput=append:${LOG_DIR}/lingyue.log
 StandardError=append:${LOG_DIR}/lingyue-error.log
 
+# Java 环境
 Environment=JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
 Environment=SPRING_PROFILES_ACTIVE=prod
 
+# 数据库配置
+Environment=DB_HOST=${DB_HOST}
+Environment=DB_PORT=${DB_PORT}
+Environment=DB_NAME=${DB_NAME}
+Environment=DB_USERNAME=${DB_USER}
+Environment=DB_PASSWORD=${DB_PASS}
+
+# Redis 配置
+Environment=REDIS_HOST=localhost
+Environment=REDIS_PORT=6379
+
+# RabbitMQ 配置
+Environment=RABBITMQ_HOST=localhost
+Environment=RABBITMQ_PORT=5672
+Environment=RABBITMQ_USERNAME=${MQ_USER}
+Environment=RABBITMQ_PASSWORD=${MQ_PASS}
+
+# NER 服务配置
+Environment=NER_SERVICE_URL=http://${NER_SERVICE_HOST}:${NER_SERVICE_PORT}
+
 [Install]
 WantedBy=multi-user.target
 EOF
@@ -245,20 +487,32 @@ stop_app() {
 show_status() {
     log_title "服务状态"
     
-    echo "PostgreSQL:  $(systemctl is-active postgresql)"
-    echo "Redis:       $(systemctl is-active redis-server)"
-    echo "RabbitMQ:    $(systemctl is-active rabbitmq-server)"
-    echo "Lingyue:     $(systemctl is-active lingyue)"
+    printf "%-20s %s\n" "服务" "状态"
+    printf "%-20s %s\n" "--------------------" "----------"
+    printf "%-20s %s\n" "PostgreSQL" "$(systemctl is-active postgresql 2>/dev/null || echo 'unknown')"
+    printf "%-20s %s\n" "Redis" "$(systemctl is-active redis-server 2>/dev/null || echo 'unknown')"
+    printf "%-20s %s\n" "RabbitMQ" "$(systemctl is-active rabbitmq-server 2>/dev/null || echo 'unknown')"
+    printf "%-20s %s\n" "Lingyue (Java)" "$(systemctl is-active lingyue 2>/dev/null || echo 'unknown')"
+    printf "%-20s %s\n" "Lingyue NER" "$(systemctl is-active lingyue-ner 2>/dev/null || echo 'unknown')"
     
     if command -v ollama &> /dev/null && curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
-        echo "Ollama:      running"
+        printf "%-20s %s\n" "Ollama" "active"
     else
-        echo "Ollama:      stopped"
+        printf "%-20s %s\n" "Ollama" "inactive"
     fi
     
     echo ""
+    local ip=$(hostname -I | awk '{print $1}')
+    
     if systemctl is-active --quiet lingyue; then
-        log_info "应用访问地址: http://$(hostname -I | awk '{print $1}'):8000"
+        log_info "主应用: http://${ip}:8000"
+        log_info "Swagger: http://${ip}:8000/swagger-ui.html"
+        log_info "健康检查: http://${ip}:8000/actuator/health"
+    fi
+    
+    if systemctl is-active --quiet lingyue-ner; then
+        log_info "NER 服务: http://${ip}:${NER_SERVICE_PORT}"
+        log_info "NER 健康检查: http://${ip}:${NER_SERVICE_PORT}/health"
     fi
 }
 
@@ -271,71 +525,325 @@ show_logs() {
 health_check() {
     log_title "健康检查"
     
+    echo "=== Java 主应用 ==="
     local health=$(curl -s http://localhost:8000/actuator/health 2>/dev/null)
     if [ -n "$health" ]; then
         echo "$health" | python3 -m json.tool 2>/dev/null || echo "$health"
     else
-        log_error "应用未响应"
+        log_error "Java 应用未响应"
     fi
+    
+    echo ""
+    echo "=== NER 服务 ==="
+    local ner_health=$(curl -s http://localhost:${NER_SERVICE_PORT}/health 2>/dev/null)
+    if [ -n "$ner_health" ]; then
+        echo "$ner_health" | python3 -m json.tool 2>/dev/null || echo "$ner_health"
+    else
+        log_warn "NER 服务未响应(可能未部署)"
+    fi
+}
+
+# 备份数据库
+backup_database() {
+    log_title "备份数据库"
+    
+    ensure_directories
+    
+    local timestamp=$(date '+%Y%m%d_%H%M%S')
+    local backup_file="${BACKUP_DIR}/db_backup_${timestamp}.sql"
+    
+    log_step "备份数据库到 ${backup_file}..."
+    PGPASSWORD=${DB_PASS} pg_dump -U ${DB_USER} -h ${DB_HOST} -d ${DB_NAME} > ${backup_file}
+    
+    # 压缩备份
+    gzip ${backup_file}
+    
+    log_info "数据库备份完成: ${backup_file}.gz"
+    
+    # 清理 7 天前的备份
+    find ${BACKUP_DIR} -name "db_backup_*.sql.gz" -mtime +7 -delete
+    log_info "已清理 7 天前的备份"
+    
+    # 显示备份列表
+    echo ""
+    echo "现有备份:"
+    ls -lh ${BACKUP_DIR}/db_backup_*.sql.gz 2>/dev/null || echo "无备份文件"
+}
+
+# 恢复数据库
+restore_database() {
+    log_title "恢复数据库"
+    
+    local backup_file=$1
+    
+    if [ -z "$backup_file" ]; then
+        echo "可用的备份文件:"
+        ls -1 ${BACKUP_DIR}/db_backup_*.sql.gz 2>/dev/null || echo "无备份文件"
+        echo ""
+        log_error "请指定备份文件: ./server-deploy.sh restore <backup_file>"
+        return 1
+    fi
+    
+    if [ ! -f "$backup_file" ]; then
+        log_error "备份文件不存在: $backup_file"
+        return 1
+    fi
+    
+    log_warn "警告: 此操作将覆盖现有数据库!"
+    read -p "确认恢复? (yes/no): " confirm
+    
+    if [ "$confirm" != "yes" ]; then
+        log_info "操作已取消"
+        return 0
+    fi
+    
+    log_step "恢复数据库..."
+    
+    # 解压并恢复
+    if [[ "$backup_file" == *.gz ]]; then
+        gunzip -c "$backup_file" | PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -h ${DB_HOST} -d ${DB_NAME}
+    else
+        PGPASSWORD=${DB_PASS} psql -U ${DB_USER} -h ${DB_HOST} -d ${DB_NAME} < "$backup_file"
+    fi
+    
+    log_info "数据库恢复完成"
+}
+
+# 清理日志
+clean_logs() {
+    log_title "清理日志"
+    
+    # 清理 30 天前的日志
+    find ${LOG_DIR} -name "*.log" -mtime +30 -delete 2>/dev/null || true
+    
+    # 截断当前日志文件(保留最后 10000 行)
+    for logfile in ${LOG_DIR}/*.log; do
+        if [ -f "$logfile" ]; then
+            tail -n 10000 "$logfile" > "${logfile}.tmp" && mv "${logfile}.tmp" "$logfile"
+        fi
+    done
+    
+    log_info "日志清理完成"
+    
+    # 显示日志目录大小
+    echo "日志目录大小:"
+    du -sh ${LOG_DIR}
+}
+
+# 查看所有日志
+show_all_logs() {
+    log_title "选择要查看的日志"
+    
+    echo "1) Java 主应用日志"
+    echo "2) Java 错误日志"
+    echo "3) NER 服务日志"
+    echo "4) NER 错误日志"
+    echo "5) Ollama 日志"
+    echo "6) 全部日志 (tail)"
+    
+    read -p "选择 [1-6]: " choice
+    
+    case $choice in
+        1) tail -f ${LOG_DIR}/lingyue.log ;;
+        2) tail -f ${LOG_DIR}/lingyue-error.log ;;
+        3) tail -f ${LOG_DIR}/ner-service.log ;;
+        4) tail -f ${LOG_DIR}/ner-service-error.log ;;
+        5) tail -f /var/log/ollama.log ;;
+        6) tail -f ${LOG_DIR}/*.log ;;
+        *) log_error "无效选择" ;;
+    esac
 }
 
 # 完整安装
 full_install() {
     check_root
+    ensure_directories
+    
     install_dependencies
+    install_python_env
     install_postgresql
     setup_database
     install_redis
     install_rabbitmq
     install_ollama
+    
     deploy_project
+    deploy_ner_service
+    
     create_systemd_service
+    create_ner_systemd_service
+    
     start_app
+    start_ner_service
     
     log_title "部署完成"
     show_status
 }
 
+# 仅安装基础设施(不部署应用)
+install_infra_only() {
+    check_root
+    ensure_directories
+    
+    install_dependencies
+    install_python_env
+    install_postgresql
+    setup_database
+    install_redis
+    install_rabbitmq
+    install_ollama
+    
+    log_title "基础设施安装完成"
+    show_status
+}
+
+# 仅部署应用(假设基础设施已就绪)
+deploy_app_only() {
+    check_root
+    check_services || exit 1
+    
+    deploy_project
+    deploy_ner_service
+    
+    create_systemd_service
+    create_ner_systemd_service
+    
+    start_app
+    start_ner_service
+    
+    log_title "应用部署完成"
+    show_status
+}
+
 # 仅更新代码
 update_only() {
     log_title "更新代码"
     
     cd ${PROJECT_DIR}
-    git pull origin main || git pull origin master || true
     
-    cd backend
-    mvn clean package -DskipTests -q
+    # 拉取最新代码
+    log_step "拉取最新代码..."
+    git fetch --all
+    git pull origin dev || git pull origin main || git pull origin master || true
+    
+    # 编译 Java 项目
+    build_java_project
+    
+    # 更新 NER 服务依赖
+    if [ -d "python-services/ner-service" ]; then
+        log_step "更新 NER 服务依赖..."
+        source ${PYTHON_VENV_DIR}/bin/activate 2>/dev/null || true
+        pip install -r python-services/ner-service/requirements.txt -q 2>/dev/null || true
+        deactivate 2>/dev/null || true
+    fi
     
+    # 重启服务
+    log_step "重启服务..."
     systemctl restart lingyue
+    systemctl restart lingyue-ner 2>/dev/null || true
     
     sleep 10
     health_check
 }
 
+# 快速重启(不重新编译)
+quick_restart() {
+    log_title "快速重启"
+    
+    systemctl restart lingyue
+    systemctl restart lingyue-ner 2>/dev/null || true
+    
+    sleep 5
+    health_check
+}
+
+# 停止所有服务
+stop_all() {
+    log_title "停止所有服务"
+    
+    systemctl stop lingyue 2>/dev/null || true
+    systemctl stop lingyue-ner 2>/dev/null || true
+    
+    log_info "所有应用服务已停止"
+}
+
+# 启动所有服务
+start_all() {
+    log_title "启动所有服务"
+    
+    # 先检查基础服务
+    check_services || {
+        log_warn "部分基础服务未运行,尝试启动..."
+        systemctl start postgresql 2>/dev/null || true
+        systemctl start redis-server 2>/dev/null || true
+        systemctl start rabbitmq-server 2>/dev/null || true
+        sleep 3
+    }
+    
+    start_app
+    start_ner_service
+}
+
 # 显示帮助
 show_help() {
     cat <<EOF
-灵越智报 2.0 - 服务器部署脚本
-
-用法: ./server-deploy.sh [命令]
-
-命令:
-    install         完整安装(首次部署)
-    update          仅更新代码并重启
-    start           启动应用
-    stop            停止应用
-    restart         重启应用
-    status          查看服务状态
-    logs            查看应用日志
-    health          健康检查
+${BLUE}灵越智报 2.0 - 服务器部署脚本${NC}
+${CYAN}版本: 2.1.0${NC}
+
+${GREEN}用法:${NC} ./server-deploy.sh [命令] [参数]
+
+${YELLOW}部署命令:${NC}
+    install         完整安装(首次部署,包含所有基础设施)
+    install-infra   仅安装基础设施(PostgreSQL, Redis, RabbitMQ, Ollama)
+    deploy          仅部署应用(假设基础设施已就绪)
+    update          更新代码并重启(git pull + mvn package)
+
+${YELLOW}服务控制:${NC}
+    start           启动所有应用服务
+    stop            停止所有应用服务
+    restart         快速重启(不重新编译)
+    status          查看所有服务状态
+
+${YELLOW}日志与监控:${NC}
+    logs            选择查看日志
+    health          健康检查(Java + NER)
+
+${YELLOW}数据库管理:${NC}
+    backup          备份数据库
+    restore <file>  恢复数据库(需指定备份文件)
+    init-db         重新初始化数据库表
+
+${YELLOW}维护命令:${NC}
+    clean-logs      清理旧日志文件
     help            显示此帮助
 
-首次部署:
+${GREEN}示例:${NC}
+    # 首次部署
     ./server-deploy.sh install
 
-更新部署:
+    # 更新代码并重启
     ./server-deploy.sh update
 
+    # 备份数据库
+    ./server-deploy.sh backup
+
+    # 查看服务状态
+    ./server-deploy.sh status
+
+${YELLOW}目录说明:${NC}
+    项目目录: ${PROJECT_DIR}
+    日志目录: ${LOG_DIR}
+    数据目录: ${DATA_DIR}
+    备份目录: ${BACKUP_DIR}
+
+${YELLOW}服务端口:${NC}
+    Java 主应用: 8000
+    NER 服务:    ${NER_SERVICE_PORT}
+    PostgreSQL:  ${DB_PORT}
+    Redis:       6379
+    RabbitMQ:    5672 / 15672 (管理界面)
+    Ollama:      11434
+
 EOF
 }
 
@@ -345,29 +853,45 @@ main() {
         install)
             full_install
             ;;
+        install-infra)
+            install_infra_only
+            ;;
+        deploy)
+            deploy_app_only
+            ;;
         update)
             update_only
             ;;
         start)
-            start_app
+            start_all
             ;;
         stop)
-            stop_app
+            stop_all
             ;;
         restart)
-            systemctl restart lingyue
-            sleep 5
-            health_check
+            quick_restart
             ;;
         status)
             show_status
             ;;
         logs)
-            show_logs
+            show_all_logs
             ;;
         health)
             health_check
             ;;
+        backup)
+            backup_database
+            ;;
+        restore)
+            restore_database "$2"
+            ;;
+        init-db)
+            init_database_tables
+            ;;
+        clean-logs)
+            clean_logs
+            ;;
         help|--help|-h)
             show_help
             ;;