From 5f3f35c53bee88625f1c90734d4339677de30f18 Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Sat, 20 Jun 2026 10:33:14 +0800 Subject: [PATCH] feat: parse uploaded review mind maps --- .../controller/ReviewController.java | 13 + .../entity/ReviewMindMapEntity.java | 28 ++ .../enums/ReviewMindMapFileFormatEnum.java | 40 +++ .../enums/ReviewMindMapParseStatusEnum.java | 15 + .../service/ReviewService.java | 8 + .../service/impl/MindMapFileParser.java | 314 ++++++++++++++++++ .../service/impl/ReviewServiceImpl.java | 84 +++++ ...tend_review_mind_maps_for_file_parsing.sql | 26 ++ .../service/impl/ReviewServiceImplTest.java | 42 +++ 9 files changed, 570 insertions(+) create mode 100644 src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapFileFormatEnum.java create mode 100644 src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapParseStatusEnum.java create mode 100644 src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java create mode 100644 src/main/resources/db/migration/V20260620_2__extend_review_mind_maps_for_file_parsing.sql diff --git a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java index e36e998..193a061 100644 --- a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java +++ b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java @@ -14,9 +14,12 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.service.ReviewService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; /** @@ -133,4 +136,14 @@ public class ReviewController { @Valid @RequestBody UpsertReviewMindMapRequest request) throws NotFindEntitiesException { return CommonResult.success(reviewService.upsertMindMap(taskNum, request)); } + + /** + * 上传并解析指定任务的思维导图文件 + */ + @PostMapping(value = "/mind-map/{taskNum}/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResult uploadMindMap( + @PathVariable String taskNum, + @RequestParam("file") MultipartFile file) throws NotFindEntitiesException, IOException { + return CommonResult.success(reviewService.uploadMindMap(taskNum, file)); + } } diff --git a/src/main/java/com/guo/learningprogresstracker/entity/ReviewMindMapEntity.java b/src/main/java/com/guo/learningprogresstracker/entity/ReviewMindMapEntity.java index c92d2e4..7547c24 100644 --- a/src/main/java/com/guo/learningprogresstracker/entity/ReviewMindMapEntity.java +++ b/src/main/java/com/guo/learningprogresstracker/entity/ReviewMindMapEntity.java @@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; +import java.time.LocalDateTime; @TableName(value = "review_mind_maps") @Data @@ -27,6 +28,33 @@ public class ReviewMindMapEntity extends BaseEntity implements Serializable { @TableField(value = "content_format") private String contentFormat; + @TableField(value = "source_type") + private String sourceType; + + @TableField(value = "file_name") + private String fileName; + + @TableField(value = "file_path") + private String filePath; + + @TableField(value = "file_format") + private String fileFormat; + + @TableField(value = "parsed_content") + private String parsedContent; + + @TableField(value = "parse_status") + private String parseStatus; + + @TableField(value = "parse_error") + private String parseError; + + @TableField(value = "summary") + private String summary; + + @TableField(value = "last_parsed_time") + private LocalDateTime lastParsedTime; + @TableField(exist = false) private static final long serialVersionUID = 1L; } diff --git a/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapFileFormatEnum.java b/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapFileFormatEnum.java new file mode 100644 index 0000000..391f095 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapFileFormatEnum.java @@ -0,0 +1,40 @@ +package com.guo.learningprogresstracker.enums; + +import lombok.AllArgsConstructor; + +import java.util.Locale; + +@AllArgsConstructor +public enum ReviewMindMapFileFormatEnum { + XMIND("XMIND"), + MARKDOWN("MARKDOWN"), + OPML("OPML"), + FREEMIND("FREEMIND"), + TEXT("TEXT"); + + private final String code; + + public String getCode() { + return code; + } + + public static ReviewMindMapFileFormatEnum fromFileName(String fileName) { + String normalized = fileName == null ? "" : fileName.toLowerCase(Locale.ROOT); + if (normalized.endsWith(".xmind")) { + return XMIND; + } + if (normalized.endsWith(".md") || normalized.endsWith(".markdown")) { + return MARKDOWN; + } + if (normalized.endsWith(".opml")) { + return OPML; + } + if (normalized.endsWith(".mm")) { + return FREEMIND; + } + if (normalized.endsWith(".txt")) { + return TEXT; + } + return TEXT; + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapParseStatusEnum.java b/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapParseStatusEnum.java new file mode 100644 index 0000000..c3687df --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/enums/ReviewMindMapParseStatusEnum.java @@ -0,0 +1,15 @@ +package com.guo.learningprogresstracker.enums; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum ReviewMindMapParseStatusEnum { + SUCCESS("SUCCESS"), + FAILED("FAILED"); + + private final String code; + + public String getCode() { + return code; + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java b/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java index d64a0d3..59c9d33 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java +++ b/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java @@ -10,6 +10,9 @@ import com.guo.learningprogresstracker.entity.ReviewMindMapEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.exception.NotFindEntitiesException; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; import java.util.List; @@ -77,4 +80,9 @@ public interface ReviewService { * 创建或更新指定任务的思维导图 */ ReviewMindMapEntity upsertMindMap(String taskNum, UpsertReviewMindMapRequest request) throws NotFindEntitiesException; + + /** + * 上传并解析指定任务的思维导图文件 + */ + ReviewMindMapEntity uploadMindMap(String taskNum, MultipartFile file) throws NotFindEntitiesException, IOException; } diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java b/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java new file mode 100644 index 0000000..01eb4e1 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java @@ -0,0 +1,314 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; +import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +class MindMapFileParser { + + private final ObjectMapper objectMapper; + + MindMapFileParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + ParseResult parse(MultipartFile file, ReviewMindMapFileFormatEnum format) throws IOException { + byte[] bytes = file.getBytes(); + Map root = switch (format) { + case XMIND -> parseXMind(bytes); + case MARKDOWN -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format); + case OPML -> parseOutlineXml(bytes, "text", format); + case FREEMIND -> parseOutlineXml(bytes, "TEXT", format); + case TEXT -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format); + }; + String title = String.valueOf(root.getOrDefault("title", file.getOriginalFilename())); + String outline = toOutline(root); + int nodeCount = countNodes(root); + int depth = maxDepth(root); + String summary = "共解析 " + nodeCount + " 个节点,最大层级 " + depth + "。"; + return new ParseResult(title, format.getCode(), objectMapper.writeValueAsString(root), outline, summary); + } + + private Map parseXMind(byte[] bytes) throws IOException { + byte[] contentJson = null; + byte[] contentXml = null; + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(bytes))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if ("content.json".equals(entry.getName())) { + contentJson = zipInputStream.readAllBytes(); + } else if ("content.xml".equals(entry.getName())) { + contentXml = zipInputStream.readAllBytes(); + } + } + } + if (contentJson != null) { + return parseXMindJson(contentJson); + } + if (contentXml != null) { + return parseXMindXml(contentXml); + } + throw new IOException("未找到 XMind 内容文件"); + } + + private Map parseXMindJson(byte[] bytes) throws IOException { + JsonNode sheets = objectMapper.readTree(bytes); + JsonNode sheet = sheets.isArray() && !sheets.isEmpty() ? sheets.get(0) : sheets; + JsonNode rootTopic = sheet.path("rootTopic"); + Map root = node(rootTopic.path("title").asText("XMind 导图")); + root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode()); + root.put("nodes", parseXMindTopicChildren(rootTopic)); + return root; + } + + private List> parseXMindTopicChildren(JsonNode topic) { + List> result = new ArrayList<>(); + JsonNode attached = topic.path("children").path("attached"); + if (!attached.isArray()) { + return result; + } + for (JsonNode child : attached) { + Map item = node(child.path("title").asText("未命名节点")); + item.put("children", parseXMindTopicChildren(child)); + result.add(item); + } + return result; + } + + private Map parseXMindXml(byte[] bytes) throws IOException { + Document document = xmlDocument(bytes); + Element sheet = firstElementByName(document.getDocumentElement(), "sheet"); + Element topic = sheet == null ? firstElementByName(document.getDocumentElement(), "topic") : firstElementByName(sheet, "topic"); + if (topic == null) { + throw new IOException("未找到 XMind 根节点"); + } + Map root = parseTopicElement(topic); + root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode()); + root.put("nodes", root.remove("children")); + return root; + } + + private Map parseMarkdown(String content, ReviewMindMapFileFormatEnum format) { + Map root = node("导图大纲"); + root.put("sourceFormat", format.getCode()); + List> roots = new ArrayList<>(); + root.put("nodes", roots); + + ArrayDeque stack = new ArrayDeque<>(); + stack.push(new StackItem(0, roots)); + for (String rawLine : content.split("\\R")) { + String line = rawLine.stripTrailing(); + if (line.isBlank()) { + continue; + } + int level = markdownLevel(line); + String title = markdownTitle(line); + Map item = node(title); + while (stack.peek().level >= level) { + stack.pop(); + } + stack.peek().children.add(item); + @SuppressWarnings("unchecked") + List> children = (List>) item.get("children"); + stack.push(new StackItem(level, children)); + } + if (!roots.isEmpty()) { + root.put("title", roots.get(0).get("title")); + } + return root; + } + + private Map parseOutlineXml(byte[] bytes, String titleAttribute, ReviewMindMapFileFormatEnum format) throws IOException { + Document document = xmlDocument(bytes); + Element rootElement = document.getDocumentElement(); + Element first = firstElementByName(rootElement, "outline"); + if (first == null) { + first = firstElementByName(rootElement, "node"); + titleAttribute = "TEXT"; + } + if (first == null) { + throw new IOException("未找到导图节点"); + } + Map root = parseOutlineElement(first, titleAttribute); + root.put("sourceFormat", format.getCode()); + root.put("nodes", root.remove("children")); + return root; + } + + private Document xmlDocument(byte[] bytes) throws IOException { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); + } catch (Exception e) { + throw new IOException("导图 XML 解析失败", e); + } + } + + private Map parseTopicElement(Element element) { + Map item = node(textOfFirstChild(element, "title", "未命名节点")); + item.put("children", childTopicElements(element).stream() + .map(this::parseTopicElement) + .toList()); + return item; + } + + private Map parseOutlineElement(Element element, String titleAttribute) { + String title = element.getAttribute(titleAttribute); + if (title == null || title.isBlank()) { + title = element.getAttribute("text"); + } + if (title == null || title.isBlank()) { + title = "未命名节点"; + } + Map item = node(title); + List> children = directChildElements(element).stream() + .filter(child -> "outline".equalsIgnoreCase(child.getTagName()) || "node".equalsIgnoreCase(child.getTagName())) + .map(child -> parseOutlineElement(child, titleAttribute)) + .toList(); + item.put("children", children); + return item; + } + + private Map node(String title) { + Map node = new LinkedHashMap<>(); + node.put("title", title); + node.put("notes", ""); + node.put("children", new ArrayList>()); + return node; + } + + private int markdownLevel(String line) { + String trimmed = line.stripLeading(); + if (trimmed.startsWith("#")) { + int count = 0; + while (count < trimmed.length() && trimmed.charAt(count) == '#') { + count++; + } + return count; + } + int indent = line.length() - trimmed.length(); + return 1 + indent / 2; + } + + private String markdownTitle(String line) { + return line.strip() + .replaceFirst("^#{1,6}\\s*", "") + .replaceFirst("^[-*+]\\s*", "") + .replaceFirst("^\\d+\\.\\s*", ""); + } + + private List childTopicElements(Element element) { + return directChildElements(element).stream() + .flatMap(child -> { + if ("topic".equalsIgnoreCase(child.getTagName())) { + return List.of(child).stream(); + } + return childTopicElements(child).stream(); + }) + .toList(); + } + + private Element firstElementByName(Element element, String name) { + if (name.equalsIgnoreCase(element.getTagName())) { + return element; + } + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + if (nodes.item(i) instanceof Element child) { + Element found = firstElementByName(child, name); + if (found != null) { + return found; + } + } + } + return null; + } + + private List directChildElements(Element element) { + List result = new ArrayList<>(); + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + if (nodes.item(i) instanceof Element child) { + result.add(child); + } + } + return result; + } + + private String textOfFirstChild(Element element, String name, String fallback) { + for (Element child : directChildElements(element)) { + if (name.equalsIgnoreCase(child.getTagName())) { + return child.getTextContent(); + } + } + return fallback; + } + + private String toOutline(Map root) { + StringBuilder builder = new StringBuilder(); + appendOutline(builder, root, 0); + return builder.toString(); + } + + @SuppressWarnings("unchecked") + private void appendOutline(StringBuilder builder, Map node, int level) { + builder.append(" ".repeat(level)).append("- ").append(node.get("title")).append('\n'); + Object children = node.get(level == 0 && node.containsKey("nodes") ? "nodes" : "children"); + if (children instanceof List list) { + for (Object child : list) { + appendOutline(builder, (Map) child, level + 1); + } + } + } + + @SuppressWarnings("unchecked") + private int countNodes(Map node) { + int count = 1; + Object children = node.get(node.containsKey("nodes") ? "nodes" : "children"); + if (children instanceof List list) { + for (Object child : list) { + count += countNodes((Map) child); + } + } + return count; + } + + @SuppressWarnings("unchecked") + private int maxDepth(Map node) { + Object children = node.get(node.containsKey("nodes") ? "nodes" : "children"); + if (!(children instanceof List list) || list.isEmpty()) { + return 1; + } + int max = 1; + for (Object child : list) { + max = Math.max(max, 1 + maxDepth((Map) child)); + } + return max; + } + + record ParseResult(String title, String fileFormat, String parsedContent, String outline, String summary) { + } + + private record StackItem(int level, List> children) { + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java index ceddb5b..83cbc43 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java @@ -1,6 +1,7 @@ package com.guo.learningprogresstracker.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewTaskStats; import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest; @@ -13,6 +14,8 @@ import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity; import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum; +import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; +import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum; import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; @@ -24,7 +27,14 @@ import com.guo.learningprogresstracker.service.ReviewService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; @@ -43,6 +53,8 @@ public class ReviewServiceImpl implements ReviewService { private static final int MAX_LIMIT = 100; private static final String RANDOM_MODE = "random"; private static final String DEFAULT_MIND_MAP_FORMAT = "TEXT"; + private static final String MANUAL_SOURCE_TYPE = "MANUAL"; + private static final String FILE_SOURCE_TYPE = "FILE"; private final StudyReportsMapper studyReportsMapper; private final StudyReportFragmentsMapper studyReportFragmentsMapper; @@ -50,6 +62,7 @@ public class ReviewServiceImpl implements ReviewService { private final TasksMapper tasksMapper; private final ReviewApplicationMapper reviewApplicationMapper; private final ReviewMindMapMapper reviewMindMapMapper; + private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper()); @Override public List getReviewFeed(int limit, String mode) { @@ -208,6 +221,66 @@ public class ReviewServiceImpl implements ReviewService { entity.setTitle(request.getTitle()); entity.setContent(request.getContent()); entity.setContentFormat(normalizeMindMapFormat(request.getContentFormat())); + entity.setSourceType(MANUAL_SOURCE_TYPE); + entity.setFileName(null); + entity.setFilePath(null); + entity.setFileFormat(null); + entity.setParsedContent(null); + entity.setSummary(null); + entity.setLastParsedTime(null); + entity.setParseStatus(ReviewMindMapParseStatusEnum.SUCCESS.getCode()); + entity.setParseError(null); + if (create) { + reviewMindMapMapper.insert(entity); + } else { + reviewMindMapMapper.updateById(entity); + } + return entity; + } + + @Override + public ReviewMindMapEntity uploadMindMap(String taskNum, MultipartFile file) throws NotFindEntitiesException, IOException { + ensureTaskExists(taskNum); + if (file == null || file.isEmpty()) { + throw new IOException("思维导图文件不可为空"); + } + + String originalFileName = StringUtils.cleanPath( + Optional.ofNullable(file.getOriginalFilename()).orElse("mind-map.txt")); + ReviewMindMapFileFormatEnum format = ReviewMindMapFileFormatEnum.fromFileName(originalFileName); + Path storedPath = storeMindMapFile(taskNum, originalFileName, file); + + ReviewMindMapEntity entity = getMindMap(taskNum); + boolean create = entity == null; + if (create) { + entity = new ReviewMindMapEntity(); + entity.setTaskNum(taskNum); + } + + entity.setSourceType(FILE_SOURCE_TYPE); + entity.setFileName(originalFileName); + entity.setFilePath(storedPath.toString()); + entity.setFileFormat(format.getCode()); + entity.setContentFormat("JSON"); + entity.setLastParsedTime(LocalDateTime.now()); + + try { + MindMapFileParser.ParseResult parseResult = mindMapFileParser.parse(file, format); + entity.setTitle(StringUtils.hasText(parseResult.title()) ? parseResult.title() : originalFileName); + entity.setContent(parseResult.outline()); + entity.setParsedContent(parseResult.parsedContent()); + entity.setSummary(parseResult.summary()); + entity.setParseStatus(ReviewMindMapParseStatusEnum.SUCCESS.getCode()); + entity.setParseError(null); + } catch (IOException e) { + entity.setTitle(originalFileName); + entity.setContent(""); + entity.setParsedContent(null); + entity.setSummary(null); + entity.setParseStatus(ReviewMindMapParseStatusEnum.FAILED.getCode()); + entity.setParseError(e.getMessage()); + } + if (create) { reviewMindMapMapper.insert(entity); } else { @@ -323,6 +396,17 @@ public class ReviewServiceImpl implements ReviewService { return normalized; } + private Path storeMindMapFile(String taskNum, String originalFileName, MultipartFile file) throws IOException { + Path directory = Paths.get("uploads", "review-mind-maps", taskNum); + Files.createDirectories(directory); + String storedFileName = UUID.randomUUID() + "-" + originalFileName.replaceAll("[\\\\/:*?\"<>|]", "_"); + Path target = directory.resolve(storedFileName); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } + private void fillReviewTaskStats(List statsList) { Map statsByTaskNum = statsList.stream() .filter(item -> StringUtils.hasText(item.getTaskNum())) diff --git a/src/main/resources/db/migration/V20260620_2__extend_review_mind_maps_for_file_parsing.sql b/src/main/resources/db/migration/V20260620_2__extend_review_mind_maps_for_file_parsing.sql new file mode 100644 index 0000000..5bd2efa --- /dev/null +++ b/src/main/resources/db/migration/V20260620_2__extend_review_mind_maps_for_file_parsing.sql @@ -0,0 +1,26 @@ +alter table review_mind_maps + add column source_type varchar(30) not null default 'MANUAL' comment '来源类型:MANUAL/FILE'; + +alter table review_mind_maps + add column file_name varchar(255) null comment '原始文件名'; + +alter table review_mind_maps + add column file_path varchar(1024) null comment '服务端文件路径'; + +alter table review_mind_maps + add column file_format varchar(30) null comment '文件格式:XMIND/MARKDOWN/OPML/FREEMIND/TEXT'; + +alter table review_mind_maps + add column parsed_content mediumtext null comment '解析后的统一结构JSON'; + +alter table review_mind_maps + add column parse_status varchar(30) not null default 'SUCCESS' comment '解析状态:SUCCESS/FAILED'; + +alter table review_mind_maps + add column parse_error text null comment '解析错误'; + +alter table review_mind_maps + add column summary text null comment '解析摘要'; + +alter table review_mind_maps + add column last_parsed_time datetime null comment '最近解析时间'; diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java index 555deb7..e7ef2f3 100644 --- a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java @@ -12,6 +12,8 @@ import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity; import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum; +import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; +import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum; import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; @@ -25,7 +27,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.util.List; @@ -172,4 +177,41 @@ class ReviewServiceImplTest { assertEquals("JSON", entity.getContentFormat()); verify(reviewMindMapMapper).updateById(existing); } + + @Test + void uploadMindMap_shouldParseMarkdownFile() throws Exception { + when(tasksMapper.exists(any())).thenReturn(true); + when(reviewMindMapMapper.selectOne(any())).thenReturn(null); + when(reviewMindMapMapper.insert(any())).thenAnswer(invocation -> { + ReviewMindMapEntity entity = invocation.getArgument(0); + entity.setId(9); + return 1; + }); + + MockMultipartFile file = new MockMultipartFile( + "file", + "java-review.md", + "text/markdown", + "# Java 并发\n## 线程基础\n- RUNNABLE\n".getBytes()); + + ReviewMindMapEntity entity = reviewService.uploadMindMap("T1", file); + + try { + assertEquals(9, entity.getId()); + assertEquals("FILE", entity.getSourceType()); + assertEquals(ReviewMindMapFileFormatEnum.MARKDOWN.getCode(), entity.getFileFormat()); + assertEquals(ReviewMindMapParseStatusEnum.SUCCESS.getCode(), entity.getParseStatus()); + assertEquals("JSON", entity.getContentFormat()); + assertNotNull(entity.getParsedContent()); + assertEquals(true, entity.getParsedContent().contains("线程基础")); + assertEquals(true, entity.getContent().contains("RUNNABLE")); + } finally { + if (entity.getFilePath() != null) { + Path storedFile = Path.of(entity.getFilePath()); + Files.deleteIfExists(storedFile); + Files.deleteIfExists(storedFile.getParent()); + } + } + verify(reviewMindMapMapper).insert(any(ReviewMindMapEntity.class)); + } }