Files
lpt-be/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java
T
cat-shark 3c4db2fead feat(复习):feed 智能模式——加权随机采样
- /review/feed?mode=smart:时间衰减 × 回忆掌握度加权采样
- 越久未见、回忆覆盖率越低的内容出现概率越高
- 保持随机性,符合复习模块偶遇式设计理念
- 概率性单元测试验证低掌握内容显著优先
2026-07-03 23:44:40 +08:00

543 lines
24 KiB
Java

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.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper;
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;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 复习模块 Service 实现
*/
@Service
@RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService {
private static final int DEFAULT_LIMIT = 30;
private static final int MAX_LIMIT = 100;
private static final String RANDOM_MODE = "random";
private static final String SMART_MODE = "smart";
private static final int SMART_CANDIDATE_MULTIPLIER = 5;
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;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
private final ReviewMindMapMapper reviewMindMapMapper;
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper());
@Override
public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
int safeLimit = normalizeLimit(limit);
if (SMART_MODE.equalsIgnoreCase(mode)) {
return getSmartFeed(safeLimit);
}
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
List<StudyReportsEntity> reports = random
? studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit))
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.orderByDesc(StudyReportsEntity::getCreatedTime)
.last("LIMIT " + safeLimit));
List<StudyReportFragmentsEntity> fragments = random
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit))
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
.last("LIMIT " + safeLimit));
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments);
if (random) {
Collections.shuffle(items);
}
return items.stream().limit(safeLimit).collect(Collectors.toList());
}
/**
* 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。
* 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。
* 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。
*/
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
if (candidates.size() <= safeLimit) {
Collections.shuffle(candidates);
return candidates;
}
// 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习)
Map<String, Double> taskRecallRatio = latestRecallRatioByTask(
candidates.stream().map(ReviewFeedItem::getTaskNum)
.filter(Objects::nonNull).collect(Collectors.toSet()));
LocalDateTime now = LocalDateTime.now();
List<Double> weights = new ArrayList<>(candidates.size());
for (ReviewFeedItem item : candidates) {
weights.add(reviewNeedWeight(item, taskRecallRatio, now));
}
return weightedSample(candidates, weights, safeLimit);
}
/** 单条内容的复习需求权重 */
private double reviewNeedWeight(ReviewFeedItem item, Map<String, Double> taskRecallRatio, LocalDateTime now) {
// 时间衰减:7 天内权重较低,之后随天数增长,90 天封顶
double ageDays = item.getCreatedTime() == null ? 30
: Math.max(0, java.time.Duration.between(item.getCreatedTime(), now).toDays());
double ageFactor = 0.3 + Math.min(ageDays, 90) / 90.0 * 0.7;
// 掌握度:最近回忆覆盖率越低,权重越高
double ratio = item.getTaskNum() == null ? 0
: taskRecallRatio.getOrDefault(item.getTaskNum(), 0D);
double masteryFactor = 1.0 - ratio * 0.7; // 覆盖率 100% 时权重降至 0.3
return ageFactor * masteryFactor;
}
/** 查询每个任务最近一次回忆对比的覆盖率 */
private Map<String, Double> latestRecallRatioByTask(Set<String> taskNums) {
if (taskNums.isEmpty()) {
return Collections.emptyMap();
}
List<ReviewRecallRecordEntity> records = reviewRecallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.in(ReviewRecallRecordEntity::getTaskNum, taskNums)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
Map<String, Double> result = new HashMap<>();
for (ReviewRecallRecordEntity record : records) {
result.putIfAbsent(record.getTaskNum(),
record.getRecallRatio() == null ? 0D : record.getRecallRatio());
}
return result;
}
/** 按权重不放回采样 */
private List<ReviewFeedItem> weightedSample(List<ReviewFeedItem> items, List<Double> weights, int count) {
List<ReviewFeedItem> pool = new ArrayList<>(items);
List<Double> poolWeights = new ArrayList<>(weights);
List<ReviewFeedItem> selected = new ArrayList<>(count);
Random random = new Random();
while (selected.size() < count && !pool.isEmpty()) {
double total = poolWeights.stream().mapToDouble(Double::doubleValue).sum();
double r = random.nextDouble() * total;
double cumulative = 0;
int chosen = pool.size() - 1;
for (int i = 0; i < pool.size(); i++) {
cumulative += poolWeights.get(i);
if (r <= cumulative) {
chosen = i;
break;
}
}
selected.add(pool.remove(chosen));
poolWeights.remove(chosen);
}
return selected;
}
@Override
public List<ReviewTaskStats> getReviewTaskStats() {
List<TaskEntity> tasks = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.orderByDesc(TaskEntity::getCalculatedPriority));
List<ReviewTaskStats> stats = tasks.stream()
.map(this::newTaskStats)
.collect(Collectors.toList());
fillReviewTaskStats(stats);
return stats;
}
@Override
public ReviewTaskStats getReviewTaskStats(String taskNum) {
TaskEntity task = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.eq(TaskEntity::getTaskNum, taskNum)
.last("LIMIT 1"))
.stream()
.findFirst()
.orElse(null);
ReviewTaskStats stats = newTaskStats(taskNum, task == null ? null : task.getTaskName());
fillReviewTaskStats(Collections.singletonList(stats));
return stats;
}
@Override
public List<ReviewFeedItem> getTaskReview(String taskNum) {
List<String> sessionNums = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
.select(StudySessionsEntity::getSessionNum))
.stream()
.map(StudySessionsEntity::getSessionNum)
.collect(Collectors.toList());
if (sessionNums.isEmpty()) {
return Collections.emptyList();
}
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums)
.orderByDesc(StudyReportsEntity::getCreatedTime));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime));
return mergeAndConvert(reports, fragments);
}
@Override
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在"));
}
@Override
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
}
@Override
public ReviewMindMapEntity getMindMap(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return reviewMindMapMapper.selectOne(
Wrappers.<ReviewMindMapEntity>lambdaQuery()
.eq(ReviewMindMapEntity::getTaskNum, taskNum)
.last("LIMIT 1"));
}
@Override
public ReviewMindMapEntity upsertMindMap(String taskNum, UpsertReviewMindMapRequest request) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
ReviewMindMapEntity entity = getMindMap(taskNum);
boolean create = entity == null;
if (create) {
entity = new ReviewMindMapEntity();
entity.setTaskNum(taskNum);
}
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 {
reviewMindMapMapper.updateById(entity);
}
return entity;
}
/**
* 将报告和残片合并转换为 ReviewFeedItem 列表,按创建时间倒序排列
*/
private List<ReviewFeedItem> mergeAndConvert(List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments) {
Set<String> allSessionNums = Stream.concat(
reports.stream().map(StudyReportsEntity::getSessionNum),
fragments.stream().map(StudyReportFragmentsEntity::getSessionNum)
).collect(Collectors.toSet());
if (allSessionNums.isEmpty()) {
return Collections.emptyList();
}
Map<String, String> sessionToTaskMap = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.in(StudySessionsEntity::getSessionNum, allSessionNums)
.select(StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum))
.stream()
.collect(Collectors.toMap(
StudySessionsEntity::getSessionNum,
StudySessionsEntity::getTaskNum,
(a, b) -> a));
Set<String> taskNums = new HashSet<>(sessionToTaskMap.values());
Map<String, String> taskNameMap = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.in(TaskEntity::getTaskNum, taskNums)
.select(TaskEntity::getTaskNum, TaskEntity::getTaskName))
.stream()
.collect(Collectors.toMap(
TaskEntity::getTaskNum,
TaskEntity::getTaskName,
(a, b) -> a));
List<ReviewFeedItem> reportItems = reports.stream().map(r -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(r.getId());
item.setSessionNum(r.getSessionNum());
item.setSourceType("REPORT");
item.setContent(r.getContent());
item.setCreatedTime(r.getCreatedTime());
String taskNum = sessionToTaskMap.get(r.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
List<ReviewFeedItem> fragmentItems = fragments.stream().map(f -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(f.getId());
item.setSessionNum(f.getSessionNum());
item.setSourceType("FRAGMENT");
item.setContent(f.getContent());
item.setCreatedTime(f.getCreatedTime());
String taskNum = sessionToTaskMap.get(f.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
return Stream.concat(reportItems.stream(), fragmentItems.stream())
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());
}
private ReviewTaskStats newTaskStats(TaskEntity task) {
return newTaskStats(task.getTaskNum(), task.getTaskName());
}
private ReviewTaskStats newTaskStats(String taskNum, String taskName) {
ReviewTaskStats stats = new ReviewTaskStats();
stats.setTaskNum(taskNum);
stats.setTaskName(taskName);
return stats;
}
private int normalizeLimit(int limit) {
if (limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private String normalizeMindMapFormat(String contentFormat) {
if (!StringUtils.hasText(contentFormat)) {
return DEFAULT_MIND_MAP_FORMAT;
}
String normalized = contentFormat.trim().toUpperCase(Locale.ROOT);
if (!Set.of("TEXT", "MERMAID", "JSON").contains(normalized)) {
return DEFAULT_MIND_MAP_FORMAT;
}
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<ReviewTaskStats> statsList) {
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
.filter(item -> StringUtils.hasText(item.getTaskNum()))
.collect(Collectors.toMap(
ReviewTaskStats::getTaskNum,
item -> item,
(a, b) -> a,
LinkedHashMap::new));
if (statsByTaskNum.isEmpty()) {
return;
}
List<StudySessionsEntity> sessions = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.in(StudySessionsEntity::getTaskNum, statsByTaskNum.keySet()));
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay();
Map<String, String> sessionToTaskNum = new HashMap<>();
Map<String, Double> effectivenessRatioSum = new HashMap<>();
for (StudySessionsEntity session : sessions) {
ReviewTaskStats stats = statsByTaskNum.get(session.getTaskNum());
if (stats == null) {
continue;
}
if (StringUtils.hasText(session.getSessionNum())) {
sessionToTaskNum.put(session.getSessionNum(), session.getTaskNum());
}
double effectiveTime = session.getEffectiveTime();
stats.setSessionCount(stats.getSessionCount() + 1);
stats.setEffectiveTime(stats.getEffectiveTime() + effectiveTime);
effectivenessRatioSum.merge(session.getTaskNum(), session.getEffectivenessRatio(), Double::sum);
LocalDateTime startTime = session.getStartTime();
if (startTime != null && !startTime.isBefore(todayStart)) {
stats.setTodayEffectiveTime(stats.getTodayEffectiveTime() + effectiveTime);
}
if (startTime != null && !startTime.isBefore(weekStart)) {
stats.setWeekEffectiveTime(stats.getWeekEffectiveTime() + effectiveTime);
}
}
statsByTaskNum.forEach((taskNum, stats) -> {
if (stats.getSessionCount() > 0) {
stats.setAvgEffectiveTime(stats.getEffectiveTime() / stats.getSessionCount());
stats.setAvgEffectivenessRatio(effectivenessRatioSum.getOrDefault(taskNum, 0D) / stats.getSessionCount());
}
});
fillReportAndFragmentCount(sessionToTaskNum, statsByTaskNum);
}
private void fillReportAndFragmentCount(Map<String, String> sessionToTaskNum,
Map<String, ReviewTaskStats> statsByTaskNum) {
if (sessionToTaskNum.isEmpty()) {
return;
}
Set<String> sessionNums = sessionToTaskNum.keySet();
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums)
.select(StudyReportsEntity::getSessionNum));
for (StudyReportsEntity report : reports) {
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(report.getSessionNum()));
if (stats != null) {
stats.setReportCount(stats.getReportCount() + 1);
}
}
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
.select(StudyReportFragmentsEntity::getSessionNum));
for (StudyReportFragmentsEntity fragment : fragments) {
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(fragment.getSessionNum()));
if (stats != null) {
stats.setFragmentCount(stats.getFragmentCount() + 1);
}
}
}
}