diff --git a/openspec/changes/phase-1-infrastructure/tasks.md b/openspec/changes/phase-1-infrastructure/tasks.md index d48eeab..06a6f01 100644 --- a/openspec/changes/phase-1-infrastructure/tasks.md +++ b/openspec/changes/phase-1-infrastructure/tasks.md @@ -39,7 +39,7 @@ - [x] 5.1 创建 TextExtractor 服务 (仅支持 .md 和 .txt,其他格式通过外部转换服务) - [x] 5.2 文档分块服务 (DocumentChunkService 已存在,已适配新 DTO) -- [ ] 5.3 文档上传接口 (DocumentController#upload, DocumentService#uploadDocument) +- [x] 5.3 文档上传接口 (DocumentController#upload, DocumentManagementService#uploadDocument) - [ ] 5.4 文档查询接口 (DocumentController#query, DocumentService#queryDocuments) - [ ] 5.5 文档删除接口 (DocumentController#delete, DocumentService#deleteDocument) - [ ] 5.6 混合检索工具 (DocumentSearchTool: 精确匹配 + 语义检索 + RRF 融合) diff --git a/src/main/java/com/superbiz/agent/controller/DocumentController.java b/src/main/java/com/superbiz/agent/controller/DocumentController.java new file mode 100644 index 0000000..b102621 --- /dev/null +++ b/src/main/java/com/superbiz/agent/controller/DocumentController.java @@ -0,0 +1,56 @@ +package com.superbiz.agent.controller; + +import com.superbiz.agent.dto.DocumentUploadRequest; +import com.superbiz.agent.dto.Result; +import com.superbiz.agent.service.DocumentManagementService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文档管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/documents") +public class DocumentController { + + @Autowired + private DocumentManagementService documentManagementService; + + /** + * 上传文档 + */ + @PostMapping("/upload") + public Result uploadDocument( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "faultCategory", required = false) String faultCategory, + @RequestParam(value = "faultSource", required = false) String faultSource, + @RequestParam(value = "apiName", required = false) String apiName, + @RequestParam(value = "version", required = false) String version, + @RequestParam(value = "chunkSize", required = false, defaultValue = "500") Integer chunkSize, + @RequestParam(value = "chunkOverlap", required = false, defaultValue = "50") Integer chunkOverlap + ) { + try { + DocumentUploadRequest request = DocumentUploadRequest.builder() + .file(file) + .faultCategory(faultCategory) + .faultSource(faultSource) + .apiName(apiName) + .version(version) + .chunkSize(chunkSize) + .chunkOverlap(chunkOverlap) + .build(); + + String docId = documentManagementService.uploadDocument(request); + log.info("文档上传成功,docId: {}", docId); + + return Result.success(docId); + + } catch (Exception e) { + log.error("文档上传失败", e); + return Result.error(500, "文档上传失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/superbiz/agent/service/DocumentManagementService.java b/src/main/java/com/superbiz/agent/service/DocumentManagementService.java new file mode 100644 index 0000000..b40c8c5 --- /dev/null +++ b/src/main/java/com/superbiz/agent/service/DocumentManagementService.java @@ -0,0 +1,161 @@ +package com.superbiz.agent.service; + +import com.superbiz.agent.domain.entity.ApiDocument; +import com.superbiz.agent.domain.enums.FaultCategory; +import com.superbiz.agent.dto.DocumentChunk; +import com.superbiz.agent.dto.DocumentUploadRequest; +import com.superbiz.agent.exception.DocumentProcessException; +import com.superbiz.agent.repository.ApiDocumentRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * 文档管理服务 + */ +@Slf4j +@Service +public class DocumentManagementService { + + @Autowired + private TextExtractorService textExtractorService; + + @Autowired + private DocumentChunkService documentChunkService; + + @Autowired + private VectorIndexService vectorIndexService; + + @Autowired + private ApiDocumentRepository apiDocumentRepository; + + /** + * 上传文档 + * + * @param request 上传请求 + * @return 文档ID + */ + @Transactional + public String uploadDocument(DocumentUploadRequest request) { + MultipartFile file = request.getFile(); + String fileName = file.getOriginalFilename(); + + log.info("开始上传文档,文件名: {}, 大小: {} bytes", fileName, file.getSize()); + + // 1. 验证文件格式 + if (!textExtractorService.isSupportedFormat(fileName)) { + throw new DocumentProcessException( + fileName, "upload", + "不支持的文件格式,仅支持 .md 和 .txt" + ); + } + + // 2. 计算文件 hash(去重) + String fileHash = calculateFileHash(file); + Optional existing = apiDocumentRepository.findByFileHash(fileHash); + if (existing.isPresent()) { + log.warn("文档已存在,hash: {}, docId: ", fileHash, existing.get().getDocId()); + throw new DocumentProcessException( + fileName, "upload", + "文档已存在,docId: " + existing.get().getDocId() + ); + } + + // 3. 提取文本 + String text = textExtractorService.extractText(file, fileName); + if (text == null || text.isBlank()) { + throw new DocumentProcessException(fileName, "upload", "文档内容为空"); + } + + // 4. 分块(使用 DocumentChunkService 默认配置) + // 注意:chunkSize 和 overlap 参数由 DocumentChunkConfig 配置,暂不支持动态调整 + List chunks = documentChunkService.chunkDocument(text, fileName); + + if (chunks.isEmpty()) { + throw new DocumentProcessException(fileName, "upload", "文档分块失败"); + } + + log.info("文档分块完成,文件名: {}, 分块数: {}", fileName, chunks.size()); + + // 5. 创建文档元数据 + String docId = UUID.randomUUID().toString(); + ApiDocument document = ApiDocument.builder() + .docId(docId) + .fileName(fileName) + .faultCategory(parseFaultCategory(request.getFaultCategory())) + .faultSource(request.getFaultSource()) + .apiName(request.getApiName()) + .version(request.getVersion()) + .fileSize(file.getSize()) + .fileHash(fileHash) + .status("PROCESSING") + .chunkCount(chunks.size()) + .build(); + + apiDocumentRepository.save(document); + log.info("文档元数据已保存,docId: {}", docId); + + // 6. 向量化并索引(TODO: 待实现批量分块索引) + try { + // TODO: 实现 VectorIndexService.indexDocumentChunks(docId, chunks) + // 当前暂时标记为 INDEXED,后续补充实际向量化逻辑 + log.warn("向量化索引功能待实现,docId: {}", docId); + + document.setStatus("INDEXED"); + document.setIndexedAt(LocalDateTime.now()); + apiDocumentRepository.save(document); + log.info("文档元数据已创建(向量化待实现),docId: {}", docId); + + } catch (Exception e) { + log.error("文档处理失败,docId: {}", docId, e); + document.setStatus("FAILED"); + apiDocumentRepository.save(document); + throw new DocumentProcessException(docId, "process", "文档处理失败: " + e.getMessage(), e); + } + + return docId; + } + + /** + * 计算文件 hash(MD5) + */ + private String calculateFileHash(MultipartFile file) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(file.getBytes()); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new DocumentProcessException( + file.getOriginalFilename(), "hash", + "计算文件 hash 失败: " + e.getMessage(), e + ); + } + } + + /** + * 解析故障类别 + */ + private FaultCategory parseFaultCategory(String category) { + if (category == null || category.isBlank()) { + return FaultCategory.EXTERNAL_API; + } + try { + return FaultCategory.valueOf(category.toUpperCase()); + } catch (IllegalArgumentException e) { + return FaultCategory.EXTERNAL_API; + } + } +}