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 getReviewFeed(int limit, String mode) { int safeLimit = normalizeLimit(limit); if (SMART_MODE.equalsIgnoreCase(mode)) { return getSmartFeed(safeLimit); } boolean random = RANDOM_MODE.equalsIgnoreCase(mode); List reports = random ? studyReportsMapper.selectList(Wrappers.lambdaQuery() .last("ORDER BY RAND() LIMIT " + safeLimit)) : studyReportsMapper.selectList(Wrappers.lambdaQuery() .orderByDesc(StudyReportsEntity::getCreatedTime) .last("LIMIT " + safeLimit)); List fragments = random ? studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery() .last("ORDER BY RAND() LIMIT " + safeLimit)) : studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery() .orderByDesc(StudyReportFragmentsEntity::getCreatedTime) .last("LIMIT " + safeLimit)); List items = mergeAndConvert(reports, fragments); if (random) { Collections.shuffle(items); } return items.stream().limit(safeLimit).collect(Collectors.toList()); } /** * 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。 * 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。 * 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。 */ private List getSmartFeed(int safeLimit) { int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500); List reports = studyReportsMapper.selectList( Wrappers.lambdaQuery() .last("ORDER BY RAND() LIMIT " + candidateLimit)); List fragments = studyReportFragmentsMapper.selectList( Wrappers.lambdaQuery() .last("ORDER BY RAND() LIMIT " + candidateLimit)); List candidates = mergeAndConvert(reports, fragments); if (candidates.size() <= safeLimit) { Collections.shuffle(candidates); return candidates; } // 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习) Map taskRecallRatio = latestRecallRatioByTask( candidates.stream().map(ReviewFeedItem::getTaskNum) .filter(Objects::nonNull).collect(Collectors.toSet())); LocalDateTime now = LocalDateTime.now(); List 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 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 latestRecallRatioByTask(Set taskNums) { if (taskNums.isEmpty()) { return Collections.emptyMap(); } List records = reviewRecallRecordMapper.selectList( Wrappers.lambdaQuery() .in(ReviewRecallRecordEntity::getTaskNum, taskNums) .orderByDesc(ReviewRecallRecordEntity::getCreatedTime)); Map result = new HashMap<>(); for (ReviewRecallRecordEntity record : records) { result.putIfAbsent(record.getTaskNum(), record.getRecallRatio() == null ? 0D : record.getRecallRatio()); } return result; } /** 按权重不放回采样 */ private List weightedSample(List items, List weights, int count) { List pool = new ArrayList<>(items); List poolWeights = new ArrayList<>(weights); List 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 getReviewTaskStats() { List tasks = tasksMapper.selectList( Wrappers.lambdaQuery() .orderByDesc(TaskEntity::getCalculatedPriority)); List stats = tasks.stream() .map(this::newTaskStats) .collect(Collectors.toList()); fillReviewTaskStats(stats); return stats; } @Override public ReviewTaskStats getReviewTaskStats(String taskNum) { TaskEntity task = tasksMapper.selectList( Wrappers.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 getTaskReview(String taskNum) { List sessionNums = studySessionsMapper.selectList( Wrappers.lambdaQuery() .eq(StudySessionsEntity::getTaskNum, taskNum) .select(StudySessionsEntity::getSessionNum)) .stream() .map(StudySessionsEntity::getSessionNum) .collect(Collectors.toList()); if (sessionNums.isEmpty()) { return Collections.emptyList(); } List reports = studyReportsMapper.selectList( Wrappers.lambdaQuery() .in(StudyReportsEntity::getSessionNum, sessionNums) .orderByDesc(StudyReportsEntity::getCreatedTime)); List fragments = studyReportFragmentsMapper.selectList( Wrappers.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.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 mergeAndConvert(List reports, List fragments) { Set allSessionNums = Stream.concat( reports.stream().map(StudyReportsEntity::getSessionNum), fragments.stream().map(StudyReportFragmentsEntity::getSessionNum) ).collect(Collectors.toSet()); if (allSessionNums.isEmpty()) { return Collections.emptyList(); } Map sessionToTaskMap = studySessionsMapper.selectList( Wrappers.lambdaQuery() .in(StudySessionsEntity::getSessionNum, allSessionNums) .select(StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum)) .stream() .collect(Collectors.toMap( StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum, (a, b) -> a)); Set taskNums = new HashSet<>(sessionToTaskMap.values()); Map taskNameMap = tasksMapper.selectList( Wrappers.lambdaQuery() .in(TaskEntity::getTaskNum, taskNums) .select(TaskEntity::getTaskNum, TaskEntity::getTaskName)) .stream() .collect(Collectors.toMap( TaskEntity::getTaskNum, TaskEntity::getTaskName, (a, b) -> a)); List 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 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.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 statsList) { Map 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 sessions = studySessionsMapper.selectList( Wrappers.lambdaQuery() .in(StudySessionsEntity::getTaskNum, statsByTaskNum.keySet())); LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay(); Map sessionToTaskNum = new HashMap<>(); Map 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 sessionToTaskNum, Map statsByTaskNum) { if (sessionToTaskNum.isEmpty()) { return; } Set sessionNums = sessionToTaskNum.keySet(); List reports = studyReportsMapper.selectList( Wrappers.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 fragments = studyReportFragmentsMapper.selectList( Wrappers.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); } } } }