StructuredDocumentService.java 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. package com.lingyue.document.service;
  2. import com.lingyue.common.exception.ServiceException;
  3. import com.lingyue.document.dto.StructuredDocumentDTO;
  4. import com.lingyue.document.dto.StructuredDocumentDTO.*;
  5. import com.lingyue.document.entity.Document;
  6. import com.lingyue.document.entity.DocumentBlock;
  7. import com.lingyue.document.entity.DocumentBlock.TextElement;
  8. import com.lingyue.document.entity.DocumentElement;
  9. import com.lingyue.document.repository.DocumentBlockRepository;
  10. import com.lingyue.document.repository.DocumentRepository;
  11. import lombok.RequiredArgsConstructor;
  12. import lombok.extern.slf4j.Slf4j;
  13. import org.springframework.stereotype.Service;
  14. import org.springframework.transaction.annotation.Transactional;
  15. import java.util.*;
  16. import java.util.stream.Collectors;
  17. /**
  18. * 结构化文档服务(参考飞书设计)
  19. *
  20. * 核心设计:
  21. * - 文档由 Block 树组成,每个 Block 包含 elements 数组
  22. * - 实体作为 TextElement(type=entity)嵌入块中
  23. * - 编辑时修改 elements 数组,无需处理字符偏移
  24. *
  25. * @author lingyue
  26. * @since 2026-01-21
  27. */
  28. @Slf4j
  29. @Service
  30. @RequiredArgsConstructor
  31. public class StructuredDocumentService {
  32. private final DocumentRepository documentRepository;
  33. private final DocumentBlockRepository blockRepository;
  34. private final DocumentElementService documentElementService;
  35. /**
  36. * 获取结构化文档(用于编辑器渲染)
  37. */
  38. public StructuredDocumentDTO getStructuredDocument(String documentId) {
  39. // 1. 获取文档基本信息
  40. Document document = documentRepository.selectById(documentId);
  41. if (document == null) {
  42. return null;
  43. }
  44. // 2. 获取所有块
  45. List<DocumentBlock> blocks = blockRepository.findByDocumentId(documentId);
  46. // 3. 构建块 DTO 列表
  47. List<BlockDTO> blockDTOs = blocks.stream()
  48. .map(this::buildBlockDTO)
  49. .collect(Collectors.toList());
  50. // 4. 统计实体
  51. EntityStats stats = buildEntityStats(blocks);
  52. // 5. 获取图片列表
  53. List<ImageDTO> images = buildImageList(documentId);
  54. // 6. 获取段落列表(包含格式信息)
  55. List<StructuredDocumentDTO.ParagraphDTO> paragraphs = buildParagraphList(documentId);
  56. return StructuredDocumentDTO.builder()
  57. .documentId(documentId)
  58. .revision(1) // TODO: 实现版本控制
  59. .title(document.getName())
  60. .status(document.getStatus())
  61. .blocks(blockDTOs)
  62. .images(images)
  63. .paragraphs(paragraphs)
  64. .entityStats(stats)
  65. .updatedAt(document.getUpdateTime())
  66. .build();
  67. }
  68. /**
  69. * 构建图片列表(从 document_elements 表获取)
  70. */
  71. private List<ImageDTO> buildImageList(String documentId) {
  72. List<DocumentElement> imageElements = documentElementService.getImagesByDocumentId(documentId);
  73. return imageElements.stream()
  74. .map(el -> ImageDTO.builder()
  75. .index(el.getElementIndex())
  76. .url(el.getImageUrl())
  77. .alt(el.getImageAlt())
  78. .width(el.getImageWidth())
  79. .height(el.getImageHeight())
  80. .format(el.getImageFormat())
  81. .build())
  82. .collect(Collectors.toList());
  83. }
  84. /**
  85. * 构建段落列表(从 document_elements 表获取,包含格式信息)
  86. */
  87. private List<StructuredDocumentDTO.ParagraphDTO> buildParagraphList(String documentId) {
  88. List<DocumentElement> elements = documentElementService.getElementsByDocumentId(documentId);
  89. return elements.stream()
  90. .filter(el -> el.getElementType() != null && !el.getElementType().equals("image") && !el.getElementType().equals("table"))
  91. .map(this::convertToParagraphDTO)
  92. .collect(Collectors.toList());
  93. }
  94. /**
  95. * 将 DocumentElement 转换为 ParagraphDTO
  96. */
  97. @SuppressWarnings("unchecked")
  98. private StructuredDocumentDTO.ParagraphDTO convertToParagraphDTO(DocumentElement el) {
  99. List<StructuredDocumentDTO.TextRunDTO> runDTOs = null;
  100. if (el.getRuns() != null && !el.getRuns().isEmpty()) {
  101. runDTOs = el.getRuns().stream()
  102. .map(run -> StructuredDocumentDTO.TextRunDTO.builder()
  103. .text((String) run.get("text"))
  104. .fontFamily((String) run.get("fontFamily"))
  105. .fontSize(run.get("fontSize") instanceof Number ? ((Number) run.get("fontSize")).doubleValue() : null)
  106. .bold((Boolean) run.get("bold"))
  107. .italic((Boolean) run.get("italic"))
  108. .underline((String) run.get("underline"))
  109. .color((String) run.get("color"))
  110. .strikeThrough((Boolean) run.get("strikeThrough"))
  111. .verticalAlign((String) run.get("verticalAlign"))
  112. .highlightColor((String) run.get("highlightColor"))
  113. .build())
  114. .collect(Collectors.toList());
  115. }
  116. return StructuredDocumentDTO.ParagraphDTO.builder()
  117. .index(el.getElementIndex())
  118. .type(el.getElementType())
  119. .content(el.getContent())
  120. .style(el.getStyle())
  121. .runs(runDTOs)
  122. .build();
  123. }
  124. /**
  125. * 构建块 DTO
  126. */
  127. private BlockDTO buildBlockDTO(DocumentBlock block) {
  128. return BlockDTO.builder()
  129. .id(block.getId())
  130. .parentId(block.getParentId())
  131. .children(block.getChildren())
  132. .index(block.getBlockIndex())
  133. .type(block.getBlockType())
  134. .elements(block.getElements())
  135. .plainText(block.getPlainText())
  136. .html(block.toHtml())
  137. .markedHtml(block.toMarkedHtml())
  138. .build();
  139. }
  140. /**
  141. * 构建实体统计(从块的 elements 中提取)
  142. */
  143. private EntityStats buildEntityStats(List<DocumentBlock> blocks) {
  144. int total = 0;
  145. int confirmed = 0;
  146. Map<String, Integer> byType = new HashMap<>();
  147. for (DocumentBlock block : blocks) {
  148. if (block.getElements() == null) continue;
  149. for (TextElement el : block.getElements()) {
  150. if ("entity".equals(el.getType())) {
  151. total++;
  152. if (Boolean.TRUE.equals(el.getConfirmed())) {
  153. confirmed++;
  154. }
  155. String entityType = el.getEntityType();
  156. if (entityType != null) {
  157. byType.merge(entityType, 1, Integer::sum);
  158. }
  159. }
  160. }
  161. }
  162. return EntityStats.builder()
  163. .total(total)
  164. .confirmed(confirmed)
  165. .byType(byType)
  166. .build();
  167. }
  168. // ==================== 块操作 ====================
  169. /**
  170. * 更新块的 elements
  171. */
  172. @Transactional
  173. public void updateBlockElements(String blockId, List<TextElement> elements) {
  174. DocumentBlock block = blockRepository.selectById(blockId);
  175. if (block == null) {
  176. throw new ServiceException("块不存在: " + blockId);
  177. }
  178. block.setElements(elements);
  179. block.setUpdateTime(new Date());
  180. blockRepository.updateById(block);
  181. log.info("更新块元素: blockId={}, elementCount={}", blockId, elements.size());
  182. }
  183. /**
  184. * 在块内添加实体(将文本片段转为实体元素)
  185. *
  186. * @param blockId 块ID
  187. * @param elementIndex 要转换的元素索引
  188. * @param startOffset 在该元素文本中的起始位置
  189. * @param endOffset 在该元素文本中的结束位置
  190. * @param entityType 实体类型
  191. * @return 新创建的实体ID
  192. */
  193. @Transactional
  194. public String markEntity(String blockId, int elementIndex, int startOffset, int endOffset, String entityType) {
  195. DocumentBlock block = blockRepository.selectById(blockId);
  196. if (block == null || block.getElements() == null) {
  197. throw new ServiceException("块不存在或没有内容");
  198. }
  199. List<TextElement> elements = new ArrayList<>(block.getElements());
  200. if (elementIndex >= elements.size()) {
  201. throw new ServiceException("元素索引越界");
  202. }
  203. TextElement targetElement = elements.get(elementIndex);
  204. if (!"text_run".equals(targetElement.getType()) || targetElement.getContent() == null) {
  205. throw new ServiceException("只能在文本元素上标记实体");
  206. }
  207. String content = targetElement.getContent();
  208. if (startOffset < 0 || endOffset > content.length() || startOffset >= endOffset) {
  209. throw new ServiceException("偏移量无效");
  210. }
  211. String entityId = UUID.randomUUID().toString().replace("-", "");
  212. String entityText = content.substring(startOffset, endOffset);
  213. // 拆分元素:前段文本 + 实体 + 后段文本
  214. List<TextElement> newElements = new ArrayList<>();
  215. // 前段文本
  216. if (startOffset > 0) {
  217. TextElement before = new TextElement();
  218. before.setType("text_run");
  219. before.setContent(content.substring(0, startOffset));
  220. before.setStyle(targetElement.getStyle());
  221. newElements.add(before);
  222. }
  223. // 实体元素
  224. TextElement entity = new TextElement();
  225. entity.setType("entity");
  226. entity.setEntityId(entityId);
  227. entity.setEntityText(entityText);
  228. entity.setEntityType(entityType);
  229. entity.setConfirmed(true); // 手动标记的直接确认
  230. newElements.add(entity);
  231. // 后段文本
  232. if (endOffset < content.length()) {
  233. TextElement after = new TextElement();
  234. after.setType("text_run");
  235. after.setContent(content.substring(endOffset));
  236. after.setStyle(targetElement.getStyle());
  237. newElements.add(after);
  238. }
  239. // 替换原元素
  240. elements.remove(elementIndex);
  241. elements.addAll(elementIndex, newElements);
  242. block.setElements(elements);
  243. block.setUpdateTime(new Date());
  244. blockRepository.updateById(block);
  245. log.info("标记实体: blockId={}, entityId={}, text={}, type={}", blockId, entityId, entityText, entityType);
  246. return entityId;
  247. }
  248. /**
  249. * 删除实体标记(将实体元素还原为文本)
  250. */
  251. @Transactional
  252. public void unmarkEntity(String blockId, String entityId) {
  253. DocumentBlock block = blockRepository.selectById(blockId);
  254. if (block == null || block.getElements() == null) {
  255. return;
  256. }
  257. List<TextElement> elements = new ArrayList<>(block.getElements());
  258. for (int i = 0; i < elements.size(); i++) {
  259. TextElement el = elements.get(i);
  260. if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
  261. // 将实体还原为文本
  262. TextElement textEl = new TextElement();
  263. textEl.setType("text_run");
  264. textEl.setContent(el.getEntityText());
  265. elements.set(i, textEl);
  266. break;
  267. }
  268. }
  269. // 合并相邻的文本元素
  270. elements = mergeAdjacentTextRuns(elements);
  271. block.setElements(elements);
  272. block.setUpdateTime(new Date());
  273. blockRepository.updateById(block);
  274. log.info("取消实体标记: blockId={}, entityId={}", blockId, entityId);
  275. }
  276. /**
  277. * 更新实体类型
  278. */
  279. @Transactional
  280. public void updateEntityType(String blockId, String entityId, String newType) {
  281. DocumentBlock block = blockRepository.selectById(blockId);
  282. if (block == null || block.getElements() == null) {
  283. return;
  284. }
  285. for (TextElement el : block.getElements()) {
  286. if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
  287. el.setEntityType(newType);
  288. break;
  289. }
  290. }
  291. block.setUpdateTime(new Date());
  292. blockRepository.updateById(block);
  293. }
  294. /**
  295. * 确认实体
  296. */
  297. @Transactional
  298. public void confirmEntity(String blockId, String entityId) {
  299. DocumentBlock block = blockRepository.selectById(blockId);
  300. if (block == null || block.getElements() == null) {
  301. return;
  302. }
  303. for (TextElement el : block.getElements()) {
  304. if ("entity".equals(el.getType()) && entityId.equals(el.getEntityId())) {
  305. el.setConfirmed(true);
  306. break;
  307. }
  308. }
  309. block.setUpdateTime(new Date());
  310. blockRepository.updateById(block);
  311. }
  312. /**
  313. * 合并相邻的文本元素
  314. */
  315. private List<TextElement> mergeAdjacentTextRuns(List<TextElement> elements) {
  316. if (elements.size() <= 1) {
  317. return elements;
  318. }
  319. List<TextElement> merged = new ArrayList<>();
  320. TextElement current = null;
  321. for (TextElement el : elements) {
  322. if ("text_run".equals(el.getType())) {
  323. if (current == null) {
  324. current = new TextElement();
  325. current.setType("text_run");
  326. current.setContent(el.getContent());
  327. current.setStyle(el.getStyle());
  328. } else {
  329. // 合并文本
  330. current.setContent(current.getContent() + el.getContent());
  331. }
  332. } else {
  333. if (current != null) {
  334. merged.add(current);
  335. current = null;
  336. }
  337. merged.add(el);
  338. }
  339. }
  340. if (current != null) {
  341. merged.add(current);
  342. }
  343. return merged;
  344. }
  345. // ==================== 块增删操作 ====================
  346. /**
  347. * 创建新块
  348. */
  349. @Transactional
  350. public DocumentBlock createBlock(String documentId, String parentId, int index,
  351. String blockType, List<TextElement> elements) {
  352. DocumentBlock block = new DocumentBlock();
  353. block.setId(UUID.randomUUID().toString().replace("-", ""));
  354. block.setDocumentId(documentId);
  355. block.setParentId(parentId);
  356. block.setBlockIndex(index);
  357. block.setBlockType(blockType);
  358. block.setElements(elements);
  359. block.setCreateTime(new Date());
  360. block.setUpdateTime(new Date());
  361. blockRepository.insert(block);
  362. // 更新父块的 children
  363. if (parentId != null) {
  364. DocumentBlock parent = blockRepository.selectById(parentId);
  365. if (parent != null) {
  366. List<String> children = parent.getChildren();
  367. if (children == null) {
  368. children = new ArrayList<>();
  369. }
  370. children.add(block.getId());
  371. parent.setChildren(children);
  372. blockRepository.updateById(parent);
  373. }
  374. }
  375. log.info("创建块: documentId={}, blockId={}, type={}", documentId, block.getId(), blockType);
  376. return block;
  377. }
  378. /**
  379. * 删除块
  380. */
  381. @Transactional
  382. public void deleteBlock(String blockId) {
  383. DocumentBlock block = blockRepository.selectById(blockId);
  384. if (block == null) {
  385. return;
  386. }
  387. // 递归删除子块
  388. if (block.getChildren() != null) {
  389. for (String childId : block.getChildren()) {
  390. deleteBlock(childId);
  391. }
  392. }
  393. // 从父块的 children 中移除
  394. if (block.getParentId() != null) {
  395. DocumentBlock parent = blockRepository.selectById(block.getParentId());
  396. if (parent != null && parent.getChildren() != null) {
  397. parent.getChildren().remove(block.getId());
  398. blockRepository.updateById(parent);
  399. }
  400. }
  401. blockRepository.deleteById(blockId);
  402. log.info("删除块: blockId={}", blockId);
  403. }
  404. // ==================== 批量操作 ====================
  405. /**
  406. * 批量保存文档块(用于 NER 完成后生成结构化文档)
  407. *
  408. * @param documentId 文档ID
  409. * @param blocks 块列表(来自 DocumentBlockGeneratorService)
  410. * @return 保存的块数量
  411. */
  412. @Transactional
  413. public int saveBlocksBatch(String documentId, List<Map<String, Object>> blocks) {
  414. if (blocks == null || blocks.isEmpty()) {
  415. log.warn("批量保存块: 块列表为空, documentId={}", documentId);
  416. return 0;
  417. }
  418. // 先删除该文档的旧块
  419. int deleted = blockRepository.deleteByDocumentId(documentId);
  420. if (deleted > 0) {
  421. log.info("删除旧块: documentId={}, count={}", documentId, deleted);
  422. }
  423. // 保存新块
  424. int savedCount = 0;
  425. for (Map<String, Object> blockMap : blocks) {
  426. try {
  427. DocumentBlock block = convertMapToBlock(blockMap);
  428. block.setDocumentId(documentId);
  429. block.setCreateTime(new Date());
  430. block.setUpdateTime(new Date());
  431. blockRepository.insert(block);
  432. savedCount++;
  433. } catch (Exception e) {
  434. log.error("保存块失败: documentId={}, block={}, error={}",
  435. documentId, blockMap, e.getMessage());
  436. }
  437. }
  438. log.info("批量保存块完成: documentId={}, savedCount={}", documentId, savedCount);
  439. return savedCount;
  440. }
  441. /**
  442. * 将 Map 转换为 DocumentBlock
  443. */
  444. @SuppressWarnings("unchecked")
  445. private DocumentBlock convertMapToBlock(Map<String, Object> blockMap) {
  446. DocumentBlock block = new DocumentBlock();
  447. block.setId((String) blockMap.get("blockId"));
  448. block.setDocumentId((String) blockMap.get("documentId"));
  449. block.setParentId((String) blockMap.get("parentId"));
  450. block.setBlockIndex(getIntValue(blockMap, "blockIndex", 0));
  451. block.setBlockType((String) blockMap.get("blockType"));
  452. // 转换 children
  453. Object childrenObj = blockMap.get("children");
  454. if (childrenObj instanceof List) {
  455. block.setChildren((List<String>) childrenObj);
  456. }
  457. // 转换 elements
  458. Object elementsObj = blockMap.get("elements");
  459. if (elementsObj instanceof List) {
  460. List<Map<String, Object>> elementMaps = (List<Map<String, Object>>) elementsObj;
  461. List<TextElement> elements = new ArrayList<>();
  462. for (Map<String, Object> elMap : elementMaps) {
  463. TextElement el = new TextElement();
  464. el.setType((String) elMap.get("type"));
  465. el.setContent((String) elMap.get("content"));
  466. el.setEntityId((String) elMap.get("entityId"));
  467. el.setEntityText((String) elMap.get("entityText"));
  468. el.setEntityType((String) elMap.get("entityType"));
  469. el.setConfirmed((Boolean) elMap.get("confirmed"));
  470. el.setUrl((String) elMap.get("url"));
  471. el.setRefDocId((String) elMap.get("refDocId"));
  472. el.setRefDocTitle((String) elMap.get("refDocTitle"));
  473. elements.add(el);
  474. }
  475. block.setElements(elements);
  476. }
  477. return block;
  478. }
  479. private int getIntValue(Map<String, Object> map, String key, int defaultValue) {
  480. Object value = map.get(key);
  481. if (value instanceof Number) {
  482. return ((Number) value).intValue();
  483. }
  484. return defaultValue;
  485. }
  486. }