diff --git a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java index a4cacb0..a88baaf 100644 --- a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java +++ b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java @@ -24,7 +24,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; -import java.util.List; +import java.util.Map; /** * 复习模块控制层 — 复习内容滚动展示与交互 @@ -159,7 +159,18 @@ public class ReviewController { public CommonResult recallCompare( @PathVariable String taskNum, @Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException { - return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getSessionNum())); + return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getFocusPath())); + } + + /** + * 在标准导图中查找与给定内容最匹配的节点 + */ + @PostMapping("/standard-mind-map/{taskNum}/find-node") + public CommonResult> findNode( + @PathVariable String taskNum, + @RequestBody Map body) throws NotFindEntitiesException, OperationFailedException { + String content = body != null ? body.getOrDefault("content", "") : ""; + return CommonResult.success(standardMindMapService.findNode(taskNum, content)); } /** diff --git a/src/main/java/com/guo/learningprogresstracker/controller/StudySessionController.java b/src/main/java/com/guo/learningprogresstracker/controller/StudySessionController.java index c913f35..d0b5bda 100644 --- a/src/main/java/com/guo/learningprogresstracker/controller/StudySessionController.java +++ b/src/main/java/com/guo/learningprogresstracker/controller/StudySessionController.java @@ -18,9 +18,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import com.guo.learningprogresstracker.dto.response.SessionBriefVO; import java.util.ArrayList; -import java.util.List; import java.time.LocalDateTime; /** @@ -139,10 +137,4 @@ public class StudySessionController { @RequestParam(required = false) String excludeTaskNum) { return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum)); } - - /** 获取任务的所有会话简要列表(用于复习维度选择) */ - @GetMapping("/tasks/{taskNum}/sessions") - public CommonResult> getTaskSessions(@PathVariable String taskNum) { - return CommonResult.success(studySessionsServiceImpl.getTaskSessions(taskNum)); - } } diff --git a/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java b/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java index d5f055c..a9e88a8 100644 --- a/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java +++ b/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java @@ -12,6 +12,6 @@ public class RecallCompareRequest { @NotBlank(message = "回忆大纲不可为空") private String recallOutline; - /** 复习维度:会话编码,null 为任务维度 */ - private String sessionNum; + /** 复习起点节点路径(以 / 分隔),null 为任务维度 */ + private String focusPath; } diff --git a/src/main/java/com/guo/learningprogresstracker/dto/response/SessionBriefVO.java b/src/main/java/com/guo/learningprogresstracker/dto/response/SessionBriefVO.java deleted file mode 100644 index 75c2576..0000000 --- a/src/main/java/com/guo/learningprogresstracker/dto/response/SessionBriefVO.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.guo.learningprogresstracker.dto.response; - -import lombok.Data; - -/** - * 会话简要信息(用于复习维度选择列表) - */ -@Data -public class SessionBriefVO { - private String sessionNum; - private String startTime; - private String expectation; - private int reportCount; - private int fragmentCount; -} diff --git a/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java b/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java index a0f704a..82aa56b 100644 --- a/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java +++ b/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java @@ -25,10 +25,10 @@ public class ReviewRecallRecordEntity extends BaseEntity implements Serializable private Integer standardMapId; /** - * 复习维度:会话编码(null 表示任务维度复习) + * 复习起点节点路径(以 / 分隔),null 表示任务维度复习 */ - @TableField(value = "session_num") - private String sessionNum; + @TableField(value = "focus_path") + private String focusPath; /** * 用户回忆绘制的导图大纲文本 diff --git a/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java b/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java index 734cf71..779a140 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java +++ b/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java @@ -6,6 +6,7 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.OperationFailedException; import java.util.List; +import java.util.Map; /** * 标准思维导图服务。 @@ -37,10 +38,16 @@ public interface StandardMindMapService { * 用户提交回忆大纲,与标准导图对比并返回结果。 * * @param recallOutline 用户回忆的缩进大纲文本 - * @param sessionNum 可选会话编码,指定时仅对比该会话相关的标准导图节点 - * @return 对比记录实体(compareResult 字段包含对比树 JSON) + * @param focusPath 可选:标准导图中的节点路径(/ 分隔),指定后仅对比该子树 */ - ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum) throws NotFindEntitiesException, OperationFailedException; + ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath) throws NotFindEntitiesException, OperationFailedException; + + /** + * 根据内容文本在标准导图中查找最匹配的节点,返回节点路径。 + * + * @return { "path": "节点A / 节点B", "nodeTitle": "节点B", "score": 0.85 } + */ + Map findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException; /** * 获取该任务的所有回忆对比记录。 diff --git a/src/main/java/com/guo/learningprogresstracker/service/StudySessionsService.java b/src/main/java/com/guo/learningprogresstracker/service/StudySessionsService.java index 76630fb..9932da1 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/StudySessionsService.java +++ b/src/main/java/com/guo/learningprogresstracker/service/StudySessionsService.java @@ -11,9 +11,7 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.guo.learningprogresstracker.exception.ErrorParameterException; import java.time.LocalDateTime; -import com.guo.learningprogresstracker.dto.response.SessionBriefVO; import java.util.ArrayList; -import java.util.List; /** * @author guo @@ -37,7 +35,4 @@ public interface StudySessionsService extends IService { Page getTaskReports(String taskNum, int page, int size, String keyword); StudySessionResponse getActiveSession(String excludeTaskNum); - - /** 获取任务的所有会话简要信息(复习维度选择用) */ - List getTaskSessions(String taskNum); } diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java b/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java index 2c208af..e6be4a9 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java @@ -84,7 +84,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient { } MindMapNode sessionNode = new MindMapNode(sessionTitle); - sessionNode.setSessionNum(sessionNum); // 报告作为子节点 for (StudyReportsEntity report : sessReports) { @@ -94,7 +93,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient { reportNode.setNotes(content); reportNode.setSourceType("REPORT"); reportNode.setSourceId(report.getId()); - reportNode.setSessionNum(sessionNum); sessionNode.getChildren().add(reportNode); } @@ -106,7 +104,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient { fragNode.setNotes(content); fragNode.setSourceType("FRAGMENT"); fragNode.setSourceId(frag.getId()); - fragNode.setSessionNum(sessionNum); sessionNode.getChildren().add(fragNode); } diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java index e1c0c58..ae9fdc9 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java @@ -152,7 +152,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService { @Override @Transactional - public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum) + public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath) throws NotFindEntitiesException, OperationFailedException { ensureTaskExists(taskNum); @@ -163,9 +163,14 @@ public class StandardMindMapServiceImpl implements StandardMindMapService { MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper); MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline); - // 2b. 若指定了会话维度,过滤标准导图只保留该会话相关节点 - if (sessionNum != null && !sessionNum.isBlank()) { - standardRoot = MindMapTreeTool.filterBySession(standardRoot, sessionNum); + // 2b. 若指定了起点节点路径,提取该子树作为对比基准 + MindMapNode compareRoot = standardRoot; + if (focusPath != null && !focusPath.isBlank()) { + compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath); + if (compareRoot == null) { + log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath); + compareRoot = standardRoot; + } } // 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法) @@ -176,19 +181,19 @@ public class StandardMindMapServiceImpl implements StandardMindMapService { .getTaskName(); var optJson = aiServiceClient.compareRecall( taskName, - MindMapTreeTool.toFullOutline(standardRoot), + MindMapTreeTool.toFullOutline(compareRoot), recallOutline ); if (optJson.isPresent()) { - result = buildCompareResultFromAI(optJson.get(), standardRoot); + result = buildCompareResultFromAI(optJson.get(), compareRoot); log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}", taskNum, result.getRecallRatio(), result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : ""); } else { - result = compareTrees(standardRoot, recallRoot); + result = compareTrees(compareRoot, recallRoot); } } else { - result = compareTrees(standardRoot, recallRoot); + result = compareTrees(compareRoot, recallRoot); } // 4. 序列化对比结果 @@ -203,7 +208,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService { ReviewRecallRecordEntity record = new ReviewRecallRecordEntity(); record.setTaskNum(taskNum); record.setStandardMapId(standard.getId()); - record.setSessionNum(sessionNum != null && !sessionNum.isBlank() ? sessionNum : null); + record.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null); record.setRecallContent(recallOutline); record.setCompareResult(resultJson); record.setRecallRatio(result.getRecallRatio()); @@ -219,6 +224,29 @@ public class StandardMindMapServiceImpl implements StandardMindMapService { return standard; } + // ============ 节点查找 ============ + + @Override + public Map findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException { + ensureTaskExists(taskNum); + ReviewStandardMindMapEntity standard = getOrGenerate(taskNum); + MindMapNode root = MindMapTreeTool.fromJson(standard.getContent(), objectMapper); + + MindMapNode closest = MindMapTreeTool.findClosestNode(root, content); + Map result = new LinkedHashMap<>(); + if (closest != null) { + String path = MindMapTreeTool.getPath(root, closest.getTitle()); + result.put("path", path); + result.put("nodeTitle", closest.getTitle()); + result.put("score", MindMapTreeTool.similarityScore(closest.getTitle(), content)); + } else { + result.put("path", ""); + result.put("nodeTitle", ""); + result.put("score", 0.0); + } + return result; + } + // ============ 对比算法核心 ============ /** diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/StudySessionsServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/StudySessionsServiceImpl.java index b7495fc..fcc93d6 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/StudySessionsServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/StudySessionsServiceImpl.java @@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.google.protobuf.ServiceException; import com.guo.learningprogresstracker.dto.StudySessionsDto; -import com.guo.learningprogresstracker.dto.response.SessionBriefVO; import com.guo.learningprogresstracker.dto.response.StudySessionResponse; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity; @@ -27,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -227,34 +225,6 @@ public class StudySessionsServiceImpl extends ServiceImpl getTaskSessions(String taskNum) { - var sessions = this.list(Wrappers.lambdaQuery(StudySessionsEntity.class) - .eq(StudySessionsEntity::getTaskNum, taskNum) - .orderByDesc(StudySessionsEntity::getStartTime)); - - return sessions.stream().map(s -> { - SessionBriefVO vo = new SessionBriefVO(); - vo.setSessionNum(s.getSessionNum()); - vo.setStartTime(s.getStartTime() != null ? s.getStartTime().toString() : null); - // 查询该会话的预期 - Optional.ofNullable(studyExpectationsService.getBySessionNum(s.getSessionNum())) - .ifPresent(e -> vo.setExpectation(e.getDescription())); - // 统计报告和残片数量 - long reportCount = studyReportsMapper.selectCount( - Wrappers.lambdaQuery(StudyReportsEntity.class) - .eq(StudyReportsEntity::getSessionNum, s.getSessionNum())); - long fragmentCount = studyReportFragmentsMapper.selectCount( - Wrappers.lambdaQuery(StudyReportFragmentsEntity.class) - .eq(StudyReportFragmentsEntity::getSessionNum, s.getSessionNum())); - vo.setReportCount((int) reportCount); - vo.setFragmentCount((int) fragmentCount); - return vo; - }).collect(Collectors.toList()); - } - // ============ 活跃会话查询 ============ /** diff --git a/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java b/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java index 53d285c..78d126a 100644 --- a/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java +++ b/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java @@ -35,8 +35,6 @@ public class MindMapNode { private String notes; private String sourceType; private Integer sourceId; - /** 可选:节点来源会话编码(REPORT/FRAGMENT 节点使用) */ - private String sessionNum; private List children; public MindMapNode(String title) { @@ -53,7 +51,6 @@ public class MindMapNode { map.put("notes", notes != null ? notes : ""); if (sourceType != null) map.put("sourceType", sourceType); if (sourceId != null) map.put("sourceId", sourceId); - if (sessionNum != null) map.put("sessionNum", sessionNum); List childMaps = new ArrayList<>(); if (children != null) { for (MindMapNode c : children) { @@ -73,7 +70,6 @@ public class MindMapNode { if (map.containsKey("sourceId") && map.get("sourceId") != null) { node.setSourceId(((Number) map.get("sourceId")).intValue()); } - node.setSessionNum((String) map.get("sessionNum")); Object raw = map.getOrDefault("children", map.get("nodes")); List children = new ArrayList<>(); if (raw instanceof List list) { diff --git a/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java b/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java index cd2e503..1c13405 100644 --- a/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java +++ b/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java @@ -156,50 +156,6 @@ public class MindMapTreeTool { return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0); } - /** - * 过滤树:只保留与指定 sessionNum 相关的分支。 - * - 节点有 sessionNum 且匹配 → 保留 - * - 节点无 sessionNum(如 APPLICATION 或 AI 生成的节点) → 保留(视为任务级通用知识) - * - 有子节点被保留时,父节点也保留 - * - * @param root 树根 - * @param sessionNum 目标会话编码 - * @return 过滤后的树,若无相关节点则返回仅含根的空树 - */ - public static MindMapNode filterBySession(MindMapNode root, String sessionNum) { - if (root == null || sessionNum == null || sessionNum.isBlank()) return root; - MindMapNode filtered = filterRecursive(root, sessionNum); - return filtered != null ? filtered : new MindMapNode(root.getTitle() != null ? root.getTitle() : ""); - } - - private static MindMapNode filterRecursive(MindMapNode node, String sessionNum) { - if (node == null) return null; - - // 先过滤子节点 - List keptChildren = new ArrayList<>(); - if (node.getChildren() != null) { - for (MindMapNode child : node.getChildren()) { - MindMapNode kept = filterRecursive(child, sessionNum); - if (kept != null) { - keptChildren.add(kept); - } - } - } - - // 判断当前节点是否应该保留: - // 1. 有 sessionNum 且匹配 → 保留 - // 2. 无 sessionNum → 保留(任务级节点,如 APPLICATION 或 AI 生成) - // 3. 有子节点保留 → 保留 - boolean selfMatch = (node.getSessionNum() == null) || sessionNum.equals(node.getSessionNum()); - if (selfMatch || !keptChildren.isEmpty()) { - MindMapNode result = copyNodeShallow(node); - result.setChildren(keptChildren.isEmpty() ? node.getChildren() : keptChildren); - return result; - } - - return null; - } - /** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */ private static MindMapNode copyNodeShallow(MindMapNode node) { MindMapNode copy = new MindMapNode(); @@ -207,10 +163,138 @@ public class MindMapTreeTool { copy.setNotes(node.getNotes()); copy.setSourceType(node.getSourceType()); copy.setSourceId(node.getSourceId()); - copy.setSessionNum(node.getSessionNum()); return copy; } + /** + * 按路径提取子树:以 / 分隔的节点标题路径,提取目标节点为根的新树。 + * @param root 完整树根 + * @param path 节点路径,如 "根标题 / 分支A / 子节点B" + * @return 以路径末端节点为根的深拷贝子树,匹配失败时返回 null + */ + public static MindMapNode extractSubtree(MindMapNode root, String path) { + if (root == null || path == null || path.isBlank()) return null; + String[] segments = path.split("\\s*/\\s*"); + MindMapNode current = root; + for (int i = 0; i < segments.length; i++) { + String seg = segments[i].trim(); + if (seg.isEmpty()) continue; + if (current.getTitle() != null && normalizeForMerge(current.getTitle()).equals(normalizeForMerge(seg))) { + continue; // 当前节点已匹配,看下一段 + } + MindMapNode found = null; + if (current.getChildren() != null) { + for (MindMapNode child : current.getChildren()) { + if (child.getTitle() != null && normalizeForMerge(child.getTitle()).equals(normalizeForMerge(seg))) { + found = child; + break; + } + } + } + if (found != null) { + current = found; + } else { + return null; + } + } + return deepCopy(current); + } + + /** 深拷贝节点及其子树 */ + private static MindMapNode deepCopy(MindMapNode node) { + if (node == null) return null; + MindMapNode copy = new MindMapNode(); + copy.setTitle(node.getTitle()); + copy.setNotes(node.getNotes()); + copy.setSourceType(node.getSourceType()); + copy.setSourceId(node.getSourceId()); + List children = new ArrayList<>(); + if (node.getChildren() != null) { + for (MindMapNode child : node.getChildren()) { + children.add(deepCopy(child)); + } + } + copy.setChildren(children); + return copy; + } + + /** + * 查找与 content 最相似的节点。基于 title 的 bigram Jaccard 相似度。 + * @return 得分最高的节点,若所有节点得分均低于 0.1 则返回 null + */ + public static MindMapNode findClosestNode(MindMapNode root, String content) { + if (root == null || content == null || content.isBlank()) return null; + List flat = flatten(root); + MindMapNode best = null; + double bestScore = 0.0; + for (MindMapNode node : flat) { + if (node.getTitle() == null || node.getTitle().isBlank()) continue; + double score = similarityScore(node.getTitle(), content); + // notes 也参与匹配,但权重减半 + if (node.getNotes() != null && !node.getNotes().isBlank()) { + double noteScore = similarityScore(node.getNotes(), content); + score = Math.max(score, noteScore * 0.5); + } + if (score > bestScore) { + bestScore = score; + best = node; + } + } + return bestScore > 0.1 ? best : null; + } + + /** + * 获取节点在树中的路径(以 / 分隔的 title 序列) + */ + public static String getPath(MindMapNode root, String targetTitle) { + if (root == null || targetTitle == null) return ""; + List path = new ArrayList<>(); + if (findPathRecursive(root, targetTitle, path)) { + return String.join(" / ", path); + } + return ""; + } + + private static boolean findPathRecursive(MindMapNode node, String targetTitle, List path) { + if (node == null) return false; + path.add(node.getTitle() != null ? node.getTitle() : ""); + if (normalizeForMerge(node.getTitle()).equals(normalizeForMerge(targetTitle))) return true; + if (node.getChildren() != null) { + for (MindMapNode child : node.getChildren()) { + if (findPathRecursive(child, targetTitle, path)) return true; + } + } + path.remove(path.size() - 1); + return false; + } + + /** bigram Jaccard 相似度 */ + public static double similarityScore(String a, String b) { + if (a == null || b == null) return 0; + String na = normalizeForMerge(a); + String nb = normalizeForMerge(b); + if (na.isEmpty() || nb.isEmpty()) return 0; + if (na.equals(nb)) return 1.0; + // 子串匹配 + if (na.contains(nb) || nb.contains(na)) return 0.9; + Set bigramsA = bigrams(na); + Set bigramsB = bigrams(nb); + if (bigramsA.isEmpty() && bigramsB.isEmpty()) return 0; + Set union = new HashSet<>(bigramsA); + union.addAll(bigramsB); + Set intersect = new HashSet<>(bigramsA); + intersect.retainAll(bigramsB); + return (double) intersect.size() / union.size(); + } + + private static Set bigrams(String s) { + Set set = new LinkedHashSet<>(); + for (int i = 0; i < s.length() - 1; i++) { + set.add(s.substring(i, i + 2)); + } + return set; + } + /** * 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。 * 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。 diff --git a/src/main/resources/db/migration/V20260706_1__add_focus_path_to_recall_records.sql b/src/main/resources/db/migration/V20260706_1__add_focus_path_to_recall_records.sql new file mode 100644 index 0000000..67881aa --- /dev/null +++ b/src/main/resources/db/migration/V20260706_1__add_focus_path_to_recall_records.sql @@ -0,0 +1,3 @@ +-- 为回忆对比记录添加 focus_path 字段,支持节点级复习范围 +ALTER TABLE review_recall_records + ADD COLUMN focus_path VARCHAR(512) NULL AFTER session_num;