DocumentServiceImpl.java 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. package com.agent.management.service.impl;
  2. import com.agent.management.common.exception.BusinessException;
  3. import com.agent.management.config.KbProperties;
  4. import com.agent.management.model.entity.KbChunk;
  5. import com.agent.management.model.entity.KbDocument;
  6. import com.agent.management.repository.KbChunkRepository;
  7. import com.agent.management.repository.KbDocumentRepository;
  8. import com.agent.management.service.DocumentPipeline;
  9. import com.agent.management.service.DocumentService;
  10. import lombok.RequiredArgsConstructor;
  11. import lombok.extern.slf4j.Slf4j;
  12. import org.springframework.data.domain.Page;
  13. import org.springframework.data.domain.PageRequest;
  14. import org.springframework.stereotype.Service;
  15. import org.springframework.transaction.annotation.Transactional;
  16. import org.springframework.web.multipart.MultipartFile;
  17. import java.io.IOException;
  18. import java.nio.file.Files;
  19. import java.nio.file.Path;
  20. import java.nio.file.Paths;
  21. import java.nio.file.StandardCopyOption;
  22. import java.util.ArrayList;
  23. import java.util.HashMap;
  24. import java.util.List;
  25. import java.util.Map;
  26. import java.util.UUID;
  27. @Slf4j
  28. @Service
  29. @RequiredArgsConstructor
  30. @Transactional
  31. public class DocumentServiceImpl implements DocumentService {
  32. private final KbDocumentRepository documentRepository;
  33. private final KbChunkRepository chunkRepository;
  34. private final EmbeddingBridgeClient bridgeClient;
  35. private final DocumentPipeline pipeline;
  36. private final KbProperties kbProps;
  37. @Override
  38. public KbDocument upload(MultipartFile file, Long categoryId) {
  39. if (file == null || file.isEmpty()) {
  40. throw new BusinessException("上传文件为空");
  41. }
  42. if (file.getSize() > kbProps.getMaxFileSize()) {
  43. throw new BusinessException("文件大小超过限制(" + (kbProps.getMaxFileSize() / 1024 / 1024) + "MB)");
  44. }
  45. String mime = file.getContentType();
  46. if (mime == null || !kbProps.getAllowedMimeTypes().contains(mime)) {
  47. throw new BusinessException("不支持的文件类型:" + mime);
  48. }
  49. // 落盘
  50. Path uploadDir = Paths.get(kbProps.getUploadDir());
  51. try {
  52. Files.createDirectories(uploadDir);
  53. } catch (IOException e) {
  54. throw new BusinessException("创建上传目录失败:" + e.getMessage());
  55. }
  56. String storedName = UUID.randomUUID() + "_" + sanitizeFileName(file.getOriginalFilename());
  57. Path target = uploadDir.resolve(storedName);
  58. try {
  59. Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
  60. } catch (IOException e) {
  61. throw new BusinessException("文件保存失败:" + e.getMessage());
  62. }
  63. // 创建文档元数据
  64. KbDocument doc = new KbDocument();
  65. doc.setName(file.getOriginalFilename());
  66. doc.setCategoryId(categoryId);
  67. doc.setSourcePath(storedName);
  68. doc.setFileSize(file.getSize());
  69. doc.setMimeType(mime);
  70. doc.setStatus("PENDING");
  71. doc.setChunkCount(0);
  72. doc.setVectorCount(0);
  73. doc = documentRepository.save(doc);
  74. log.info("文档已上传,id={},name={},触发异步处理", doc.getId(), doc.getName());
  75. // 触发异步流水线(独立 bean,避免 @Async 自调用代理失效)
  76. pipeline.process(doc.getId());
  77. return doc;
  78. }
  79. @Override
  80. public PageResult list(Long categoryId, String keyword, int page, int size) {
  81. PageRequest pageable = PageRequest.of(Math.max(0, page - 1), Math.max(1, size));
  82. Page<KbDocument> p;
  83. boolean hasKw = keyword != null && !keyword.trim().isEmpty();
  84. if (categoryId != null && hasKw) {
  85. p = documentRepository.findByCategoryIdAndNameContainingIgnoreCase(categoryId, keyword.trim(), pageable);
  86. } else if (categoryId != null) {
  87. p = documentRepository.findByCategoryId(categoryId, pageable);
  88. } else if (hasKw) {
  89. p = documentRepository.findByNameContainingIgnoreCase(keyword.trim(), pageable);
  90. } else {
  91. p = documentRepository.findAll(pageable);
  92. }
  93. return new PageResult(p.getContent(), p.getTotalElements(), page, size);
  94. }
  95. @Override
  96. public KbDocument get(Long id) {
  97. return documentRepository.findById(id)
  98. .orElseThrow(() -> new BusinessException(404, "文档不存在"));
  99. }
  100. @Override
  101. public KbDocument revectorize(Long id) {
  102. KbDocument doc = get(id);
  103. // 清理旧向量
  104. try {
  105. bridgeClient.deleteByDocument(id);
  106. } catch (Exception e) {
  107. log.warn("清理旧向量失败(忽略,继续 revectorize): {}", e.getMessage());
  108. }
  109. chunkRepository.deleteByDocumentId(id);
  110. doc.setStatus("PENDING");
  111. doc.setErrorMessage(null);
  112. doc.setChunkCount(0);
  113. doc.setVectorCount(0);
  114. documentRepository.save(doc);
  115. pipeline.process(id);
  116. return doc;
  117. }
  118. @Override
  119. public KbDocument vectorizeDocument(Long id) {
  120. KbDocument doc = get(id);
  121. List<KbChunk> chunks = chunkRepository.findByDocumentIdOrderByChunkIndexAsc(id)
  122. .stream()
  123. .filter(c -> c.getChunkLevel() != null && c.getChunkLevel() == 3)
  124. .filter(c -> c.getVectorId() == null || c.getVectorId().isBlank())
  125. .toList();
  126. if (chunks.isEmpty()) {
  127. return doc;
  128. }
  129. List<String> ids = vectorizeChunksInternal(doc, chunks);
  130. refreshVectorCount(doc);
  131. log.info("文档向量化完成: doc={}, 新增向量={}", id, ids.size());
  132. return doc;
  133. }
  134. @Override
  135. public KbDocument vectorizeChunk(Long chunkId) {
  136. KbChunk chunk = chunkRepository.findById(chunkId)
  137. .orElseThrow(() -> new BusinessException(404, "分块不存在"));
  138. if (chunk.getVectorId() != null && !chunk.getVectorId().isBlank()) {
  139. throw new BusinessException("该分块已向量化,请勿重复操作");
  140. }
  141. if (chunk.getChunkLevel() == null || chunk.getChunkLevel() != 3) {
  142. throw new BusinessException("仅叶子分块(L3)支持向量化");
  143. }
  144. KbDocument doc = get(chunk.getDocumentId());
  145. List<String> ids = vectorizeChunksInternal(doc, List.of(chunk));
  146. refreshVectorCount(doc);
  147. log.info("分块向量化完成: chunkId={}, vectorId={}", chunkId, ids.isEmpty() ? null : ids.get(0));
  148. return doc;
  149. }
  150. private List<String> vectorizeChunksInternal(KbDocument doc, List<KbChunk> chunks) {
  151. if (chunks.isEmpty()) return List.of();
  152. List<Map<String, Object>> chunkMaps = new ArrayList<>(chunks.size());
  153. for (KbChunk c : chunks) {
  154. Map<String, Object> map = new HashMap<>();
  155. map.put("chunk_id", c.getChunkId());
  156. map.put("text", c.getContent());
  157. map.put("chunk_level", c.getChunkLevel());
  158. map.put("parent_chunk_id", c.getParentChunkId());
  159. map.put("root_chunk_id", c.getRootChunkId());
  160. map.put("chunk_idx", c.getChunkIndex());
  161. chunkMaps.add(map);
  162. }
  163. List<String> vectorIds = bridgeClient.vectorizeChunks(
  164. doc.getId(), doc.getName(),
  165. doc.getMimeType() == null ? "" : doc.getMimeType(),
  166. doc.getSourcePath() == null ? "" : doc.getSourcePath(),
  167. chunkMaps);
  168. for (int i = 0; i < chunks.size() && i < vectorIds.size(); i++) {
  169. chunks.get(i).setVectorId(vectorIds.get(i));
  170. }
  171. chunkRepository.saveAll(chunks);
  172. return vectorIds;
  173. }
  174. private void refreshVectorCount(KbDocument doc) {
  175. long count = chunkRepository.countByDocumentIdAndVectorIdIsNotNull(doc.getId());
  176. doc.setVectorCount((int) count);
  177. documentRepository.save(doc);
  178. }
  179. @Override
  180. public void delete(Long id) {
  181. KbDocument doc = get(id);
  182. // 删除 Milvus 向量(按 document_id)
  183. try {
  184. bridgeClient.deleteByDocument(id);
  185. } catch (Exception e) {
  186. log.warn("删除向量失败(继续删除 chunks 与文件): {}", e.getMessage());
  187. }
  188. chunkRepository.deleteByDocumentId(id);
  189. // 删除源文件
  190. try {
  191. Path target = Paths.get(kbProps.getUploadDir()).resolve(doc.getSourcePath());
  192. Files.deleteIfExists(target);
  193. } catch (IOException e) {
  194. log.warn("删除源文件失败: {}", e.getMessage());
  195. }
  196. documentRepository.delete(doc);
  197. }
  198. @Override
  199. public KbDocument move(Long id, Long categoryId) {
  200. KbDocument doc = get(id);
  201. doc.setCategoryId(categoryId);
  202. return documentRepository.save(doc);
  203. }
  204. @Override
  205. public List<ChunkView> listChunks(Long documentId) {
  206. if (!documentRepository.existsById(documentId)) {
  207. throw new BusinessException(404, "文档不存在");
  208. }
  209. List<KbChunk> chunks = chunkRepository.findByDocumentIdOrderByChunkIndexAsc(documentId);
  210. List<ChunkView> views = new ArrayList<>(chunks.size());
  211. for (KbChunk c : chunks) {
  212. String preview = c.getContent() == null ? "" :
  213. c.getContent().length() > 200 ? c.getContent().substring(0, 200) + "..." : c.getContent();
  214. views.add(new ChunkView(c.getId(), c.getChunkIndex(), c.getChunkLevel(), c.getCharCount(), preview, c.getContent(), c.getVectorId()));
  215. }
  216. return views;
  217. }
  218. private String sanitizeFileName(String name) {
  219. if (name == null) return "unknown";
  220. return name.replaceAll("[\\\\/]", "_").replaceAll("\\.\\.", "_");
  221. }
  222. }