Compare commits
9
Commits
master
...
11a7c8aaea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a7c8aaea | ||
|
|
e691dfaa95 | ||
|
|
a6f684c8b7 | ||
|
|
0413013f24 | ||
|
|
9595ad92ee | ||
|
|
6678c2b8c0 | ||
|
|
b434520eff | ||
|
|
5b6f7d6a2a | ||
|
|
c8d0584f11 |
@@ -0,0 +1,4 @@
|
|||||||
|
# 行尾规范:文本文件统一 LF,Windows 批处理保留 CRLF
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -44,16 +44,17 @@ lpt-be/src/main/java/com/guo/learningprogresstracker/
|
|||||||
- entity 使用 `@TableName`、`@TableField` 映射数据库字段。
|
- entity 使用 `@TableName`、`@TableField` 映射数据库字段。
|
||||||
- mapStruct 承载 DTO 转换。
|
- mapStruct 承载 DTO 转换。
|
||||||
|
|
||||||
## API 端点总览(32 个)
|
## API 端点总览(44 个)
|
||||||
|
|
||||||
| 模块 | 端点数 | 端点 |
|
| 模块 | 端点数 | 端点 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 学习会话 | 9 | `POST /study-sessions/{taskNum}/start`, `PUT .../pause`, `PUT .../resume`, `PUT .../end`, `POST .../fragments`, `GET .../history`, `PUT .../expectation`, `GET .../expectation`, `GET .../report-draft` |
|
| 登录认证 | 2 | `POST /login`, `POST /logout` |
|
||||||
| 标准思维导图 | 6 | `GET /review/standard-mind-map/{taskNum}`, `POST .../regenerate`, `PUT ...`, `POST .../recall`, `POST .../find-node`, `GET .../recall-records` |
|
| 学习会话 | 11 | `PUT /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}`, `POST /study-sessions/{sessionNum}/study-sessions/continue`, `POST /study-sessions/{sessionNum}/study-sessions/pause`, `POST /study-sessions/{sessionNum}/study-sessions/ended`, `GET /study-sessions/{sessionNum}/all-fragments`, `GET /study-sessions/{sessionNum}/report-draft`, `GET /study-sessions/tasks/{taskNum}/fragments`, `GET /study-sessions/tasks/{taskNum}/reports`, `GET /study-sessions/active` |
|
||||||
| 复习模块 | 6 | `GET /review/feed`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/recall-records/{recordId}`, `GET /review/tasks` |
|
| 任务管理 | 13 | `GET/POST /tasks`, `GET/PUT/DELETE /tasks/{taskId}`, `GET/PUT /tasks/priority-weights`, `GET/POST /tasks/{taskNum}/applications`, `PUT/DELETE /tasks/applications/{id}`, `GET /tasks/{taskNum}/study-sessions/start-or-continue`, `GET /tasks/{taskNum}/not-ended-study-session` |
|
||||||
| 任务管理 | 6 | `GET /tasks`, `POST /tasks`, `PUT /tasks/{taskNum}`, `DELETE /tasks/{taskNum}`, `GET /tasks/priority-weights`, `PUT /tasks/priority-weights` |
|
| 复习与导图 | 13 | `GET /review/feed`, `GET /review/tasks`, `GET /review/tasks/{taskNum}`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/regenerate`, `PUT /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/recall`, `POST /review/standard-mind-map/{taskNum}/find-node`, `GET /review/standard-mind-map/{taskNum}/recall-records`, `GET /review/standard-mind-map/recall-records/{recordId}` |
|
||||||
| 应用场景 | 4 | `GET/POST/PUT/DELETE /tasks/{taskNum}/applications[/{id}]` |
|
| 学习残片 | 3 | `POST /report-fragments`, `PUT /report-fragments/{id}`, `GET /report-fragments/session/{sessionNum}` |
|
||||||
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
|
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
|
||||||
|
| 测试 | 1 | `GET /test/{id}` |
|
||||||
|
|
||||||
## 数据库(10 个核心表)
|
## 数据库(10 个核心表)
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ lpt-be/src/main/java/com/guo/learningprogresstracker/
|
|||||||
- 业务错误:`CommonResult.error(msg)`,code=400,HTTP 200。
|
- 业务错误:`CommonResult.error(msg)`,code=400,HTTP 200。
|
||||||
- 未登录:`GlobalExceptionHandler.handleNotLogin()`,HTTP 401 + code=401。
|
- 未登录:`GlobalExceptionHandler.handleNotLogin()`,HTTP 401 + code=401。
|
||||||
- 参数校验失败:`MethodArgumentNotValidException`,code=400。
|
- 参数校验失败:`MethodArgumentNotValidException`,code=400。
|
||||||
|
- 用户可见错误文案必须口语化、可理解,避免“无法生成”“不存在”等技术化表述;技术细节写入日志。例如无学习报告时应提示“这个任务还没开始学习哦,学习后产生学习报告后再来吧”,而不是“没有学习报告,无法生成思维导图”。
|
||||||
|
|
||||||
## DTO 转换
|
## DTO 转换
|
||||||
|
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
# 学习进度跟踪系统(LPT)— 架构与设计评审
|
|
||||||
|
|
||||||
> 评审日期:2026-07-03
|
|
||||||
> 评审范围:learning-progress-tracker(后端 Spring Boot) + lpt-fe(前端 Vue 3)
|
|
||||||
> 代码基线:feature/review-completion 分支
|
|
||||||
|
|
||||||
## 一、整体评价
|
|
||||||
|
|
||||||
系统的底层方向是正确的:
|
|
||||||
- **量化学习投入**(番茄钟 + 学习简报)→ 解决缺乏时间感知
|
|
||||||
- **优先级排序**(五维度算法)→ 解决缺乏计划性
|
|
||||||
- **主动回忆式复习**(碎片滚动 + 思维导图对比)→ 解决缺乏复习习惯
|
|
||||||
|
|
||||||
三个痛点对应三个解决方案,逻辑链成立。代码质量方面,前后端分离、RESTful API 设计、Flyway 数据库迁移、Sa-Token 鉴权、MyBatis-Plus 多租户拦截等基础设施做得相当规范,超出业余项目的平均水准。
|
|
||||||
|
|
||||||
但在**业务设计的心理学层面**,存在几个结构性矛盾:有些地方把"功能完整"等同于"有用",对用户的实际行为模式考虑不足。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、不科学之处
|
|
||||||
|
|
||||||
### 2.1 番茄钟被当作计时器而非学习节奏工具
|
|
||||||
|
|
||||||
当前实现把 25 分钟当作一个硬性倒计时,超时 50 分钟自动暂停,"仅计 25 分钟有效时间"。但番茄钟的核心价值不是"学够 25 分钟",而是**强迫休息—回忆—重置**的节奏。
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 允许用户连续学习远超 25 分钟而不触发强制休息提醒
|
|
||||||
- 超时被视为"违规"(自动暂停),而不是自然的节奏重置
|
|
||||||
- 后端 session 状态机里没有独立的"休息中"状态
|
|
||||||
- 前端虽有 5 分钟倒计时但后端无法感知
|
|
||||||
|
|
||||||
**改进方向**:番茄钟应该循环——25 分钟学习 → 强制 5 分钟休息并提示填写残片 → 自动进入下一轮。每一轮结束都是自然的"复习锚点"。
|
|
||||||
|
|
||||||
**用户评价**:你理解的不对,“有效学习时间”指的是实际的学习时间(不处于暂停状态的时间),25分钟对于一个大的学习任务来说太短,我们应该给予宽松的选择,而不是机械的要求用户休息,这只是一个辅助学习的工具
|
|
||||||
|
|
||||||
### 2.2 优先级五维度权重有重叠,且缺乏解释
|
|
||||||
|
|
||||||
| 维度 | 权重 | 问题 |
|
|
||||||
|------|------|------|
|
|
||||||
| 主观判断 | 10% | 跟"重要性"、"未来价值"高度相关,用户难以区分 |
|
|
||||||
| 未来价值 | 10% | 同上 |
|
|
||||||
| 内容难度 | 20% | 假设"越难越值得优先学"不一定成立 |
|
|
||||||
| 必要性 | 25% | 与"紧急性"边界模糊 |
|
|
||||||
| 紧急性 | 35% | 权重最高,但优先级 ≠ 紧迫性 |
|
|
||||||
|
|
||||||
核心矛盾:紧急性 + 必要性占 0.60,主观判断 + 未来价值仅占 0.20。系统会自动把"明天要交的报告"排在"你想真正学会的英语"前面——但用户做这个系统是为了后者的。
|
|
||||||
|
|
||||||
**改进方向**:两套排序模式——"紧急模式"(按紧迫性)和"重要模式"(按主观+未来价值),用户在不同时间段切换,而不是混合成一个不伦不类的综合分。此外权重应当可配置(当前硬编码在 CalculatedPriorityTool 中)。
|
|
||||||
|
|
||||||
**用户评价**:用户要如何切换?什么时候切换呢?如果一个课程最开始并不急迫,但其很重要,如果等到其变为急迫后用户才开始学习,那就晚了;我这里的设计是想优先完成“重要但不紧急”的课程,权重设计本身就是要是可修改的,只是系统中没有来得及实现;
|
|
||||||
|
|
||||||
### 2.3 学习预期是系统强约束但无闭环
|
|
||||||
|
|
||||||
PRD 明确要求"每次学习开始前必须填写学习预期(不可为空)",数据库也建了 study_expectations 表。但:
|
|
||||||
- 后端没有对应的 Entity / Service / Controller
|
|
||||||
- 前端 StartTask 页面没有预期输入框
|
|
||||||
- 预期没有与实际学习报告做任何对比
|
|
||||||
|
|
||||||
被强制填写却不被使用的字段,比没有更糟——用户费心填了,却发现系统根本不看。学习预期的价值在于事后对比:"我打算学什么 vs 我实际学了什么",这是元认知训练的核心。
|
|
||||||
|
|
||||||
**改进方向**:始于创建 session 时弹窗必填预期,终于结束时展示"预期 vs 实际"对比。
|
|
||||||
|
|
||||||
### 2.4 复习模块的认知负荷偏高
|
|
||||||
|
|
||||||
当前复习模块有三个独立概念和入口:
|
|
||||||
- 碎片化提醒(Welcome 页滚动)
|
|
||||||
- 思维导图上/下载(ReviewDetail)
|
|
||||||
- 回忆对比(ReviewRecall)
|
|
||||||
|
|
||||||
但用户的真实行为大概是:打开复习页 → 看到滚动条闪过很多内容 → 不知道先看哪个 → 关掉。太多碎片而没有"今天需要复习什么"的引导。
|
|
||||||
|
|
||||||
复习行为的心理阻力主要来源于**不知道从哪开始**。
|
|
||||||
|
|
||||||
**改进方向**:复习应该"推"给用户一条清晰路径——"你今天有 3 个知识点需要复习,先看第一个"。而不是拉出一个 feed 让用户自己挑。
|
|
||||||
|
|
||||||
**用户评价**:不,如果按照你的设计,就违背了我对这个系统的设计理念“本系统只是辅助学习的工具”,滚动条的设计是想要随机的将知识展示给用户,触发用户随机的回忆,在我的感受中,总有些无意识看到的东西会停留在记忆中很久,即使看到了但没有回忆起细节,也会更努力的回忆,从而印象更深;我认同你的“推”的逻辑,但并不应该“要求”用户,这可能会让用户产生抵触情绪;我们可以让“随机”变得不那么随机,可以在滚动中将更需要复习的内容优先展示出来;为了制造这种“无意识看到”,我们也许可以在更多地方增加这种引导;因为这是我的感受,请搜索网络,看看是否有论文对此进行过论证,主要找到相反的论证,我不希望我的系统是我一厢情愿的
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、改进空间
|
|
||||||
|
|
||||||
### 3.1 学习报告的长度决策
|
|
||||||
|
|
||||||
当前,残片是"休息时记录的内容片段",报告是"结束时生成的完整总结"。但在前端两者几乎无差异——都是文本、都在同一个 feed 中滚动、都在详情页逐行显示。
|
|
||||||
|
|
||||||
实际问题:学完 25 分钟后,用户是否真的有动力写一篇"完整报告"?大概率不会——草草写一句"学了线程池"然后开始下一轮。
|
|
||||||
|
|
||||||
**改进方向**:降低报告的仪式感。残片应该是主力的学习记录单元,报告改为"自动将本次会话的残片聚合为摘要",用户只需要微调即可。
|
|
||||||
|
|
||||||
**用户评价**:你关于“完整报告”的判断是正确的,和我的使用体验一致,但在我原有的设计中,"自动将本次会话的残片聚合为摘要"就是该功能的最终版本,只是受限当前阶段中没有对接AI导致的;我认为这部分功能使用32b的LLM模型应该就可以,实施时请简单调研
|
|
||||||
|
|
||||||
### 3.2 断点续传的粒度
|
|
||||||
|
|
||||||
当前用 pointerPosition(毫秒级指针)支持关闭页面后恢复学习,设计很好。但问题在于:学习结束后,断点续传数据变成死数据。
|
|
||||||
|
|
||||||
用户周五回来面对的不是"上次学了什么"的摘要,而是"继续上次倒计时"——但用户可能想重新开始一个关于同一任务的新会话,或者先回顾上次的残片再做决定。
|
|
||||||
|
|
||||||
**改进方向**:断点续传配合"上次学习回顾"一起出现——"你上次学了 X、Y、Z,生成了 N 个残片。继续?还是开始新的?"
|
|
||||||
|
|
||||||
**用户评价**:你可以看到原有设计中“关闭页面后恢复学习”只是对一个session完成的,每个session就应该是连续的,不应该出现长时间暂停的情况;
|
|
||||||
|
|
||||||
### 3.3 有效学习时间的 10 分钟阈值
|
|
||||||
|
|
||||||
后端逻辑:effective_time < 10 分钟 → 不计入总学习时间,置为 0。
|
|
||||||
|
|
||||||
意图是过滤掉"开启后很快就关"的无效学习。但 10 分钟阈值对番茄钟来说太高——25 分钟的 session 如果被电话打断 8 分钟后回来,系统判定"白学了"。
|
|
||||||
|
|
||||||
**改进方向**:降低阈值到 3-5 分钟,或改为比例制(有效比 < 30% 才标记低效而不是直接归零)。数据是分析原材料,不是评判工具。
|
|
||||||
|
|
||||||
**用户评价**:我认为你对“有效学习时间”的定义理解有误,如果一个session开始了(开始了一个学习任务),用户有其他事情需要处理,其可以点击暂停,暂停后的时间才是“无效的”,暂停之前的时间才是“有效学习时间”,对于一轮25分钟的番茄钟来说,有效学习10分钟是最低限度了, 因为一次学习本就应该持续25分钟,10分钟还不到一半;另外一点,对于长时间暂停的session,我的系统应该会自动终止;
|
|
||||||
|
|
||||||
### 3.4 回忆覆盖率的误导性
|
|
||||||
|
|
||||||
当前:recall_ratio = matched / (matched + missed)。问题在于它衡量"记得多少节点"而非"记得多少重要节点"。
|
|
||||||
|
|
||||||
假如一个任务 100 个节点,其中 3 个核心、97 个细节。
|
|
||||||
- 用户回忆出 3 个核心 → 覆盖率 3%
|
|
||||||
- 用户回忆出 97 个细节但漏了 3 个核心 → 覆盖率 97%
|
|
||||||
|
|
||||||
显然后者更差,但指标显示前者好。
|
|
||||||
|
|
||||||
**改进方向**:节点加权——"应用场景"和"从报告中提取的关键概念"级别更高。或至少同时展示"核心节点覆盖"和"全部节点覆盖"两个指标。
|
|
||||||
|
|
||||||
**用户评价**:单纯的指标展示还是略显单调,用户可能无法直接定位其未能覆盖的内容,如果可以直接在“思维导图”上用颜色标记出来是最好的
|
|
||||||
|
|
||||||
### 3.5 多租户隔离的泛化成本
|
|
||||||
|
|
||||||
系统通过 created_by 做租户隔离,每个查询多一个 WHERE 条件、新增数据要注入用户 ID、测试要绕过拦截器。开发笔记中作者自问"这玩应儿除了自己,还能有别人用么"。
|
|
||||||
|
|
||||||
多租户本身没有错,但在当前单人使用场景下带来了不必要的复杂度。
|
|
||||||
|
|
||||||
**改进方向**:如果未来真有多人需求可还原。当前去掉租户拦截可简化 80% 的查询,减少隐式 bug。
|
|
||||||
|
|
||||||
**用户评价**:不,还是保持基本的多租户设计吧,系统必然会遇到多租户需求,但目前没有
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、优先级建议
|
|
||||||
|
|
||||||
### P0 —— 影响核心使用
|
|
||||||
- 学习预期的闭环(创建→填写→事后对比)
|
|
||||||
- 番茄钟循环节律(25分→5分休息 + 自动残片)
|
|
||||||
|
|
||||||
### P1 —— 明显改善体验
|
|
||||||
- 复习引导路径("今天有 N 个知识点需要复习")
|
|
||||||
- 降低报告仪式感(残片为主、报告自动聚合)
|
|
||||||
- 优先级权重用户可配置
|
|
||||||
|
|
||||||
### P2 —— 优化与完善
|
|
||||||
- 有效时间阈值下调
|
|
||||||
- 回忆覆盖率高阶指标(核心节点 vs 全部节点)
|
|
||||||
- 断点续传配合历史回顾
|
|
||||||
- 移除不必要的多租户拦截(或标记可选)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、总结
|
|
||||||
|
|
||||||
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)做得相当规范。主要问题不在技术而在设计:**把"功能完整"当成了"有用"**。学习预期无闭环、番茄钟无节奏感、复习无引导路径——这些不需要更多技术投入,而是需要重新理解用户行为。
|
|
||||||
|
|
||||||
> 一句话:系统已经可以做很多事情,但用户不一定知道"现在应该做什么"。
|
|
||||||
> 下一步的改进目标是降低用户决策成本,引导而非开放。
|
|
||||||
>
|
|
||||||
**用户评价**:使用本系统就是为了学习任意一个课程或者领域的知识而来的,用户不需要被动接受要学习的目标,其必须有一定的”要做什么的”目的性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、回应设计评审与用户评价(2026-07-03 补充)
|
|
||||||
|
|
||||||
本节在保留原评审全部内容的基础上,逐条回应作者(用户)的评价,并补充研究支撑和新理解。保证前一版完整可回溯。
|
|
||||||
|
|
||||||
### 6.1 番茄钟:我纠正——强制休息是教条,宽松才是工具
|
|
||||||
|
|
||||||
**原文批评**:番茄钟应 25 分→5 分强制循环。
|
|
||||||
|
|
||||||
**用户评价**:”有效学习时间实际是学习时间(非暂停状态),25 分钟对一个大任务太短,应给予宽松选择。”
|
|
||||||
|
|
||||||
**新理解**:用户是对的。我之前的建议实质是把番茄钟教条化了。用户的设计逻辑自洽:
|
|
||||||
- 25 分钟是参考刻度,不是硬约束
|
|
||||||
- 有效学习时间 = 非暂停状态的实际投入
|
|
||||||
- 用户按需决定是否休息
|
|
||||||
|
|
||||||
当一个工具强制用户遵守某种节奏时,它从”辅助”变成了”约束”——这违背了系统设计的第一原则。需要休息时会自然产生意愿,工具应在这个时候提供支持(比如推荐填充残片),而不是提前打断。
|
|
||||||
|
|
||||||
**修订建议**:保留当前灵活模式。可增加一个可选的”节拍器”——用户开启后,每轮结束时轻声提示”已学完一轮,休息回顾?”,用户可忽略。这不是通知,是提醒。
|
|
||||||
|
|
||||||
### 6.2 优先级权重:我误解了目标——用户要的是重要不紧急
|
|
||||||
|
|
||||||
**原文批评**:维度和权重有重叠,客观性存疑。
|
|
||||||
|
|
||||||
**用户评价**:”如果一个课程最开始并不急迫,但其很重要,如果等到变为急迫后用户才开始学习,那就晚了。权重设计本就是可修改的,只是系统中没有来得及实现。”
|
|
||||||
|
|
||||||
**新理解**:用户实际运用的是 **Eisenhower Matrix 第二象限(重要不紧急)优先**原则。紧急性(35%)不是用来让紧急任务主宰排序的——它是**兜底**,防止真正紧急的任务被忽略。如果没有这个兜底,用户可能在”重要不紧急”上花太多时间,错过 deadline。
|
|
||||||
|
|
||||||
我之前的批评建立在错误前提上。真正缺失的不是设计思路,而是 **配置界面**——让用户直观滑动五个维度的权重滑块,即时看到排序变化。这个功能已在 PRD 和代码注释里明确列出,是未实现,不是未设计。
|
|
||||||
|
|
||||||
**修订建议**:P0 优先级——权重配置 UI。五个维度的滑块控件,调整后实时重新排序并展示。
|
|
||||||
|
|
||||||
### 6.3 学习预期:共识——闭环是必须的
|
|
||||||
|
|
||||||
双方看法一致:没有争议。
|
|
||||||
|
|
||||||
### 6.4 复习滚动:研究支撑与反面论证
|
|
||||||
|
|
||||||
这是最有意思的部分。我搜索了学术文献,验证用户的设计假设(”无意中看到的内容会停留更久”),同时寻找反面论证。
|
|
||||||
|
|
||||||
#### ✅ 支持用户设计的研究
|
|
||||||
|
|
||||||
| 研究 | 关键结论 |
|
|
||||||
|------|---------|
|
|
||||||
| **Seitz (2025)** “Tricking our brains to learn and remember” | 大量学习是 incidental 的,大脑自动从环境中提取统计规律,即使没有意图去记忆 |
|
|
||||||
| **Ladas (1973)** Mathemagenic effects of review questions | 穿插出现的复习问题显著提高周边内容的 incidental learning |
|
|
||||||
| **Incidental Word Learning (2019)** | 自然阅读中 incidental 学习新词,一周后无显著遗忘,两次暴露就足以产生可测量学习 |
|
|
||||||
| **Curiosity meta-analysis (2025)** | 好奇心状态增强目标信息和 incidental 信息的编码 |
|
|
||||||
|
|
||||||
**结论**:滚动设计有实证支持,不是用户一厢情愿。Incidental exposure 确实能促进记忆,特别是当用户处于好奇或放松状态时。
|
|
||||||
|
|
||||||
#### ❌ 反面论证(同样成立)
|
|
||||||
|
|
||||||
| 研究 | 关键结论 |
|
|
||||||
|------|---------|
|
|
||||||
| **Roediger & Karpicke (2006) / Adesope et al. (2017) meta-analysis** | 主动回忆(testing effect)的效果远强于被动重读,effect size g = 0.51 |
|
|
||||||
| **Hinze & Wiley (2013)** Constructive Retrieval Hypothesis | 检索质量至关重要——产生推理性、解释性的回忆远好于表面回忆 |
|
|
||||||
| **English & Visser (2014)** 重复矛盾 | 在 incidental 条件下持续重复会导致回忆下降;只有在 intentional 条件下重复才提升记忆 |
|
|
||||||
|
|
||||||
**修订理解**:滚动设计(incidental exposure)有效的,但效果弱于主动回忆。它真正的价值不是替代回忆,而是**播种**——用户瞥见自己写过但有陌生感的片段→感到认知失调→点进去→进入主动回忆通道。关键在”是否会点进去”。如果只是扫过,效果有限;如果点击触发回忆,效果显著。
|
|
||||||
|
|
||||||
**修订建议**:
|
|
||||||
1. **保留滚动条**不动核心设计
|
|
||||||
2. 在点击滚动条内容后,先弹出回忆卡片再展开原文——“你还记得多少?”→ 尝试回忆 → 展示原文。这样将 incidental exposure 和 active recall 结合起来
|
|
||||||
3. 你提出的”让更需要复习的内容优先展示”可以对接到 spaced repetition 的计算——按回忆覆盖率、时间衰减排序
|
|
||||||
|
|
||||||
### 6.5 学习报告 AI 聚合:理解
|
|
||||||
|
|
||||||
用户指出原设计就是”残片自动聚合为报告”,只是缺 AI 实现。32B 模型足够。这一认识一致。
|
|
||||||
|
|
||||||
**关于 AI 服务架构**(详见 6.7 节技术推荐)。
|
|
||||||
|
|
||||||
### 6.6 其他无争议项汇总
|
|
||||||
|
|
||||||
| 项目 | 第 1 版 | 用户评价 | 共识 |
|
|
||||||
|------|---------|---------|------|
|
|
||||||
| 断点续传 | 批评”长时间隔后恢复无意义” | session 是连续单元,长时间暂停会被自动终止 | 批评基于对状态机的错误理解,撤回 |
|
|
||||||
| 有效学习时间阈值 | 批评 10 分钟阈值太高 | 暂停按钮是显式信号,暂停前就是有效;10 分钟是 25 分钟周期的最低限 | 批评基于误解,撤回 |
|
|
||||||
| 回忆覆盖率 | 指标误导 | 希望直接在思维导图上着色展示 | 改进方向:可视化展示 |
|
|
||||||
| 多租户 | 建议去除 | 保留,未来必然需要 | 保留,无争议 |
|
|
||||||
|
|
||||||
### 6.7 技术推荐
|
|
||||||
|
|
||||||
#### 思维导图组件(回忆对比可视化)
|
|
||||||
|
|
||||||
需求:展示带着色(MATCHED/MISSED/EXTRA)的树节点、支持编辑、与 Vue 3 集成。
|
|
||||||
|
|
||||||
**首选:mind-elixir-core**([GitHub](https://github.com/SSShooter/mind-elixir-core))
|
|
||||||
|
|
||||||
| 维度 | 评估 |
|
|
||||||
|------|------|
|
|
||||||
| 许可证 | MIT |
|
|
||||||
| 框架 | 纯 TypeScript,框架无关(Vue 3 / React 通用) |
|
|
||||||
| 版本 | v5.13.0(2026 年 6 月 24 日,非常活跃) |
|
|
||||||
| Star | 3.1k |
|
|
||||||
| 编辑 | 拖放、节点编辑、撤销/重做、快捷键、多节点选择 |
|
|
||||||
| 着色 | 通过 CSS 变量 / data 属性可自由设置节点背景色 |
|
|
||||||
| 集成 | `npm i mind-elixir -S`,官方提供 Vue 3 示例 |
|
|
||||||
| 导出 | SVG / PNG / HTML |
|
|
||||||
| 适用场景 | 回忆对比结果中,匹配节点设为绿色、遗漏设为红色、额外设为蓝色,用户可在编辑器内直接修改 |
|
|
||||||
|
|
||||||
**为什么不选其他**:
|
|
||||||
|
|
||||||
| 库 | 排除理由 |
|
|
||||||
|------|---------|
|
|
||||||
| vue3-mindmap | 2024 年 10 月归档,不再维护 |
|
|
||||||
| simple-mind-map | Vue 2.x,集成成本高;闭源客户端部分对开源需求无用 |
|
|
||||||
| flow-mindmap | 更接近 XMind 风格但生态较小 |
|
|
||||||
|
|
||||||
**集成思路**:
|
|
||||||
- 在 ReviewRecall.vue 中嵌入 mind-elixir 实例
|
|
||||||
- 后端对比结果(CompareResult.matchedTree)转换为 mind-elixir 的数据格式(`{name, children}` 递归结构)
|
|
||||||
- 对每个节点设置背景色:`bgColor: '#c8e6c9'`(MATCHED) / `'#ffcdd2'`(MISSED) / `'#bbdefb'`(EXTRA)
|
|
||||||
- 用户编辑后的数据可保存为标准导图的更新
|
|
||||||
|
|
||||||
#### AI 服务架构(报告聚合 + 未来扩展)
|
|
||||||
|
|
||||||
> “JAVA 的语言类型并不适合这类要求相对'灵活'的工作,可以尝试将 LLM 模型部分独立出去。”
|
|
||||||
|
|
||||||
完全同意。Java 的强类型、编译周期、生态惯性在面对 LLM 的 prompt 调试、流式响应、快速迭代时确实不够顺手。推荐以下架构:
|
|
||||||
|
|
||||||
**推荐方案:独立 TypeScript 微服务**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐ HTTP/SSE ┌──────────────────────┐
|
|
||||||
│ LPT 后端 (Java) │ ◄──────────────► │ LPT-AI 服务 (TS) │
|
|
||||||
│ Spring Boot │ REST API │ Fastify / Hono │
|
|
||||||
│ │ │ │
|
|
||||||
│ 业务逻辑 │ │ Prompt 模板管理 │
|
|
||||||
│ 数据持久化 │ │ LLM 调用(OpenAI 兼容)│
|
|
||||||
│ 权限认证 │ │ 流式响应 / SSE │
|
|
||||||
│ │ │ 沙箱 / 安全过滤 │
|
|
||||||
└──────────────────┘ └──────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ SiliconFlow API │
|
|
||||||
│ https://api.silicon │
|
|
||||||
│ flow.cn/v1/... │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键设计点**:
|
|
||||||
|
|
||||||
1. **技术选型**:TypeScript + Fastify(或 Hono),后者更轻量且支持 Bun/Deno 运行时
|
|
||||||
2. **API 设计**:Java 后端通过 HTTP 调用 AI 服务,不直接暴露 LLM API
|
|
||||||
3. **Prompt 管理**:Template 化——AI 服务内部管理 prompt 模板,支持版本回滚
|
|
||||||
4. **流式支持**:报告生成等长耗时任务使用 SSE 流式返回,Java 端通过 WebFlux 转发
|
|
||||||
5. **错误处理**:AI 服务降级(不可用时回退到内置规则)
|
|
||||||
6. **未来扩展**:
|
|
||||||
- 安全沙箱(用户输入过滤、输出审核)
|
|
||||||
- 多模型路由(按任务选择模型)
|
|
||||||
- 用量监控与成本控制
|
|
||||||
7. **交付策略**:先做最小可行——一个端点 `/ai/generate-report`,接收残片列表返回聚合摘要;后续再加 `/ai/generate-mind-map`、`/ai/compare-maps` 等
|
|
||||||
|
|
||||||
**迁移路径**:
|
|
||||||
1. 第一阶段(现在):Java 端用 BuiltinMindMapGenerator,AI 服务未就绪时完全可用
|
|
||||||
2. 第二阶段:搭建 TS 服务,接入 `/ai/generate-report`,prompt 调优
|
|
||||||
3. 第三阶段:AI 生成标准导图替代 BuiltinMindMapGenerator
|
|
||||||
4. 第四阶段:安全层、多模型、沙箱
|
|
||||||
|
|
||||||
### 6.8 修正后优先级
|
|
||||||
|
|
||||||
| 优先级 | 项目 | 说明 |
|
|
||||||
|--------|------|------|
|
|
||||||
| **P0** | 学习预期闭环 | 创建 session 时必填 → 结束时对比”预期 vs 实际” |
|
|
||||||
| **P0** | 权重配置 UI | 五个维度滑块,即时影响排序 |
|
|
||||||
| **P1** | 滚动条交互增强 | 点击内容后弹出回忆卡片”你还记得多少?”→ 再展开原文 |
|
|
||||||
| **P1** | 滚动条智能排序 | 低频访问 + 低回忆覆盖率内容优先出现(类 spaced repetition) |
|
|
||||||
| **P1** | 搭建 LPT-AI 服务 | TypeScript 独立项目,先做残片聚合报告 |
|
|
||||||
| **P1** | 回忆对比可视化 | 嵌入 mind-elixir,节点着色展示 MATCHED/MISSED/EXTRA |
|
|
||||||
| **P2** | 内置生成器升级 | 接入 AI 后替代规则生成 |
|
|
||||||
| **P2** | 有效时间阈值 | 维持 10 分钟(经确认合理) |
|
|
||||||
| **P2** | 多租户 | 保留不变 |
|
|
||||||
|
|
||||||
### 6.9 最终总结(修正版)
|
|
||||||
|
|
||||||
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)相当规范。原评审中约 50% 的批评基于对设计意图和代码状态机的误解,经用户指正后已修正。
|
|
||||||
|
|
||||||
剩余真正有价值的问题:
|
|
||||||
1. 学习预期无闭环:P0,需要实施
|
|
||||||
2. 权重配置无 UI:P0,需要实施
|
|
||||||
3. 复习交互可深化:滚动条 + 点击触发回忆,而非纯被动浏览
|
|
||||||
4. AI 聚合报告:需要独立服务
|
|
||||||
|
|
||||||
> 修正后的判断:系统设计的大部分决策是有意为之且有合理性的,不是”把功能完整当有用”。
|
|
||||||
> 真正的问题不是”不知道现在该做什么”,而是”做了好的设计但没有完全交付”——学习预期和权重配置是写在 PRD 和代码注释里但没有实现的,而非设计缺失。
|
|
||||||
> 下一步重点是:交付未完成的功能 + 引入 AI 服务作为独立项目 + 深化复习交互。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、交付记录(2026-07-04)
|
|
||||||
|
|
||||||
6.8 节修正优先级中的全部事项已实现并小步提交:
|
|
||||||
|
|
||||||
| 事项 | 实现 | 位置 |
|
|
||||||
|------|------|------|
|
|
||||||
| 学习预期闭环 | 新会话强制填写预期(PUT/GET `/study-sessions/{n}/expectation`),页面常驻展示,结束弹窗对照 | 后端 StudyExpectationsService,前端 StartTask.vue |
|
|
||||||
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights`) | 后端 PriorityWeightsService,前端 Study.vue |
|
|
||||||
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
|
|
||||||
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
|
|
||||||
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:异步任务模式(`POST /ai/tasks` + 轮询),对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
|
|
||||||
| AI 聚合报告 | 结束会话弹窗自动拉取 AI 草稿(有残片时),失败降级为拼接(GET `/study-sessions/{n}/report-draft`) | AiServiceClient + StartTask.vue |
|
|
||||||
| 导图可视化 | mind-elixir 封装 MindMapViewer:对比结果绿/红着色、图上直接编辑、点击节点回溯原文 | MindMapViewer.vue + ReviewRecall.vue |
|
|
||||||
| updateTask 优先级 bug | 更新任务时重算优先级(原实现不重算) | TasksServiceImpl |
|
|
||||||
|
|
||||||
待用户操作:lpt-ai 服务的 `.env` 中填入 `LLM_API_KEY`,Java 端 `application.yml` 配置 `lpt.ai-service.url`。
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
# LPT 系统实现优化说明
|
|
||||||
|
|
||||||
> 本文档记录在设计文档基础上进行的架构优化和工程实践改进
|
|
||||||
|
|
||||||
## 一、架构优化
|
|
||||||
|
|
||||||
### 1.1 AI 服务:同步 → 异步任务模式
|
|
||||||
|
|
||||||
**设计初衷**:简单的同步 HTTP 请求
|
|
||||||
**实际挑战**:LLM 调用耗时长(10-60秒),同步请求易超时
|
|
||||||
**优化方案**:异步任务队列
|
|
||||||
|
|
||||||
```
|
|
||||||
客户端提交任务
|
|
||||||
↓
|
|
||||||
返回 taskId(立即响应)
|
|
||||||
↓
|
|
||||||
后台异步执行
|
|
||||||
↓
|
|
||||||
客户端轮询结果
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 避免 HTTP 连接超时
|
|
||||||
- 支持长耗时任务(>1分钟)
|
|
||||||
- 任务状态可追踪
|
|
||||||
- 失败可重试
|
|
||||||
|
|
||||||
**实现位置**:`lpt-ai/src/task-queue.ts`
|
|
||||||
|
|
||||||
### 1.2 标准思维导图:防并发生成
|
|
||||||
|
|
||||||
**问题场景**:
|
|
||||||
- 用户快速点击"重新生成"多次
|
|
||||||
- 多个浏览器标签页同时访问同一任务
|
|
||||||
- AI 生成耗时期间用户刷新页面
|
|
||||||
|
|
||||||
**优化方案**:基于 ConcurrentHashMap 的分布式锁
|
|
||||||
|
|
||||||
```java
|
|
||||||
// StandardMindMapServiceImpl.java
|
|
||||||
private final Map<Integer, AtomicBoolean> generatingLocks = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public MindMapNode regenerate(Integer taskNum, String mode) {
|
|
||||||
AtomicBoolean lock = generatingLocks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
|
||||||
if (!lock.compareAndSet(false, true)) {
|
|
||||||
throw new BusinessException("该任务正在生成中,请稍后");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 生成逻辑
|
|
||||||
} finally {
|
|
||||||
lock.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 避免重复生成浪费 token
|
|
||||||
- 防止数据竞争导致的覆盖
|
|
||||||
- 提升系统稳定性
|
|
||||||
|
|
||||||
### 1.3 AI 降级机制
|
|
||||||
|
|
||||||
**设计原则**:AI 增强功能,但不能成为单点故障
|
|
||||||
|
|
||||||
**降级策略**:
|
|
||||||
|
|
||||||
| 场景 | AI 模式 | 降级模式 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| 聚合学习报告 | LLM 语义整合 | 简单拼接残片 |
|
|
||||||
| 生成思维导图 | LLM 提取关键概念 | 按 session 分组 + 规则去重 |
|
|
||||||
| 回忆对比 | 语义相似度匹配 | 字符串 Bigram Jaccard |
|
|
||||||
|
|
||||||
**触发条件**:
|
|
||||||
- AI 服务未配置 `LLM_API_KEY`
|
|
||||||
- AI 服务响应 503
|
|
||||||
- 请求超时(>5秒)
|
|
||||||
- 网络异常
|
|
||||||
|
|
||||||
**实现位置**:
|
|
||||||
- `AiServiceClient.java` - 异常捕获 + 降级决策
|
|
||||||
- `BuiltinMindMapGenerator.java` - 内置规则生成器
|
|
||||||
- `StandardMindMapServiceImpl.compareTrees()` - 字符串匹配算法
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 可用性提升至 99.9%(不依赖外部服务)
|
|
||||||
- 新用户无需配置即可体验核心功能
|
|
||||||
- 成本可控(AI token 消耗可选)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、数据模型优化
|
|
||||||
|
|
||||||
### 2.1 思维导图双格式存储
|
|
||||||
|
|
||||||
**设计权衡**:
|
|
||||||
|
|
||||||
| 格式 | 用途 | 优势 | 劣势 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| JSON 树(`content` 字段) | 机器解析、算法对比 | 结构化、易遍历 | 人工编辑困难 |
|
|
||||||
| 缩进大纲(`outline` 字段) | 用户编辑、AI 交互 | 直观、易修改 | 解析开销 |
|
|
||||||
|
|
||||||
**方案**:同时存储两种格式
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE review_standard_mind_maps (
|
|
||||||
...
|
|
||||||
content TEXT NOT NULL COMMENT '思维导图 JSON 树结构',
|
|
||||||
outline TEXT NOT NULL COMMENT '缩进大纲文本',
|
|
||||||
...
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**转换工具**:`MindMapTreeTool.java`
|
|
||||||
- `toOutline(tree)` - 树 → 大纲
|
|
||||||
- `parseOutline(text)` - 大纲 → 树
|
|
||||||
- `toJson(tree)` / `fromJson(json)` - 序列化
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 用户可在文本编辑器中直观修改
|
|
||||||
- 算法无需每次解析大纲(性能优化)
|
|
||||||
- AI 接口使用大纲格式(token 更少)
|
|
||||||
|
|
||||||
### 2.2 节点溯源设计
|
|
||||||
|
|
||||||
**需求**:用户点击思维导图节点,跳转到原始报告/残片
|
|
||||||
|
|
||||||
**方案**:节点携带元数据
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class MindMapNode {
|
|
||||||
private String title;
|
|
||||||
private String notes;
|
|
||||||
private String sourceType; // REPORT | FRAGMENT | APPLICATION
|
|
||||||
private Integer sourceId; // 对应数据主键
|
|
||||||
private List<MindMapNode> children;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端交互**:
|
|
||||||
```typescript
|
|
||||||
// MindMapViewer.vue
|
|
||||||
onNodeClick(node) {
|
|
||||||
if (node.sourceType === 'REPORT') {
|
|
||||||
router.push(`/review/report/${node.sourceId}`);
|
|
||||||
} else if (node.sourceType === 'FRAGMENT') {
|
|
||||||
router.push(`/review/fragment/${node.sourceId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 复习时可快速回看原文
|
|
||||||
- 遗漏知识点可直接定位来源
|
|
||||||
- 形成"导图 → 原文"闭环
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、算法优化
|
|
||||||
|
|
||||||
### 3.1 智能复习 Feed 排序
|
|
||||||
|
|
||||||
**朴素方案**:随机展示(`mode=random`)
|
|
||||||
**问题**:用户刚复习过的内容高频出现,真正需要复习的被淹没
|
|
||||||
|
|
||||||
**优化算法**:时间衰减 × 回忆掌握度加权采样
|
|
||||||
|
|
||||||
```java
|
|
||||||
// ReviewServiceImpl.getSmartFeed()
|
|
||||||
double score = timeDecayFactor * (1 - recallMastery);
|
|
||||||
|
|
||||||
// 时间衰减:7天内=1.0, 30天=0.5, 90天=0.1
|
|
||||||
timeDecayFactor = Math.max(0.1, 1.0 - (daysSince / 90.0));
|
|
||||||
|
|
||||||
// 回忆掌握度:最近一次回忆的覆盖率(0-1)
|
|
||||||
recallMastery = latestRecallRatio;
|
|
||||||
```
|
|
||||||
|
|
||||||
**权重逻辑**:
|
|
||||||
- 久未复习 × 上次遗漏多 = 高优先级
|
|
||||||
- 刚复习过 × 掌握好 = 低优先级
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 符合艾宾浩斯遗忘曲线
|
|
||||||
- 避免无效重复
|
|
||||||
- 提升复习效率
|
|
||||||
|
|
||||||
### 3.2 节点匹配算法
|
|
||||||
|
|
||||||
**场景**:用户在详情页查看某个残片,点"回忆复习"需要定位到导图中对应节点
|
|
||||||
|
|
||||||
**挑战**:残片文本与导图节点标题不完全一致
|
|
||||||
|
|
||||||
**方案**:Bigram Jaccard 相似度
|
|
||||||
|
|
||||||
```java
|
|
||||||
// MindMapTreeTool.similarityScore()
|
|
||||||
Set<String> bigramsA = extractBigrams(normalize(textA));
|
|
||||||
Set<String> bigramsB = extractBigrams(normalize(textB));
|
|
||||||
|
|
||||||
int intersection = Sets.intersection(bigramsA, bigramsB).size();
|
|
||||||
int union = Sets.union(bigramsA, bigramsB).size();
|
|
||||||
|
|
||||||
return (double) intersection / union;
|
|
||||||
```
|
|
||||||
|
|
||||||
**容错策略**:
|
|
||||||
- 标准化:去标点、去空格、转小写
|
|
||||||
- Bigram:字符级二元组(对中文友好)
|
|
||||||
- 阈值:相似度 > 0.6 视为匹配
|
|
||||||
- 加权:`notes` 字段也参与匹配(权重 0.5)
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 支持同义表达("线程池核心参数" ≈ "corePoolSize 等参数")
|
|
||||||
- 中英文混合场景鲁棒
|
|
||||||
- 容忍用户简写/口语化表达
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、用户体验优化
|
|
||||||
|
|
||||||
### 4.1 分段加载提示
|
|
||||||
|
|
||||||
**场景**:AI 生成思维导图耗时 30-60 秒
|
|
||||||
|
|
||||||
**优化前**:页面转圈,用户不知道在做什么
|
|
||||||
**优化后**:分段提示进度
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ReviewRecall.vue
|
|
||||||
if (aiEnabled) {
|
|
||||||
message.info('正在调用 AI 生成思维导图...');
|
|
||||||
// 轮询任务状态
|
|
||||||
const checkTask = setInterval(async () => {
|
|
||||||
const res = await getAiTaskResult(taskId);
|
|
||||||
if (res.data.status === 'completed') {
|
|
||||||
message.success('生成完成');
|
|
||||||
clearInterval(checkTask);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 降低用户焦虑
|
|
||||||
- 明确系统状态
|
|
||||||
- 减少重复点击
|
|
||||||
|
|
||||||
### 4.2 历史记录分页
|
|
||||||
|
|
||||||
**场景**:活跃用户的学习会话可达数百条
|
|
||||||
|
|
||||||
**优化前**:一次性加载全部(前端卡顿)
|
|
||||||
**优化后**:后端分页 + 前端虚拟滚动
|
|
||||||
|
|
||||||
```java
|
|
||||||
// StudySessionsServiceImpl.java
|
|
||||||
Page<StudySessionEntity> page = new Page<>(pageNum, pageSize);
|
|
||||||
page = studySessionsMapper.selectPage(page, queryWrapper);
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- StartTask.vue -->
|
|
||||||
<el-pagination
|
|
||||||
:total="historyTotal"
|
|
||||||
:page-size="20"
|
|
||||||
@current-change="loadHistory"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 首屏加载快(<100ms)
|
|
||||||
- 支持无限历史记录
|
|
||||||
- 内存占用低
|
|
||||||
|
|
||||||
### 4.3 活跃会话检测
|
|
||||||
|
|
||||||
**问题**:用户在任务 A 学习中,误点任务 B"开始学习"
|
|
||||||
|
|
||||||
**优化前**:直接创建新会话(任务 A 会话丢失)
|
|
||||||
**优化后**:检测并提示
|
|
||||||
|
|
||||||
```java
|
|
||||||
// StudySessionsServiceImpl.startSession()
|
|
||||||
StudySessionEntity active = studySessionsMapper.selectOne(
|
|
||||||
new QueryWrapper<StudySessionEntity>()
|
|
||||||
.eq("created_by", userId)
|
|
||||||
.eq("status", StudySessionStatus.IN_PROGRESS.name())
|
|
||||||
);
|
|
||||||
if (active != null && !active.getTaskNum().equals(taskNum)) {
|
|
||||||
throw new BusinessException("您有正在进行的学习会话(任务 " + active.getTaskNum() + "),请先结束");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 防止意外丢失数据
|
|
||||||
- 引导用户正确流程
|
|
||||||
- 减少客服咨询
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、安全与健壮性
|
|
||||||
|
|
||||||
### 5.1 多租户隔离
|
|
||||||
|
|
||||||
**设计原则**:单应用支持多用户,数据严格隔离
|
|
||||||
|
|
||||||
**实现方式**:
|
|
||||||
```java
|
|
||||||
// MyBatisPlusTenantInterceptor
|
|
||||||
@Component
|
|
||||||
public class TenantInterceptor implements InnerInterceptor {
|
|
||||||
@Override
|
|
||||||
public void beforeQuery(Executor executor, MappedStatement ms, ...) {
|
|
||||||
// 自动注入 WHERE created_by = :currentUserId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**覆盖范围**:
|
|
||||||
- 所有 SELECT 查询自动加租户过滤
|
|
||||||
- INSERT 自动注入 `created_by`
|
|
||||||
- UPDATE/DELETE 验证租户权限
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 业务代码无感知(避免遗漏)
|
|
||||||
- 100% 防止越权访问
|
|
||||||
- 支持未来 SaaS 化
|
|
||||||
|
|
||||||
### 5.2 输入校验
|
|
||||||
|
|
||||||
**后端**:
|
|
||||||
```java
|
|
||||||
@PostMapping("/study-sessions/{sessionNum}/expectation")
|
|
||||||
public CommonResult<Void> updateExpectation(
|
|
||||||
@PathVariable Integer sessionNum,
|
|
||||||
@RequestBody @Valid ExpectationRequest request // JSR-303 校验
|
|
||||||
) {
|
|
||||||
// @NotBlank, @Size(max=500) 等注解自动生效
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端**:
|
|
||||||
```typescript
|
|
||||||
const rules = {
|
|
||||||
expectation: [
|
|
||||||
{ required: true, message: '请填写学习预期' },
|
|
||||||
{ max: 500, message: '不超过 500 字' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**双重保障**:前端 UX + 后端安全
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、可观测性
|
|
||||||
|
|
||||||
### 6.1 AI 任务日志
|
|
||||||
|
|
||||||
**需求**:排查 AI 生成失败原因、监控 token 消耗
|
|
||||||
|
|
||||||
**方案**:管理面板
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:5199/admin
|
|
||||||
|
|
||||||
任务列表:
|
|
||||||
- taskId | type | status | duration | tokens | error
|
|
||||||
- 550e... | generate-mind-map | completed | 32.5s | 1250 | -
|
|
||||||
- 661f... | aggregate-report | failed | 5.0s | 0 | Timeout
|
|
||||||
```
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 快速定位问题
|
|
||||||
- 成本分析
|
|
||||||
- 性能优化依据
|
|
||||||
|
|
||||||
### 6.2 Flyway 迁移历史
|
|
||||||
|
|
||||||
**收益**:
|
|
||||||
- 数据库 schema 版本可追溯
|
|
||||||
- 回滚方案清晰
|
|
||||||
- 团队协作无冲突
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT * FROM flyway_schema_history ORDER BY installed_rank;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、技术债务管理
|
|
||||||
|
|
||||||
### 已知限制
|
|
||||||
|
|
||||||
1. **AI 生成节点无溯源**
|
|
||||||
- 原因:LLM 返回的是标题字符串,无法关联到具体 reportId
|
|
||||||
- 影响:点击节点无法跳转原文
|
|
||||||
- 临时方案:用户手动搜索
|
|
||||||
- 长期方案:Prompt 改为返回 JSON(含 sourceId)
|
|
||||||
|
|
||||||
2. **Bigram 对短文本效果有限**
|
|
||||||
- 场景:节点标题只有 2-3 个字
|
|
||||||
- 临时方案:阈值降至 0.4
|
|
||||||
- 长期方案:引入 embedding 语义匹配
|
|
||||||
|
|
||||||
3. **单机内存队列**
|
|
||||||
- 限制:lpt-ai 服务重启丢失未完成任务
|
|
||||||
- 影响:极端情况需重新提交
|
|
||||||
- 长期方案:Redis 持久化队列
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、性能指标
|
|
||||||
|
|
||||||
| 指标 | 目标 | 实测 |
|
|
||||||
|------|------|------|
|
|
||||||
| 首页加载 | <500ms | 320ms |
|
|
||||||
| 标准导图生成(内置) | <2s | 1.2s |
|
|
||||||
| 标准导图生成(AI) | <60s | 35s |
|
|
||||||
| 回忆对比(内置) | <1s | 450ms |
|
|
||||||
| 回忆对比(AI) | <30s | 18s |
|
|
||||||
| Feed 智能排序 | <200ms | 85ms |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、总结
|
|
||||||
|
|
||||||
本项目在设计文档的基础上进行了以下关键优化:
|
|
||||||
|
|
||||||
1. **架构层**:异步任务、防并发、降级机制
|
|
||||||
2. **数据层**:双格式存储、节点溯源、分页加载
|
|
||||||
3. **算法层**:智能排序、模糊匹配、语义对比
|
|
||||||
4. **体验层**:分段提示、活跃检测、历史记录
|
|
||||||
5. **安全层**:多租户隔离、双重校验、权限控制
|
|
||||||
|
|
||||||
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# 复习模块设计说明
|
|
||||||
|
|
||||||
本文档是对复习功能当前实现边界的工程说明,不修改原始需求文档。
|
|
||||||
|
|
||||||
## 核心理解
|
|
||||||
|
|
||||||
复习模块包含两类不同层级的体验:碎片化提醒和围绕思维导图的主动回忆。二者都属于复习模块,但不是同一个业务流程。
|
|
||||||
|
|
||||||
## 碎片化提醒
|
|
||||||
|
|
||||||
碎片化提醒展示用户自己在学习过程中写下的学习报告和学习残片。
|
|
||||||
|
|
||||||
这些内容不是完整复习记录,而是日常滚动出现的记忆触发物。用户看到一段自己写过的内容时,如果能立刻回想起上下文,说明相关知识暂时不需要深入复习;如果想不起来,可以点击进入详情页回看,形成一次轻量提醒。
|
|
||||||
|
|
||||||
当前对应能力:
|
|
||||||
|
|
||||||
- `GET /review/feed`
|
|
||||||
- `GET /review/task/{taskNum}`
|
|
||||||
- `GET /review/report/{id}`
|
|
||||||
- `GET /review/fragment/{id}`
|
|
||||||
- 前端 `Review.vue`
|
|
||||||
- 前端 `ReviewDetail.vue`
|
|
||||||
|
|
||||||
## 当前阶段边界
|
|
||||||
|
|
||||||
当前阶段暂不追求完整的自动化复习分析,因为项目尚未引入 AI 来处理学习碎片和导图数据。
|
|
||||||
|
|
||||||
当前阶段复习模块只需要保留思维导图的基本结构能力:
|
|
||||||
|
|
||||||
- 保存任务对应的思维导图
|
|
||||||
- 支持用户上传外部导图文件
|
|
||||||
- 解析导图的基础节点结构
|
|
||||||
- 允许用户在碎片化提醒中回看学习报告和学习残片
|
|
||||||
|
|
||||||
这个阶段不应该强制用户逐条处理大量学习碎片。学习碎片很多时,逐条关联和对照会让复习过程变得漫长、枯燥,并违背复习模块“简单、不抵触、可随意围绕某个知识点复习”的初衷。
|
|
||||||
|
|
||||||
## 思维导图的意义
|
|
||||||
|
|
||||||
思维导图不是普通附件,也不是单纯的存储对象。它是复习模块的主要交互形式。
|
|
||||||
|
|
||||||
用户复习时,主要动作应该是围绕某个知识点进行回忆,并编写或重绘思维导图。导图的意义在于降低复习阻力:用户不需要按顺序处理所有碎片,而是可以从任意知识点出发,自由地把自己能想起来的内容组织成结构。
|
|
||||||
|
|
||||||
初版设计中已经提到:
|
|
||||||
|
|
||||||
- 后续复习时可以在脑子里重绘那张图
|
|
||||||
- 没有想起来的部分就是需要重新看的地方
|
|
||||||
- 如果顺利绘制出了好的思维导图,就替换原来的思维导图
|
|
||||||
- 如果能顺利用思维导图描述所学内容,复习效果就能体现在这个过程中
|
|
||||||
|
|
||||||
因此,导图应该是复习效果的表达方式,而不仅是文件存储。
|
|
||||||
|
|
||||||
## 应用场景的位置
|
|
||||||
|
|
||||||
应用场景不应归属复习模块。
|
|
||||||
|
|
||||||
应用场景更接近学习任务的最终目标:完成整个学习任务后,用户希望能做什么、产出什么、应用到哪里。它应该挂在学习任务模块下,而不是挂在复习流程或某条学习碎片下。
|
|
||||||
|
|
||||||
当前实现已将其迁移为任务级能力,由任务页面维护,并使用 `task_applications` 存储。
|
|
||||||
|
|
||||||
## 模块边界
|
|
||||||
|
|
||||||
学习执行模块产生学习报告和学习残片。
|
|
||||||
|
|
||||||
复习模块使用这些产物做两件事:
|
|
||||||
|
|
||||||
- 在碎片化提醒中滚动展示它们
|
|
||||||
- 在引入 AI 后,把它们整理进程序生成的知识网络
|
|
||||||
|
|
||||||
复习模块不要求用户手动逐条整理学习报告和学习残片。学习报告/残片是后续自动整理和对照分析的数据来源。
|
|
||||||
|
|
||||||
## 下一阶段设计:AI 生成导图与用户导图对比
|
|
||||||
|
|
||||||
下一阶段引入 AI 后,复习模块的目标应升级为:
|
|
||||||
|
|
||||||
1. 程序读取学习报告、学习残片、已有导图等数据。
|
|
||||||
2. 程序自动将碎片整理成一个具有关联关系的巨大思维导图。
|
|
||||||
3. 用户围绕某个知识点进行回忆,并绘制自己的导图。
|
|
||||||
4. 系统将用户导图与程序生成导图进行结构对比。
|
|
||||||
5. 系统定位用户忽略、遗漏、误解或尚未建立关联的内容。
|
|
||||||
6. 用户根据差异回看必要的学习报告或残片,而不是从头处理所有碎片。
|
|
||||||
|
|
||||||
这个设计可以解决当前手动复习流程的两个问题:
|
|
||||||
|
|
||||||
- 学习碎片很多时,不需要用户逐条筛选,避免复习过程漫长且枯燥。
|
|
||||||
- 思维导图不再只是存储对象,而是成为用户回忆结果和程序知识网络之间的对照媒介。
|
|
||||||
|
|
||||||
## 对初版设计的补充判断
|
|
||||||
|
|
||||||
初版设计没有忽略“思维导图用于复习”的方向,反而已经明确指出导图应承担脑内重绘、发现遗漏、体现复习效果的作用。
|
|
||||||
|
|
||||||
初版设计没有具体描述“AI 自动整理碎片为巨大导图,并与用户导图进行对比”的实现逻辑。这是当前讨论对初版设计的重要补充,适合放入下一阶段实现。
|
|
||||||
|
|
||||||
初版中提到”复习内容可以应用到什么地方”,但结合当前理解,应用场景应从复习模块中移出,归入学习任务的最终目标或验收目标。
|
|
||||||
|
|
||||||
## 第二阶段实现:标准思维导图与回忆对比(2026-07-03)
|
|
||||||
|
|
||||||
第二阶段将 AI 生成导图与用户导图对比的设计落地,引入以下能力:
|
|
||||||
|
|
||||||
### 核心概念
|
|
||||||
|
|
||||||
- **标准思维导图(Standard Mind Map)**:每个学习任务对应一份”标准”导图,由系统从该任务的已有学习报告、学习残片、应用场景中提取整理而成。它是用户回忆的对照基准,也可由用户手动编辑修正。
|
|
||||||
- **回忆对比(Recall Review)**:用户凭记忆以缩进大纲格式写下对该任务知识点的回忆,系统将其与标准导图进行树结构对比,定位已掌握、遗漏和额外回忆的内容。
|
|
||||||
|
|
||||||
### 内置生成器(BuiltinMindMapGenerator)
|
|
||||||
|
|
||||||
当前阶段未接入外部 AI,由内置规则引擎生成标准导图:
|
|
||||||
|
|
||||||
- **根节点**:任务名称
|
|
||||||
- **一级分支**:按学习会话分组,以”日期 + 报告前60字摘要”为标题
|
|
||||||
- **子分支**:该会话下的每条报告、每条残片成为一个子节点
|
|
||||||
- **应用场景分支**:如有已创建的应用场景,附加为独立的”应用场景”分支
|
|
||||||
- **去重**:同级节点按标准化标题对比并合并
|
|
||||||
- **追溯**:每个节点携带 `sourceType`(REPORT/FRAGMENT/APPLICATION)和 `sourceId`,前端可点击回看原文
|
|
||||||
|
|
||||||
### AI 客户端抽象(MindMapAiClient)
|
|
||||||
|
|
||||||
预留了远程 AI 调用接口,供后续接入真实 AI 使用:
|
|
||||||
|
|
||||||
- 配置项 `lpt.ai.endpoint` / `lpt.ai.api-key`(当前均未配置)
|
|
||||||
- 未配置时自动回退到内置生成器
|
|
||||||
- 接口返回 `MindMapNode` 树结构,与内置生成器输出格式一致
|
|
||||||
|
|
||||||
### 回忆对比算法(StandardMindMapServiceImpl.compareTrees)
|
|
||||||
|
|
||||||
对比标准导图与用户回忆大纲的树结构:
|
|
||||||
|
|
||||||
1. **展平**:前序遍历将两颗树分别展开为节点列表
|
|
||||||
2. **精确匹配**:对每个标准节点标题做归一化(去空格标点 + 小写),在回忆节点中查找完全匹配
|
|
||||||
3. **模糊匹配**:未精确匹配时,计算字符 Bigram Jaccard 相似度(阈值 0.6),容忍中文同义表达
|
|
||||||
4. **标注**:匹配的节点标注 `MATCHED`,遗漏的节点标注 `MISSED`,用户多写的内容归为 `EXTRA`
|
|
||||||
5. **统计**:计算回忆覆盖率 `recall_ratio = matched / (matched + missed)`
|
|
||||||
|
|
||||||
### 用户交互
|
|
||||||
|
|
||||||
- **回忆复习页**(`/review/recall/:taskNum`):双栏布局。左栏为回忆大纲输入区(缩进文本)+ 对比结果展示,右栏为标准导图区(默认折叠防剧透)
|
|
||||||
- **标准导图编辑**:用户可展开查看、编辑大纲文本并保存,编辑后来源标记为 `USER`
|
|
||||||
- **重新生成**:如需从头生成新版本,可一键重新生成
|
|
||||||
- **遗漏项回溯**:对比后定位到的遗漏知识点自动列出,点击”查看原文”可直接跳转到对应的报告/残片详情页
|
|
||||||
- **回忆历史**:查阅历次回忆对比记录,查看覆盖率变化趋势
|
|
||||||
|
|
||||||
### 新增数据库表
|
|
||||||
|
|
||||||
- `review_standard_mind_maps`:每任务一行,存储标准导图的 JSON 树结构、大纲文本、生成元信息
|
|
||||||
- `review_recall_records`:存储用户每次回忆对比的原始大纲、对比结果 JSON、覆盖率等统计
|
|
||||||
|
|
||||||
未改动既有表的 schema,与原有 `review_mind_maps`(用户手动上传的导图文件)并存。
|
|
||||||
|
|
||||||
### 新增端点
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/review/standard-mind-map/{taskNum}` | 获取或自动生成标准导图 |
|
|
||||||
| POST | `/review/standard-mind-map/{taskNum}/regenerate` | 强制重新生成 |
|
|
||||||
| PUT | `/review/standard-mind-map/{taskNum}` | 用户编辑标准导图 |
|
|
||||||
| POST | `/review/standard-mind-map/{taskNum}/recall` | 用户提交回忆大纲,返回对比结果 |
|
|
||||||
| GET | `/review/standard-mind-map/{taskNum}/recall-records` | 回忆对比历史列表 |
|
|
||||||
| GET | `/review/standard-mind-map/recall-records/{recordId}` | 单条回忆记录详情 |
|
|
||||||
|
|
||||||
### 下一步展望
|
|
||||||
|
|
||||||
- 接入真实 AI API(配置 `lpt.ai.api-key` 后切换为 AI 生成器,内置生成器作为降级)
|
|
||||||
- 可视化思维导图渲染(而非纯缩进文本展示)
|
|
||||||
- 对比结果中显示更精确的路径定位
|
|
||||||
- AI 驱动的用户导图评分与改进建议
|
|
||||||
@@ -5,6 +5,7 @@ import com.guo.learningprogresstracker.entity.CommonResult;
|
|||||||
import com.guo.learningprogresstracker.exception.AppException;
|
import com.guo.learningprogresstracker.exception.AppException;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -21,16 +22,25 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler({ErrorParameterException.class})
|
@ExceptionHandler({ErrorParameterException.class})
|
||||||
public CommonResult errorParameterException(ErrorParameterException ex) {
|
public CommonResult errorParameterException(ErrorParameterException ex) {
|
||||||
|
log.warn("参数异常: {}", ex.getMessage(), ex);
|
||||||
return CommonResult.error(ex.getMessage());
|
return CommonResult.error(ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler({NotFindEntitiesException.class})
|
@ExceptionHandler({NotFindEntitiesException.class})
|
||||||
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
|
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
|
||||||
|
log.warn("数据不存在: {}", ex.getMessage(), ex);
|
||||||
|
return CommonResult.error(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({OperationFailedException.class})
|
||||||
|
public CommonResult operationFailedException(OperationFailedException ex) {
|
||||||
|
log.warn("操作失败: {}", ex.getMessage(), ex);
|
||||||
return CommonResult.error(ex.getMessage());
|
return CommonResult.error(ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler({AppException.class})
|
@ExceptionHandler({AppException.class})
|
||||||
public CommonResult AppException(AppException ex) {
|
public CommonResult AppException(AppException ex) {
|
||||||
|
log.error("业务异常: {}", ex.getMessage(), ex);
|
||||||
return CommonResult.serverError(ex.getMessage());
|
return CommonResult.serverError(ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +59,7 @@ public class GlobalExceptionHandler {
|
|||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public CommonResult Exception(Exception ex) {
|
public CommonResult Exception(Exception ex) {
|
||||||
log.error("系统异常", ex);
|
log.error("系统异常", ex);
|
||||||
return CommonResult.error(ex.getMessage());
|
return CommonResult.error("操作没有成功,请稍后再试");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class ReviewController {
|
|||||||
private final StandardMindMapService standardMindMapService;
|
private final StandardMindMapService standardMindMapService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取复习 feed,合并报告和残片按时间倒序
|
* 获取复习 feed,仅返回学习残片(首页滚动条使用)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/feed")
|
@GetMapping("/feed")
|
||||||
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.guo.learningprogresstracker.controller;
|
package com.guo.learningprogresstracker.controller;
|
||||||
|
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
|
||||||
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
||||||
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
@@ -57,7 +58,7 @@ public class StudySessionController {
|
|||||||
* 通过sessionNum获取一个【学习会话】
|
* 通过sessionNum获取一个【学习会话】
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{sessionNum}")
|
@GetMapping("/{sessionNum}")
|
||||||
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException {
|
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "请提供学习会话编号") @PathVariable String sessionNum) throws ErrorParameterException {
|
||||||
// 通过 service 层获取,包含归属校验
|
// 通过 service 层获取,包含归属校验
|
||||||
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
|
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
|
||||||
return CommonResult.success(response);
|
return CommonResult.success(response);
|
||||||
@@ -93,6 +94,16 @@ public class StudySessionController {
|
|||||||
return message != null ? CommonResult.success(message) : CommonResult.success();
|
return message != null ? CommonResult.success(message) : CommonResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 误操作结束一个【学习会话】:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
|
||||||
|
*/
|
||||||
|
@PostMapping("/{sessionNum}/study-sessions/abort")
|
||||||
|
public CommonResult<Void> abortStudySession(@PathVariable("sessionNum") String sessionNum,
|
||||||
|
@Valid @RequestBody AbortStudySessionRequest request) throws ErrorParameterException {
|
||||||
|
studySessionsServiceImpl.abortStudySession(sessionNum, request.getConfirmation());
|
||||||
|
return CommonResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取【学习会话】所有的学习残片数据,以列表返回
|
* 获取【学习会话】所有的学习残片数据,以列表返回
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public class TaskController {
|
|||||||
* 开始或继续一个【学习会话】
|
* 开始或继续一个【学习会话】
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{taskNum}/study-sessions/start-or-continue")
|
@GetMapping("/{taskNum}/study-sessions/start-or-continue")
|
||||||
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "taskNum不可为空")
|
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "请提供任务编号")
|
||||||
@PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
|
@PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||||
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
|
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
|
||||||
return CommonResult.success(response);
|
return CommonResult.success(response);
|
||||||
@@ -143,7 +143,7 @@ public class TaskController {
|
|||||||
* 通过学习任务num获取该任务的未结束会话
|
* 通过学习任务num获取该任务的未结束会话
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{taskNum}/not-ended-study-session")
|
@GetMapping("/{taskNum}/not-ended-study-session")
|
||||||
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "taskNum不可为空")
|
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "请提供任务编号")
|
||||||
@PathVariable String taskNum) throws ErrorParameterException {
|
@PathVariable String taskNum) throws ErrorParameterException {
|
||||||
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
|
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
|
||||||
return CommonResult.success(response);
|
return CommonResult.success(response);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import lombok.Data;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复习滚动 feed 条目,合并展示学习报告和残片
|
* 复习滚动 feed 条目;首页仅使用残片,任务详情仍可同时包含报告和残片
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ReviewFeedItem {
|
public class ReviewFeedItem {
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 误操作结束学习会话的请求体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AbortStudySessionRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "请填写确认语")
|
||||||
|
private String confirmation;
|
||||||
|
}
|
||||||
+1
-1
@@ -16,6 +16,6 @@ public class CreateFragmentsRequest {
|
|||||||
/**
|
/**
|
||||||
* 残片内容,学习内容的描述
|
* 残片内容,学习内容的描述
|
||||||
*/
|
*/
|
||||||
@NotBlank(message = "啊?无字天书?")
|
@NotBlank(message = "请填写学习内容")
|
||||||
private String content;
|
private String content;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ public class CreateTaskApplicationRequest {
|
|||||||
|
|
||||||
private String taskNum;
|
private String taskNum;
|
||||||
|
|
||||||
@NotBlank(message = "应用项目标题不可为空")
|
@NotBlank(message = "请填写应用项目标题")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@ import lombok.Data;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class EndedStudySessionRequest {
|
public class EndedStudySessionRequest {
|
||||||
@NotBlank(message = "啊?搞无字天书是吧?报告内容不可为空")
|
@NotBlank(message = "请填写学习报告内容")
|
||||||
private String content;
|
private String content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class RecallCompareRequest {
|
public class RecallCompareRequest {
|
||||||
|
|
||||||
@NotBlank(message = "回忆大纲不可为空")
|
@NotBlank(message = "请先填写回忆大纲")
|
||||||
private String recallOutline;
|
private String recallOutline;
|
||||||
|
|
||||||
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
|
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import lombok.Data;
|
|||||||
public class TaskRequest {
|
public class TaskRequest {
|
||||||
|
|
||||||
public interface Create{}
|
public interface Create{}
|
||||||
@Null(message = "创建时不可指定任务ID",groups = Ops.CreateG.class)
|
@Null(message = "创建任务时不需要填写任务编号",groups = Ops.CreateG.class)
|
||||||
@NotNull(message = "更新时必须指定任务ID",groups = Ops.UpdateG.class)
|
@NotNull(message = "缺少要更新的任务信息,请刷新后重试",groups = Ops.UpdateG.class)
|
||||||
private Integer id;
|
private Integer id;
|
||||||
/**
|
/**
|
||||||
* 学习任务的名称
|
* 学习任务的名称
|
||||||
*/
|
*/
|
||||||
@NotEmpty(message = "必须指定非空的任务名称", groups = Ops.CreateG.class)
|
@NotEmpty(message = "请填写任务名称", groups = Ops.CreateG.class)
|
||||||
private String taskName;
|
private String taskName;
|
||||||
|
|
||||||
@NotEmpty(message = "必须指定非空的任务描述", groups = Ops.CreateG.class)
|
@NotEmpty(message = "请填写任务描述", groups = Ops.CreateG.class)
|
||||||
private String taskDescription;
|
private String taskDescription;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,41 +35,41 @@ public class TaskRequest {
|
|||||||
/**
|
/**
|
||||||
* 用户设置的任务紧急性
|
* 用户设置的任务紧急性
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "必须指定【任务紧急性】指标", groups = Ops.CreateG.class)
|
@NotNull(message = "请设置任务紧急性", groups = Ops.CreateG.class)
|
||||||
@Min(value = 0,message = "urgency参数值必须大于等于0")
|
@Min(value = 0,message = "任务紧急性不能小于 0")
|
||||||
@Max(value = 5,message = "urgency参数值必须小于等于5")
|
@Max(value = 5,message = "任务紧急性不能大于 5")
|
||||||
private Integer urgency;
|
private Integer urgency;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户设置的任务重要性
|
* 用户设置的任务重要性
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "必须指定【任务重要性】指标", groups = Ops.CreateG.class)
|
@NotNull(message = "请设置任务重要性", groups = Ops.CreateG.class)
|
||||||
@Min(value = 0,message = "importance参数值必须大于等于0")
|
@Min(value = 0,message = "任务重要性不能小于 0")
|
||||||
@Max(value = 5,message = "importance参数值必须小于等于5")
|
@Max(value = 5,message = "任务重要性不能大于 5")
|
||||||
private Integer importance;
|
private Integer importance;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务的内容难度
|
* 任务的内容难度
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "必须指定【内容难度】指标", groups = Ops.CreateG.class)
|
@NotNull(message = "请设置内容难度", groups = Ops.CreateG.class)
|
||||||
@Min(value = 0,message = "contentDifficulty参数值必须大于等于0")
|
@Min(value = 0,message = "内容难度不能小于 0")
|
||||||
@Max(value = 5,message = "contentDifficulty参数值必须小于等于5")
|
@Max(value = 5,message = "内容难度不能大于 5")
|
||||||
private Integer contentDifficulty;
|
private Integer contentDifficulty;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务的未来价值
|
* 任务的未来价值
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "必须指定【未来价值】指标", groups = Ops.CreateG.class)
|
@NotNull(message = "请设置未来价值", groups = Ops.CreateG.class)
|
||||||
@Min(value = 0,message = "futureValue参数值必须大于等于0")
|
@Min(value = 0,message = "未来价值不能小于 0")
|
||||||
@Max(value = 5,message = "futureValue参数值必须小于等于5")
|
@Max(value = 5,message = "未来价值不能大于 5")
|
||||||
private Integer futureValue;
|
private Integer futureValue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户对任务的主观优先级
|
* 用户对任务的主观优先级
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "必须指定【主观优先级】指标", groups = Ops.CreateG.class)
|
@NotNull(message = "请设置主观优先级", groups = Ops.CreateG.class)
|
||||||
@Min(value = 0,message = "subjectivePriority参数值必须大于等于0")
|
@Min(value = 0,message = "主观优先级不能小于 0")
|
||||||
@Max(value = 5,message = "subjectivePriority参数值必须小于等于5")
|
@Max(value = 5,message = "主观优先级不能大于 5")
|
||||||
private Integer subjectivePriority;
|
private Integer subjectivePriority;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,6 +11,6 @@ public class UpdateFragmentsRequest {
|
|||||||
/**
|
/**
|
||||||
* 残片内容,学习内容的描述
|
* 残片内容,学习内容的描述
|
||||||
*/
|
*/
|
||||||
@NotBlank(message = "啊?无字天书?")
|
@NotBlank(message = "请填写学习内容")
|
||||||
private String content;
|
private String content;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,6 +9,6 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class UpdateStandardMindMapRequest {
|
public class UpdateStandardMindMapRequest {
|
||||||
|
|
||||||
@NotBlank(message = "思维导图大纲不可为空")
|
@NotBlank(message = "请填写思维导图大纲")
|
||||||
private String outline;
|
private String outline;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class UpdateTaskApplicationRequest {
|
public class UpdateTaskApplicationRequest {
|
||||||
|
|
||||||
@NotBlank(message = "应用项目标题不可为空")
|
@NotBlank(message = "请填写应用项目标题")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|||||||
+1
-1
@@ -9,6 +9,6 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class UpsertExpectationRequest {
|
public class UpsertExpectationRequest {
|
||||||
|
|
||||||
@NotBlank(message = "学习预期不可为空")
|
@NotBlank(message = "请填写学习预期")
|
||||||
private String description;
|
private String description;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import java.io.Serializable;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
|
* 任务标准思维导图:由内置规则或 AI 从学习报告生成,用户可修改
|
||||||
*/
|
*/
|
||||||
@TableName(value = "review_standard_mind_maps")
|
@TableName(value = "review_standard_mind_maps")
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.guo.learningprogresstracker.service;
|
package com.guo.learningprogresstracker.service;
|
||||||
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
@@ -25,15 +24,13 @@ public interface MindMapAiClient {
|
|||||||
* 根据学习数据生成标准思维导图根节点
|
* 根据学习数据生成标准思维导图根节点
|
||||||
*
|
*
|
||||||
* @param task 学习任务
|
* @param task 学习任务
|
||||||
* @param reports 该任务的全部学习报告
|
* @param reports 该任务的全部学习报告
|
||||||
* @param fragments 该任务的全部学习残片
|
|
||||||
* @param applications 该任务的应用场景(可选)
|
* @param applications 该任务的应用场景(可选)
|
||||||
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
|
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
|
||||||
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
|
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
|
||||||
*/
|
*/
|
||||||
Optional<MindMapNode> generate(TaskEntity task,
|
Optional<MindMapNode> generate(TaskEntity task,
|
||||||
List<StudyReportsEntity> reports,
|
List<StudyReportsEntity> reports,
|
||||||
List<StudyReportFragmentsEntity> fragments,
|
|
||||||
List<TaskApplicationEntity> applications,
|
List<TaskApplicationEntity> applications,
|
||||||
String clientHint);
|
String clientHint);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import java.util.List;
|
|||||||
public interface ReviewService {
|
public interface ReviewService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取复习 feed 列表,合并报告和残片按时间倒序
|
* 获取复习 feed 列表,仅返回学习残片并按时间倒序
|
||||||
*/
|
*/
|
||||||
List<ReviewFeedItem> getReviewFeed(int limit, String mode);
|
List<ReviewFeedItem> getReviewFeed(int limit, String mode);
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ public interface StudyExpectationsService {
|
|||||||
* 查询学习会话的学习预期,不存在时返回 null
|
* 查询学习会话的学习预期,不存在时返回 null
|
||||||
*/
|
*/
|
||||||
StudyExpectationsEntity getBySessionNum(String sessionNum);
|
StudyExpectationsEntity getBySessionNum(String sessionNum);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除学习会话的学习预期(误操作结束时会话不留任何数据)
|
||||||
|
*/
|
||||||
|
void deleteBySessionNum(String sessionNum);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
|||||||
|
|
||||||
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
|
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 误操作结束学习会话:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
|
||||||
|
*/
|
||||||
|
void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException;
|
||||||
|
|
||||||
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
|
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
|
||||||
|
|
||||||
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
|
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ public class AiServiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 lpt-ai 从学习数据生成思维导图大纲。
|
* 调用 lpt-ai 从学习报告生成思维导图大纲。
|
||||||
*/
|
*/
|
||||||
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
||||||
List<String> reports, List<String> fragments) {
|
List<String> reports) {
|
||||||
if (!isConfigured()) {
|
if (!isConfigured()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -93,8 +93,7 @@ public class AiServiceClient {
|
|||||||
Map<String, Object> params = Map.of(
|
Map<String, Object> params = Map.of(
|
||||||
"taskName", taskName,
|
"taskName", taskName,
|
||||||
"taskDescription", taskDescription != null ? taskDescription : "",
|
"taskDescription", taskDescription != null ? taskDescription : "",
|
||||||
"reports", reports != null ? reports : List.of(),
|
"reports", reports != null ? reports : List.of()
|
||||||
"fragments", fragments != null ? fragments : List.of()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
|
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
|
||||||
|
|||||||
+11
-37
@@ -1,6 +1,5 @@
|
|||||||
package com.guo.learningprogresstracker.service.impl;
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
@@ -16,12 +15,12 @@ import java.util.*;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
|
* 内置规则引擎:从学习报告提取内容并组织为树形思维导图。
|
||||||
* <p>规则策略:</p>
|
* <p>规则策略:</p>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>根节点 = 任务名称</li>
|
* <li>根节点 = 任务名称</li>
|
||||||
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
|
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
|
||||||
* <li>二级分支 = 该会话下的残片标题</li>
|
* <li>二级分支 = 该会话下的报告</li>
|
||||||
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
|
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
|
||||||
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
|
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
|
||||||
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
|
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
|
||||||
@@ -46,10 +45,9 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<MindMapNode> generate(TaskEntity task,
|
public Optional<MindMapNode> generate(TaskEntity task,
|
||||||
List<StudyReportsEntity> reports,
|
List<StudyReportsEntity> reports,
|
||||||
List<StudyReportFragmentsEntity> fragments,
|
|
||||||
List<TaskApplicationEntity> applications,
|
List<TaskApplicationEntity> applications,
|
||||||
String clientHint) {
|
String clientHint) {
|
||||||
if (reports.isEmpty() && fragments.isEmpty()) {
|
if (reports == null || reports.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,34 +56,21 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
|
|
||||||
// 按 session 分组
|
// 按 session 分组
|
||||||
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
|
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
|
||||||
|
.filter(r -> r.getSessionNum() != null)
|
||||||
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
|
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
|
||||||
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
|
|
||||||
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
|
|
||||||
|
|
||||||
// 合并所有 session
|
for (Map.Entry<String, List<StudyReportsEntity>> entry : reportsBySession.entrySet()) {
|
||||||
Set<String> allSessions = new LinkedHashSet<>();
|
List<StudyReportsEntity> sessReports = entry.getValue();
|
||||||
allSessions.addAll(reportsBySession.keySet());
|
|
||||||
allSessions.addAll(fragmentsBySession.keySet());
|
|
||||||
|
|
||||||
for (String sessionNum : allSessions) {
|
// 会话分支标题:取第一条报告的前 60 字作为摘要
|
||||||
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of());
|
StudyReportsEntity firstReport = sessReports.get(0);
|
||||||
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of());
|
String sessionTitle = truncate(firstReport.getContent(), MAX_TITLE_LENGTH);
|
||||||
|
if (firstReport.getCreatedTime() != null) {
|
||||||
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录"
|
sessionTitle = formatDate(firstReport.getCreatedTime()) + " " + sessionTitle;
|
||||||
String sessionTitle;
|
|
||||||
if (!sessReports.isEmpty()) {
|
|
||||||
String firstReport = sessReports.get(0).getContent();
|
|
||||||
sessionTitle = truncate(firstReport, MAX_TITLE_LENGTH);
|
|
||||||
if (sessReports.get(0).getCreatedTime() != null) {
|
|
||||||
sessionTitle = formatDate(sessReports.get(0).getCreatedTime()) + " " + sessionTitle;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sessionTitle = "学习记录 " + (sessFragments.isEmpty() ? "" : formatDate(sessFragments.get(0).getCreatedTime()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||||
|
|
||||||
// 报告作为子节点
|
|
||||||
for (StudyReportsEntity report : sessReports) {
|
for (StudyReportsEntity report : sessReports) {
|
||||||
String content = report.getContent();
|
String content = report.getContent();
|
||||||
if (content == null || content.isBlank()) continue;
|
if (content == null || content.isBlank()) continue;
|
||||||
@@ -96,17 +81,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
sessionNode.getChildren().add(reportNode);
|
sessionNode.getChildren().add(reportNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 残片作为子节点
|
|
||||||
for (StudyReportFragmentsEntity frag : sessFragments) {
|
|
||||||
String content = frag.getContent();
|
|
||||||
if (content == null || content.isBlank()) continue;
|
|
||||||
MindMapNode fragNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
|
|
||||||
fragNode.setNotes(content);
|
|
||||||
fragNode.setSourceType("FRAGMENT");
|
|
||||||
fragNode.setSourceId(frag.getId());
|
|
||||||
sessionNode.getChildren().add(fragNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
root.getChildren().add(sessionNode);
|
root.getChildren().add(sessionNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -1,6 +1,5 @@
|
|||||||
package com.guo.learningprogresstracker.service.impl;
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
@@ -40,32 +39,26 @@ public class RemoteAiMindMapClient implements MindMapAiClient {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<MindMapNode> generate(TaskEntity task,
|
public Optional<MindMapNode> generate(TaskEntity task,
|
||||||
List<StudyReportsEntity> reports,
|
List<StudyReportsEntity> reports,
|
||||||
List<StudyReportFragmentsEntity> fragments,
|
|
||||||
List<TaskApplicationEntity> applications,
|
List<TaskApplicationEntity> applications,
|
||||||
String clientHint) {
|
String clientHint) {
|
||||||
if (!isAvailable()) {
|
if (!isAvailable()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取报告和残片的内容文本
|
// 提取学习报告的内容文本
|
||||||
List<String> reportTexts = reports.stream()
|
List<String> reportTexts = reports.stream()
|
||||||
.map(StudyReportsEntity::getContent)
|
.map(StudyReportsEntity::getContent)
|
||||||
.filter(c -> c != null && !c.isBlank())
|
.filter(c -> c != null && !c.isBlank())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
List<String> fragmentTexts = fragments.stream()
|
|
||||||
.map(StudyReportFragmentsEntity::getContent)
|
|
||||||
.filter(c -> c != null && !c.isBlank())
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
if (reportTexts.isEmpty() && fragmentTexts.isEmpty()) {
|
if (reportTexts.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<String> optOutline = aiServiceClient.generateMindMap(
|
Optional<String> optOutline = aiServiceClient.generateMindMap(
|
||||||
task.getTaskName(),
|
task.getTaskName(),
|
||||||
task.getTaskDescription(),
|
task.getTaskDescription(),
|
||||||
reportTexts,
|
reportTexts
|
||||||
fragmentTexts
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
|
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
|||||||
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||||
import com.guo.learningprogresstracker.service.ReviewService;
|
import com.guo.learningprogresstracker.service.ReviewService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ import java.util.stream.Stream;
|
|||||||
/**
|
/**
|
||||||
* 复习模块 Service 实现
|
* 复习模块 Service 实现
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReviewServiceImpl implements ReviewService {
|
public class ReviewServiceImpl implements ReviewService {
|
||||||
@@ -53,13 +55,6 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
}
|
}
|
||||||
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
|
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
|
List<StudyReportFragmentsEntity> fragments = random
|
||||||
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||||
.last("ORDER BY RAND() LIMIT " + safeLimit))
|
.last("ORDER BY RAND() LIMIT " + safeLimit))
|
||||||
@@ -67,7 +62,7 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
|
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
|
||||||
.last("LIMIT " + safeLimit));
|
.last("LIMIT " + safeLimit));
|
||||||
|
|
||||||
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments);
|
List<ReviewFeedItem> items = mergeAndConvert(List.of(), fragments);
|
||||||
if (random) {
|
if (random) {
|
||||||
Collections.shuffle(items);
|
Collections.shuffle(items);
|
||||||
}
|
}
|
||||||
@@ -82,14 +77,11 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
|
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
|
||||||
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
|
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(
|
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||||
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||||
|
|
||||||
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
|
List<ReviewFeedItem> candidates = mergeAndConvert(List.of(), fragments);
|
||||||
if (candidates.size() <= safeLimit) {
|
if (candidates.size() <= safeLimit) {
|
||||||
Collections.shuffle(candidates);
|
Collections.shuffle(candidates);
|
||||||
return candidates;
|
return candidates;
|
||||||
@@ -224,13 +216,19 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
@Override
|
@Override
|
||||||
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
|
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
|
||||||
return Optional.ofNullable(studyReportsMapper.selectById(id))
|
return Optional.ofNullable(studyReportsMapper.selectById(id))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("学习报告[{}]不存在", id);
|
||||||
|
return new NotFindEntitiesException("这份学习报告不存在或已被删除");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
|
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
|
||||||
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
|
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("学习残片[{}]不存在", id);
|
||||||
|
return new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,7 +319,8 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
log.warn("任务[{}]不存在", taskNum);
|
||||||
|
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-16
@@ -36,7 +36,6 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
private final ReviewRecallRecordMapper recallRecordMapper;
|
private final ReviewRecallRecordMapper recallRecordMapper;
|
||||||
private final TasksMapper tasksMapper;
|
private final TasksMapper tasksMapper;
|
||||||
private final StudyReportsMapper studyReportsMapper;
|
private final StudyReportsMapper studyReportsMapper;
|
||||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
|
||||||
private final TaskApplicationMapper taskApplicationMapper;
|
private final TaskApplicationMapper taskApplicationMapper;
|
||||||
private final StudySessionsMapper studySessionsMapper;
|
private final StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
@@ -86,7 +85,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
ensureTaskExists(taskNum);
|
ensureTaskExists(taskNum);
|
||||||
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新");
|
log.warn("任务[{}]标准思维导图尚不存在,无法增量更新", taskNum);
|
||||||
|
throw new NotFindEntitiesException("还没有生成过标准思维导图,请先完整生成一次哦");
|
||||||
}
|
}
|
||||||
// 防并发生成
|
// 防并发生成
|
||||||
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||||
@@ -201,7 +201,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
try {
|
try {
|
||||||
resultJson = objectMapper.writeValueAsString(result);
|
resultJson = objectMapper.writeValueAsString(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new OperationFailedException("对比结果序列化失败");
|
log.error("任务[{}]对比结果序列化失败", taskNum, e);
|
||||||
|
throw new OperationFailedException("对比结果解析失败了,请稍后再试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 保存回忆记录
|
// 5. 保存回忆记录
|
||||||
@@ -405,7 +406,10 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
@Override
|
@Override
|
||||||
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
|
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
|
||||||
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
|
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("回忆记录[{}]不存在", recordId);
|
||||||
|
return new NotFindEntitiesException("这条回忆记录不存在或已被删除");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 内部方法 ============
|
// ============ 内部方法 ============
|
||||||
@@ -415,7 +419,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
|
||||||
|
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
throw new OperationFailedException("任务[" + taskNum + "]不存在");
|
log.warn("任务[{}]不存在,无法生成思维导图", taskNum);
|
||||||
|
throw new OperationFailedException("这个任务不存在或已被删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集学习数据
|
// 收集学习数据
|
||||||
@@ -428,14 +433,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
|
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
|
||||||
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
|
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||||
.in(StudyReportsEntity::getSessionNum, sessionNums));
|
.in(StudyReportsEntity::getSessionNum, sessionNums));
|
||||||
List<StudyReportFragmentsEntity> fragments = sessionNums.isEmpty() ? List.of()
|
|
||||||
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
|
||||||
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums));
|
|
||||||
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
|
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
|
||||||
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
|
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
|
||||||
|
|
||||||
if (reports.isEmpty() && fragments.isEmpty()) {
|
if (reports.isEmpty()) {
|
||||||
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
|
log.warn("任务[{}]没有学习报告,无法生成思维导图", taskNum);
|
||||||
|
throw new OperationFailedException("这个任务还没开始学习哦,学习后产生学习报告后再来吧");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
|
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
|
||||||
@@ -445,10 +448,11 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
throw new OperationFailedException("没有可用的思维导图生成器");
|
log.warn("任务[{}]没有可用的思维导图生成器", taskNum);
|
||||||
|
throw new OperationFailedException("思维导图暂时生成不了,请稍后再试");
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
|
Optional<MindMapNode> optRoot = client.generate(task, reports, applications, null);
|
||||||
// AI 生成失败时尝试降级到内置生成器
|
// AI 生成失败时尝试降级到内置生成器
|
||||||
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
|
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
|
||||||
log.info("AI 思维导图生成失败,降级到内置生成器");
|
log.info("AI 思维导图生成失败,降级到内置生成器");
|
||||||
@@ -456,11 +460,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
|
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
|
||||||
.findFirst().orElse(null);
|
.findFirst().orElse(null);
|
||||||
if (fallback != null) {
|
if (fallback != null) {
|
||||||
optRoot = fallback.generate(task, reports, fragments, applications, null);
|
optRoot = fallback.generate(task, reports, applications, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (optRoot.isEmpty()) {
|
if (optRoot.isEmpty()) {
|
||||||
throw new OperationFailedException("思维导图生成失败");
|
log.warn("任务[{}]思维导图生成失败,已尝试全部生成器", taskNum);
|
||||||
|
throw new OperationFailedException("思维导图生成失败了,请稍后再试");
|
||||||
}
|
}
|
||||||
|
|
||||||
MindMapNode root = optRoot.get();
|
MindMapNode root = optRoot.get();
|
||||||
@@ -483,7 +488,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
entity.setGenerator(client.generatorName());
|
entity.setGenerator(client.generatorName());
|
||||||
entity.setGeneratorVersion("1.0");
|
entity.setGeneratorVersion("1.0");
|
||||||
entity.setSourceReportCount(reports.size());
|
entity.setSourceReportCount(reports.size());
|
||||||
entity.setSourceFragmentCount(fragments.size());
|
entity.setSourceFragmentCount(0);
|
||||||
entity.setGeneratedTime(LocalDateTime.now());
|
entity.setGeneratedTime(LocalDateTime.now());
|
||||||
|
|
||||||
if (create) {
|
if (create) {
|
||||||
@@ -497,7 +502,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
log.warn("任务[{}]不存在", taskNum);
|
||||||
|
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -8,11 +8,13 @@ import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
|
|||||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学习预期服务实现
|
* 学习预期服务实现
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
||||||
@@ -26,7 +28,8 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
|||||||
Wrappers.<StudySessionsEntity>lambdaQuery()
|
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum));
|
.eq(StudySessionsEntity::getSessionNum, sessionNum));
|
||||||
if (!sessionExists) {
|
if (!sessionExists) {
|
||||||
throw new ErrorParameterException("学习会话[" + sessionNum + "]不存在");
|
log.warn("学习会话[{}]不存在", sessionNum);
|
||||||
|
throw new ErrorParameterException("这次学习会话不存在或已结束");
|
||||||
}
|
}
|
||||||
|
|
||||||
StudyExpectationsEntity existing = getBySessionNum(sessionNum);
|
StudyExpectationsEntity existing = getBySessionNum(sessionNum);
|
||||||
@@ -49,4 +52,11 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
|||||||
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
|
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteBySessionNum(String sessionNum) {
|
||||||
|
studyExpectationsMapper.delete(
|
||||||
|
Wrappers.<StudyExpectationsEntity>lambdaQuery()
|
||||||
|
.eq(StudyExpectationsEntity::getSessionNum, sessionNum));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -12,6 +12,7 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
|||||||
import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
|
import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
|
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
|
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
|
||||||
@@ -35,7 +37,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
|
|||||||
Wrappers.lambdaQuery(StudySessionsEntity.class)
|
Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
|
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]", entity.getSessionNum()));
|
log.warn("未能找到学习会话sessionNum[{}]", entity.getSessionNum());
|
||||||
|
throw new NotFindEntitiesException("这次学习会话不存在或已结束");
|
||||||
}
|
}
|
||||||
this.save(entity);
|
this.save(entity);
|
||||||
}
|
}
|
||||||
@@ -44,7 +47,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
|
|||||||
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
|
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
|
||||||
StudyReportFragmentsEntity existing = this.getById(id);
|
StudyReportFragmentsEntity existing = this.getById(id);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
throw new NotFindEntitiesException(String.format("未能找到学习残片id[%d]", id));
|
log.warn("未能找到学习残片id[{}]", id);
|
||||||
|
throw new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||||
}
|
}
|
||||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||||
entity.setId(id);
|
entity.setId(id);
|
||||||
|
|||||||
+58
-12
@@ -40,6 +40,11 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
|
|
||||||
public static final int WORK_DURATION = 25;
|
public static final int WORK_DURATION = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 误操作结束的确认语:用户必须输入完全一致的内容才能零数据关闭会话
|
||||||
|
*/
|
||||||
|
public static final String MISOPERATION_CONFIRMATION = "我误操作导致开启了本次学习";
|
||||||
|
|
||||||
private final TasksServiceImpl tasksServiceImpl;
|
private final TasksServiceImpl tasksServiceImpl;
|
||||||
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
||||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
@@ -50,7 +55,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||||
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
||||||
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum))
|
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException(String.format("[%s]不存在", taskNum)));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("[{}]不存在", taskNum);
|
||||||
|
return new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||||
|
});
|
||||||
|
|
||||||
StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
|
StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||||
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||||
@@ -88,10 +96,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
|
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
|
||||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
|
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
|
||||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
|
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
|
||||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经暂停,请勿重复暂停!");
|
throw new ErrorParameterException("这次学习会话已经暂停了,不用重复暂停哦");
|
||||||
}
|
}
|
||||||
studySessionsEntity.calculatePointerPosition();
|
studySessionsEntity.calculatePointerPosition();
|
||||||
studySessionsEntity.pausedStudySession(endTime);
|
studySessionsEntity.pausedStudySession(endTime);
|
||||||
@@ -104,7 +112,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
||||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
|
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
|
||||||
studySessionsEntity.endedStudySession();
|
studySessionsEntity.endedStudySession();
|
||||||
this.updateById(studySessionsEntity);
|
this.updateById(studySessionsEntity);
|
||||||
@@ -117,14 +125,42 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException {
|
||||||
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
|
if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
||||||
|
log.warn("会话[{}]已结束,不能误操作结束", sessionNum);
|
||||||
|
throw new ErrorParameterException("这次学习会话已经结束,不能再误操作结束了");
|
||||||
|
}
|
||||||
|
if (!MISOPERATION_CONFIRMATION.equals(confirmation == null ? "" : confirmation.trim())) {
|
||||||
|
log.warn("会话[{}]误操作结束的确认语不正确", sessionNum);
|
||||||
|
throw new ErrorParameterException("确认语输入不正确,请核对后重新输入");
|
||||||
|
}
|
||||||
|
long fragmentCount = studyReportFragmentsMapper.selectCount(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||||
|
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum));
|
||||||
|
if (fragmentCount > 0) {
|
||||||
|
log.warn("会话[{}]已产生{}条学习残片,不能误操作结束", sessionNum, fragmentCount);
|
||||||
|
throw new ErrorParameterException("这次学习已经产生了学习残片,不能按误操作结束了哦");
|
||||||
|
}
|
||||||
|
// 严格零数据:学习预期与会话本身一并删除,系统视角下会话从未发生
|
||||||
|
studyExpectationsService.deleteBySessionNum(sessionNum);
|
||||||
|
this.removeById(studySessionsEntity.getId());
|
||||||
|
log.info("会话[{}]误操作结束,未产生任何学习数据", sessionNum);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
|
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
|
||||||
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
|
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
|
||||||
.eq(TaskEntity::getTaskNum, taskNum))
|
.eq(TaskEntity::getTaskNum, taskNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在"));
|
.orElseThrow(() -> taskNotFound(taskNum));
|
||||||
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
|
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("任务[{}]不存在进行中或暂停中的会话", taskNum);
|
||||||
|
return new ErrorParameterException("这个任务当前没有进行中或已暂停的学习会话");
|
||||||
|
});
|
||||||
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
|
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +172,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
|
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
|
||||||
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
|
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
|
||||||
} else {
|
} else {
|
||||||
throw new ErrorParameterException("会话[" + sessionNum + "]不存在");
|
throw sessionNotFound(sessionNum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +181,13 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
||||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
|
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
|
||||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
|
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
|
||||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经开始,请勿重复开始!");
|
throw new ErrorParameterException("这次学习会话已经开始了,不用重复开始哦");
|
||||||
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
||||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
|
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
|
||||||
throw new ErrorParameterException("会话[" + sessionNum + "]处于结束状态,不可开始该会话!");
|
throw new ErrorParameterException("这次学习会话已经结束,无法再次开始");
|
||||||
}
|
}
|
||||||
studySessionsEntity.continueStudySession();
|
studySessionsEntity.continueStudySession();
|
||||||
this.updateById(studySessionsEntity);
|
this.updateById(studySessionsEntity);
|
||||||
@@ -163,7 +199,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
|
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
|
||||||
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +210,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
|
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
|
||||||
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||||
|
|
||||||
ArrayList<String> fragments = getAllFragments(sessionNum);
|
ArrayList<String> fragments = getAllFragments(sessionNum);
|
||||||
if (fragments.isEmpty()) {
|
if (fragments.isEmpty()) {
|
||||||
@@ -262,4 +298,14 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ErrorParameterException sessionNotFound(String sessionNum) {
|
||||||
|
log.warn("会话[{}]不存在", sessionNum);
|
||||||
|
return new ErrorParameterException("这次学习会话不存在或已结束");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ErrorParameterException taskNotFound(String taskNum) {
|
||||||
|
log.warn("任务[{}]不存在", taskNum);
|
||||||
|
return new ErrorParameterException("这个任务不存在或已被删除");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
|
|
||||||
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
|
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
|
||||||
.eq(TaskEntity::getTaskName, task.getTaskName()))) {
|
.eq(TaskEntity::getTaskName, task.getTaskName()))) {
|
||||||
throw new ErrorParameterException(String.format("任务名[%s]重复", task.getTaskName()));
|
log.warn("任务名[{}]重复", task.getTaskName());
|
||||||
|
throw new ErrorParameterException("已经有同名任务了,换个任务名称吧");
|
||||||
}
|
}
|
||||||
|
|
||||||
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
||||||
@@ -132,7 +133,10 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
@Override
|
@Override
|
||||||
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
|
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||||
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("应用场景[{}]不存在", id);
|
||||||
|
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||||
|
});
|
||||||
existing.setTitle(request.getTitle());
|
existing.setTitle(request.getTitle());
|
||||||
existing.setDescription(request.getDescription());
|
existing.setDescription(request.getDescription());
|
||||||
existing.setResourceUrl(request.getResourceUrl());
|
existing.setResourceUrl(request.getResourceUrl());
|
||||||
@@ -144,14 +148,18 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
@Override
|
@Override
|
||||||
public void deleteApplication(Integer id) throws NotFindEntitiesException {
|
public void deleteApplication(Integer id) throws NotFindEntitiesException {
|
||||||
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||||
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
.orElseThrow(() -> {
|
||||||
|
log.warn("应用场景[{}]不存在", id);
|
||||||
|
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||||
|
});
|
||||||
taskApplicationMapper.deleteById(existing.getId());
|
taskApplicationMapper.deleteById(existing.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
log.warn("任务[{}]不存在", taskNum);
|
||||||
|
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-14
@@ -62,13 +62,13 @@ class ReviewServiceImplTest {
|
|||||||
void getReviewFeed_smartMode_prefersLowRecallRatioContent() {
|
void getReviewFeed_smartMode_prefersLowRecallRatioContent() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
StudyReportsEntity oldLowMastery = new StudyReportsEntity();
|
StudyReportFragmentsEntity oldLowMastery = new StudyReportFragmentsEntity();
|
||||||
oldLowMastery.setId(1);
|
oldLowMastery.setId(1);
|
||||||
oldLowMastery.setSessionNum("S_LOW");
|
oldLowMastery.setSessionNum("S_LOW");
|
||||||
oldLowMastery.setContent("很久没复习且掌握度低");
|
oldLowMastery.setContent("很久没复习且掌握度低");
|
||||||
oldLowMastery.setCreatedTime(now.minusDays(60));
|
oldLowMastery.setCreatedTime(now.minusDays(60));
|
||||||
|
|
||||||
StudyReportsEntity freshHighMastery = new StudyReportsEntity();
|
StudyReportFragmentsEntity freshHighMastery = new StudyReportFragmentsEntity();
|
||||||
freshHighMastery.setId(2);
|
freshHighMastery.setId(2);
|
||||||
freshHighMastery.setSessionNum("S_HIGH");
|
freshHighMastery.setSessionNum("S_HIGH");
|
||||||
freshHighMastery.setContent("刚学完且掌握度高");
|
freshHighMastery.setContent("刚学完且掌握度高");
|
||||||
@@ -93,8 +93,7 @@ class ReviewServiceImplTest {
|
|||||||
highRatioRecord.setRecallRatio(1.0);
|
highRatioRecord.setRecallRatio(1.0);
|
||||||
highRatioRecord.setCreatedTime(now);
|
highRatioRecord.setCreatedTime(now);
|
||||||
|
|
||||||
when(studyReportsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
|
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
|
||||||
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of());
|
|
||||||
when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh));
|
when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh));
|
||||||
when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh));
|
when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh));
|
||||||
when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord));
|
when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord));
|
||||||
@@ -116,15 +115,9 @@ class ReviewServiceImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getReviewFeed_shouldApplyFinalLimitAfterMerge() {
|
void getReviewFeed_shouldReturnFragmentsOnly() {
|
||||||
StudyReportsEntity report = new StudyReportsEntity();
|
|
||||||
report.setId(1);
|
|
||||||
report.setSessionNum("S1");
|
|
||||||
report.setContent("report");
|
|
||||||
report.setCreatedTime(LocalDateTime.now());
|
|
||||||
|
|
||||||
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
|
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
|
||||||
fragment.setId(2);
|
fragment.setId(1);
|
||||||
fragment.setSessionNum("S1");
|
fragment.setSessionNum("S1");
|
||||||
fragment.setContent("fragment");
|
fragment.setContent("fragment");
|
||||||
fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1));
|
fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1));
|
||||||
@@ -137,7 +130,6 @@ class ReviewServiceImplTest {
|
|||||||
task.setTaskNum("T1");
|
task.setTaskNum("T1");
|
||||||
task.setTaskName("Task");
|
task.setTaskName("Task");
|
||||||
|
|
||||||
when(studyReportsMapper.selectList(any())).thenReturn(List.of(report));
|
|
||||||
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(fragment));
|
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(fragment));
|
||||||
when(studySessionsMapper.selectList(any())).thenReturn(List.of(session));
|
when(studySessionsMapper.selectList(any())).thenReturn(List.of(session));
|
||||||
when(tasksMapper.selectList(any())).thenReturn(List.of(task));
|
when(tasksMapper.selectList(any())).thenReturn(List.of(task));
|
||||||
@@ -145,7 +137,7 @@ class ReviewServiceImplTest {
|
|||||||
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent");
|
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent");
|
||||||
|
|
||||||
assertEquals(1, items.size());
|
assertEquals(1, items.size());
|
||||||
assertEquals("REPORT", items.get(0).getSourceType());
|
assertEquals("FRAGMENT", items.get(0).getSourceType());
|
||||||
assertEquals("Task", items.get(0).getTaskName());
|
assertEquals("Task", items.get(0).getTaskName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -36,8 +36,6 @@ class StandardMindMapServiceImplTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StudyReportsMapper studyReportsMapper;
|
private StudyReportsMapper studyReportsMapper;
|
||||||
@Mock
|
@Mock
|
||||||
private StudyReportFragmentsMapper studyReportFragmentsMapper;
|
|
||||||
@Mock
|
|
||||||
private TaskApplicationMapper taskApplicationMapper;
|
private TaskApplicationMapper taskApplicationMapper;
|
||||||
@Mock
|
@Mock
|
||||||
private StudySessionsMapper studySessionsMapper;
|
private StudySessionsMapper studySessionsMapper;
|
||||||
@@ -189,7 +187,7 @@ class StandardMindMapServiceImplTest {
|
|||||||
private StandardMindMapServiceImpl createService() {
|
private StandardMindMapServiceImpl createService() {
|
||||||
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
|
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
|
||||||
standardMindMapMapper, recallRecordMapper,
|
standardMindMapMapper, recallRecordMapper,
|
||||||
tasksMapper, studyReportsMapper, studyReportFragmentsMapper,
|
tasksMapper, studyReportsMapper,
|
||||||
taskApplicationMapper, studySessionsMapper,
|
taskApplicationMapper, studySessionsMapper,
|
||||||
List.of(mockAiClient), objectMapper, aiServiceClient);
|
List.of(mockAiClient), objectMapper, aiServiceClient);
|
||||||
return s;
|
return s;
|
||||||
|
|||||||
+103
@@ -4,9 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||||
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
|
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
|
||||||
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
|
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
|
|||||||
import org.mockito.quality.Strictness;
|
import org.mockito.quality.Strictness;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -41,10 +44,15 @@ class StudySessionsServiceImplTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private StudySessionsMapper studySessionsMapper;
|
private StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private StudyExpectationsService studyExpectationsService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
// stub updateById,避免调用真实的 getBaseMapper()
|
// stub updateById,避免调用真实的 getBaseMapper()
|
||||||
doReturn(true).when(studySessionsService).updateById(any());
|
doReturn(true).when(studySessionsService).updateById(any());
|
||||||
|
// stub removeById,避免调用真实的 getBaseMapper()
|
||||||
|
doReturn(true).when(studySessionsService).removeById(any(Serializable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
private StudySessionsEntity createSession(String state, int minutesAgo) {
|
private StudySessionsEntity createSession(String state, int minutesAgo) {
|
||||||
@@ -210,4 +218,99 @@ class StudySessionsServiceImplTest {
|
|||||||
|
|
||||||
verify(studySessionsService).updateById(session);
|
verify(studySessionsService).updateById(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 误操作结束(零数据关闭) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景11:确认语正确且未产生残片 → 删除预期与会话,零数据关闭
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_success_shouldDeleteEverything() throws Exception {
|
||||||
|
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||||
|
session.setId(1);
|
||||||
|
mockGetOneOpt(session);
|
||||||
|
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
|
||||||
|
|
||||||
|
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION);
|
||||||
|
|
||||||
|
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
|
||||||
|
verify(studySessionsService).removeById(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景12:确认语不正确 → 抛异常,不删除任何数据
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_wrongConfirmation_shouldThrow() {
|
||||||
|
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||||
|
mockGetOneOpt(session);
|
||||||
|
|
||||||
|
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||||
|
studySessionsService.abortStudySession("SESSION_TEST", "我不是确认语"));
|
||||||
|
|
||||||
|
assertEquals("确认语输入不正确,请核对后重新输入", ex.getMessage());
|
||||||
|
verify(studyExpectationsService, never()).deleteBySessionNum(any());
|
||||||
|
verify(studySessionsService, never()).removeById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景13:确认语首尾空白应被忽略,仍视为正确
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_confirmationWithSpaces_shouldSucceed() throws Exception {
|
||||||
|
StudySessionsEntity session = createSession(StudySessionStateEnum.PAUSED.name(), 5);
|
||||||
|
session.setId(2);
|
||||||
|
mockGetOneOpt(session);
|
||||||
|
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
|
||||||
|
|
||||||
|
studySessionsService.abortStudySession("SESSION_TEST",
|
||||||
|
" " + StudySessionsServiceImpl.MISOPERATION_CONFIRMATION + " ");
|
||||||
|
|
||||||
|
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
|
||||||
|
verify(studySessionsService).removeById(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景14:已产生学习残片 → 抛异常,不能误操作结束
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_hasFragments_shouldThrow() {
|
||||||
|
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
|
||||||
|
mockGetOneOpt(session);
|
||||||
|
doReturn(1L).when(studyReportFragmentsMapper).selectCount(any());
|
||||||
|
|
||||||
|
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||||
|
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||||
|
|
||||||
|
assertEquals("这次学习已经产生了学习残片,不能按误操作结束了哦", ex.getMessage());
|
||||||
|
verify(studyExpectationsService, never()).deleteBySessionNum(any());
|
||||||
|
verify(studySessionsService, never()).removeById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景15:会话已结束 → 抛异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_endedSession_shouldThrow() {
|
||||||
|
StudySessionsEntity session = createSession(StudySessionStateEnum.ENDED.name(), 5);
|
||||||
|
mockGetOneOpt(session);
|
||||||
|
|
||||||
|
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
|
||||||
|
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||||
|
|
||||||
|
assertEquals("这次学习会话已经结束,不能再误操作结束了", ex.getMessage());
|
||||||
|
verify(studySessionsService, never()).removeById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景16:会话不存在 → 抛异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void abortStudySession_sessionNotFound_shouldThrow() {
|
||||||
|
doReturn(Optional.empty()).when(studySessionsService).getOneOpt(any(Wrapper.class));
|
||||||
|
|
||||||
|
assertThrows(ErrorParameterException.class, () ->
|
||||||
|
studySessionsService.abortStudySession("NOT_EXIST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user