feat(复习): 支持会话维度复习 + 标准导图按会话过滤(后端)

- MindMapNode 新增 sessionNum 字段
- BuiltinMindMapGenerator 生成节点时填充 sessionNum
- MindMapTreeTool.filterBySession() 按会话过滤标准导图
- recallCompare 接受 sessionNum 参数
- ReviewRecallRecordEntity 新增 sessionNum 字段
- Flyway V20260705_1: review_recall_records 加 session_num 列
- 新增 GET /study-sessions/tasks/{taskNum}/sessions 端点
This commit is contained in:
2026-07-05 14:40:38 +08:00
parent 22cc5dc871
commit a636e516d1
13 changed files with 144 additions and 3 deletions
@@ -154,7 +154,7 @@ public class ReviewController {
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
@PathVariable String taskNum,
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline()));
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getSessionNum()));
}
/**
@@ -18,7 +18,9 @@ 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;
/**
@@ -137,4 +139,10 @@ public class StudySessionController {
@RequestParam(required = false) String excludeTaskNum) {
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
}
/** 获取任务的所有会话简要列表(用于复习维度选择) */
@GetMapping("/tasks/{taskNum}/sessions")
public CommonResult<List<SessionBriefVO>> getTaskSessions(@PathVariable String taskNum) {
return CommonResult.success(studySessionsServiceImpl.getTaskSessions(taskNum));
}
}
@@ -11,4 +11,7 @@ public class RecallCompareRequest {
@NotBlank(message = "回忆大纲不可为空")
private String recallOutline;
/** 复习维度:会话编码,null 为任务维度 */
private String sessionNum;
}
@@ -0,0 +1,15 @@
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;
}
@@ -24,6 +24,12 @@ public class ReviewRecallRecordEntity extends BaseEntity implements Serializable
@TableField(value = "standard_map_id")
private Integer standardMapId;
/**
* 复习维度:会话编码(null 表示任务维度复习)
*/
@TableField(value = "session_num")
private String sessionNum;
/**
* 用户回忆绘制的导图大纲文本
*/
@@ -32,9 +32,10 @@ public interface StandardMindMapService {
* 用户提交回忆大纲,与标准导图对比并返回结果。
*
* @param recallOutline 用户回忆的缩进大纲文本
* @param sessionNum 可选会话编码,指定时仅对比该会话相关的标准导图节点
* @return 对比记录实体(compareResult 字段包含对比树 JSON
*/
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline) throws NotFindEntitiesException, OperationFailedException;
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 获取该任务的所有回忆对比记录。
@@ -11,7 +11,9 @@ 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
@@ -35,4 +37,7 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
StudySessionResponse getActiveSession(String excludeTaskNum);
/** 获取任务的所有会话简要信息(复习维度选择用) */
List<SessionBriefVO> getTaskSessions(String taskNum);
}
@@ -84,6 +84,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
}
MindMapNode sessionNode = new MindMapNode(sessionTitle);
sessionNode.setSessionNum(sessionNum);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) {
@@ -93,6 +94,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
reportNode.setNotes(content);
reportNode.setSourceType("REPORT");
reportNode.setSourceId(report.getId());
reportNode.setSessionNum(sessionNum);
sessionNode.getChildren().add(reportNode);
}
@@ -104,6 +106,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
fragNode.setNotes(content);
fragNode.setSourceType("FRAGMENT");
fragNode.setSourceId(frag.getId());
fragNode.setSessionNum(sessionNum);
sessionNode.getChildren().add(fragNode);
}
@@ -96,7 +96,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
@Override
@Transactional
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline)
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
@@ -107,6 +107,11 @@ 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);
}
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
CompareResult result;
if (aiServiceClient.isConfigured()) {
@@ -142,6 +147,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.setRecallContent(recallOutline);
record.setCompareResult(resultJson);
record.setRecallRatio(result.getRecallRatio());
@@ -5,6 +5,7 @@ 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;
@@ -26,6 +27,7 @@ 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;
@@ -225,6 +227,34 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
return result;
}
// ============ 获取任务会话列表(复习维度选择用) ============
@Override
public List<SessionBriefVO> 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());
}
// ============ 活跃会话查询 ============
/**
@@ -35,6 +35,8 @@ public class MindMapNode {
private String notes;
private String sourceType;
private Integer sourceId;
/** 可选:节点来源会话编码(REPORT/FRAGMENT 节点使用) */
private String sessionNum;
private List<MindMapNode> children;
public MindMapNode(String title) {
@@ -51,6 +53,7 @@ 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<Object> childMaps = new ArrayList<>();
if (children != null) {
for (MindMapNode c : children) {
@@ -70,6 +73,7 @@ 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<MindMapNode> children = new ArrayList<>();
if (raw instanceof List<?> list) {
@@ -156,6 +156,61 @@ 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<MindMapNode> 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();
copy.setTitle(node.getTitle());
copy.setNotes(node.getNotes());
copy.setSourceType(node.getSourceType());
copy.setSourceId(node.getSourceId());
copy.setSessionNum(node.getSessionNum());
return copy;
}
/** 序列化整棵树为 JSON */
public static String toJson(MindMapNode root, ObjectMapper mapper) {
try {