85 Commits

Author SHA1 Message Date
cat-shark 4f99f31475 fix(ci): 绑定分支与部署环境并加固 IMAGE_TAG 传递
- dev 仅允许 dev 分支,prod 仅允许 master/main
- IMAGE_TAG 写 env 文件 + image_tag.txt 兜底
- checkout 时确保 maven 容器内安装 git

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:17 +08:00
cat-shark 43580dd1fd Add Kubernetes deployment configurations
- Add deployment.yaml with imagePullSecrets and env configuration
- Add service.yaml with NodePort configuration
- Configure Spring Boot environment variables from ConfigMap and Secrets
- Ensures proper image pull authentication from private registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:23:59 +08:00
cat-shark d5d9b7aa99 feat(ci): 添加 imagePullSecret 自动创建步骤 2026-07-19 22:45:59 +08:00
cat-shark 6eca6de249 feat(ci): 添加部署失败时的调试输出 2026-07-19 22:27:12 +08:00
cat-shark a42df9d521 feat(ci): 使用时间戳标签确保每次部署都触发更新 2026-07-19 21:56:14 +08:00
cat-shark 015ecd13d8 fix(ci): 使用命名 volume 避免挂载冲突 2026-07-19 21:31:18 +08:00
cat-shark 8abd750dfb debug(ci): 添加 Docker 安装调试信息 2026-07-19 21:24:03 +08:00
cat-shark f8e24ffdf0 fix(ci): 挂载 Docker socket 并安装 Docker CLI 2026-07-19 21:18:47 +08:00
cat-shark 688be6b65f feat(ci): 缓存 Maven 本地仓库,避免重复下载依赖 2026-07-19 21:17:45 +08:00
cat-shark a6e75f6e97 fix(ci): 使用容器自带的 mvn 替代 mvnw 2026-07-19 21:14:01 +08:00
cat-shark 6cc5212eba fix(ci): 使用 maven 容器镜像,移除手动安装依赖 2026-07-19 20:56:30 +08:00
cat-shark 2d87b96a09 fix(ci): 使用绝对路径调用 apt-get (host 模式) 2026-07-19 20:49:55 +08:00
cat-shark eb2869a71d fix(ci): 移除 sudo - runner 以 root 运行 2026-07-19 20:41:58 +08:00
cat-shark 1789141a7b fix: 解决 deploy.yml 冲突 2026-07-19 20:13:05 +08:00
cat-shark b80e4c01f5 fix: Alpine compatibility - apk, docker login, /tmp/kubeconfig 2026-07-19 19:27:44 +08:00
cat-shark b70d90d18a fix(ci): 修复部署流水线配置
- 使用 Git commit hash 作为镜像标签
- 修复 Docker 网络配置(先连接 traefik-public,再附加 mysql 网络)
- 增加健康检查超时到 120 秒
- 添加镜像清理阶段(保留 7 天)
- 修复 Gitea Actions: github.sha → gitea.sha
- 添加 Docker Registry 认证
- kubectl 版本修正为 v1.30.0
- JDK 安装添加 sudo 权限
- 添加部署失败自动回滚机制

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:12:21 +08:00
cat-shark 73fdaf82e7 fix: add docker.io install for step container (no docker CLI in runner image) 2026-07-19 19:02:23 +08:00
cat-shark 5418f23111 ci: replace actions/checkout with git clone (no Node.js in runner) 2026-07-19 18:56:02 +08:00
cat-shark 58e9e4db22 ci: add kubectl setup step with secret 2026-07-19 18:50:39 +08:00
cat-shark a6dab438d5 ci: use unique image tags (git sha) 2026-07-19 18:49:23 +08:00
cat-shark 174b4265d7 Switch to manual trigger (workflow_dispatch) 2026-07-19 18:14:06 +08:00
cat-shark 3d157ed30c Add CI/CD pipeline
lpt-be Build & Deploy / build-and-deploy (push) Failing after 4s
2026-07-19 18:07:50 +08:00
cat_shark 9aded054d2 feat: update design review and serializer implementation 2026-07-19 16:56:39 +08:00
cat_shark cfa21eb336 docs: 添加 CLAUDE.md 项目文档 2026-07-12 13:44:00 +08:00
cat_shark 53a6781092 feat: 新增 URL 标题抓取工具
- UtilsController 提供 /utils/fetch-title 接口,供前端获取网页标题
- TitleFetcher 工具类支持 HTTP/HTTPS 请求、重定向跟踪、gzip/deflate 解压
- 支持 SSL 宽松配置、编码自动检测、批量并行抓取
- 支持裸 URL 自动嵌入为 [标题](url) Markdown 格式
2026-07-12 13:43:58 +08:00
cat_shark ac0154c1fb feat: 学习材料地址(materialUrl)支持
- TaskInfo DTO 新增 materialUrl 字段
- StudySessionResponse 新增 materialUrl 和 taskId 字段
- StudySessionsServiceImpl 在学习会话响应中填充 materialUrl 和 taskId
- TasksServiceImpl 更新任务时保留原有 taskNum
- 修复 TaskInfoResponse 注释格式错误
2026-07-12 13:43:55 +08:00
cat_shark 9d3bac8224 feat: 统一 WebMvc 配置,合并 dev/prod 环境配置
- 删除 WebMvcDevConfig 和 WebMvcProdConfig 两个环境特定配置
- 新增统一的 WebMvcConfig,同时包含 CORS 和 SaToken 鉴权拦截器
- CORS 支持通配符 allowedOriginPatterns 与精确 origins 两种模式
- 合并后的配置对所有环境生效,简化部署
2026-07-12 13:43:51 +08:00
developer 76742ed65a 清理测试中的导图相关测试用例 2026-07-09 00:12:29 +08:00
developer c8c0773a6a 清理 ReviewServiceImpl 中的导图相关实现 2026-07-09 00:12:26 +08:00
developer 5794bddc6c 清理 ReviewController 中的导图上传和查询接口 2026-07-09 00:12:24 +08:00
developer b3c7d52174 清理 ReviewService 接口中的导图方法定义 2026-07-09 00:12:22 +08:00
developer a9a5c888e3 删除导图文件解析器 MindMapFileParser 2026-07-09 00:12:20 +08:00
developer 4cf2827a74 删除导图实体、枚举、DTO 和 Mapper 2026-07-09 00:12:17 +08:00
cat-shark b18f8b4d1c feat(复习): 节点级 focusPath + findNode,回退 session 维度
- 移除 MindMapNode.sessionNum、filterBySession、getTaskSessions
- 新增 MindMapTreeTool.extractSubtree() 按路径提取子树作为对比基准
- 新增 findClosestNode/getPath/similarityScore 支持节点匹配
- 新增 POST /review/standard-mind-map/{taskNum}/find-node
- recallCompare 使用 focusPath 替代 sessionNum
- ReviewRecallRecordEntity.focusPath 替代 sessionNum
- Flyway V20260706_1: review_recall_records 加 focus_path 列
2026-07-05 18:12:32 +08:00
cat-shark 3c3f682b2b feat(导图): 防并发生成 + 增量合并 + mergeTrees 工具
- MindMapTreeTool.mergeTrees() 按标准化标题合并新旧树节点
- StandardMindServiceImpl 防并发生成(ConcurrentHashMap 锁)
- incrementalGenerate() 保留用户编辑节点 + 追加新节点
- regenerate 端点接受 mode 参数(full/incremental)
2026-07-05 15:19:03 +08:00
cat-shark a636e516d1 feat(复习): 支持会话维度复习 + 标准导图按会话过滤(后端)
- MindMapNode 新增 sessionNum 字段
- BuiltinMindMapGenerator 生成节点时填充 sessionNum
- MindMapTreeTool.filterBySession() 按会话过滤标准导图
- recallCompare 接受 sessionNum 参数
- ReviewRecallRecordEntity 新增 sessionNum 字段
- Flyway V20260705_1: review_recall_records 加 session_num 列
- 新增 GET /study-sessions/tasks/{taskNum}/sessions 端点
2026-07-05 14:40:38 +08:00
cat-shark 22cc5dc871 feat(历史报告): 展示学习预期目标以区分各会话
StudyReportsEntity 新增 sessionExpectation 字段(非数据库),
getTaskReports() 查询后自动填充对应会话的预期描述,
前端报告条目显示 sessionNum + 预期目标。
2026-07-05 12:35:25 +08:00
cat-shark 3a30b746f5 fix(分页): MybatisPlusConfig 缺少 PaginationInnerInterceptor 导致 selectPage 不分页
PaginationInnerInterceptor 负责为 selectPage 生成 LIMIT/COUNT 查询,
缺失时返回全部记录且 total=0,前端分页控件因此不显示。
2026-07-05 11:43:13 +08:00
cat-shark 0a866bde14 feat(会话):新增活跃会话查询接口
StudySessionsServiceImpl.getActiveSession(excludeTaskNum):
- 查询所有 ONGOING/PAUSED 状态的 session
- 如果活跃会话属于 excludeTaskNum 则返回 null(同一任务继续)
- 否则返回活跃会话的 StudySessionResponse

Controller 新增 GET /study-sessions/active?excludeTaskNum=
用于前端页面恢复 + 阻止多任务同时开始
2026-07-05 11:05:01 +08:00
cat-shark c1385911c6 feat(会话):分页查询任务的历史残片/报告接口
StudySessionsService 新增:
- getTaskFragments(taskNum, page, size, keyword) → Page<FragmentsEntity>
- getTaskReports(taskNum, page, size, keyword) → Page<ReportsEntity>

通过子查询 session_num in (select session_num from study_sessions
where task_num = ?) 实现跨 session 聚合,keyword 支持 content
字段 LIKE 搜索。接口使用 MyBatis-Plus Page 分页。

控制器新增两个 GET 端点:
- /study-sessions/tasks/{taskNum}/fragments
- /study-sessions/tasks/{taskNum}/reports
2026-07-05 10:35:45 +08:00
cat-shark eaa0e20c81 feat(学习材料):material_url varchar(255) → TEXT
新增 Flyway 迁移 V20260704_1,允许存储多行 URL
2026-07-04 22:37:08 +08:00
cat-shark c0fe137c48 fix(后端):@Transactional + WebMvcProdConfig OPTIONS 跳过
- WebMvcProdConfig:SaInterceptor 增加 OPTIONS 预检跳过,
  解决生产环境 CORS 预检 401 问题
- StudySessionsServiceImpl.endedStudySession/continueStudySession:
  增加 @Transactional
- StudyReportFragmentsServiceImpl.createFragments:
  增加 @Transactional
2026-07-04 17:38:00 +08:00
cat-shark cca51530c6 fix(后端):extraNodes 解析兼容字符串和对象两种格式
AI 可能返回 [字符串] 或 [{title:...,path:...}] 两种格式,
buildCompareResultFromAI() 现在通过 isTextual() 判断并分别处理
2026-07-04 16:52:09 +08:00
cat-shark 55b7c1c83c feat(后端):AiServiceClient 改为 submit+poll 异步任务模式
- 内部实现重构:每个公有方法内部 submitAndWait() → submitTask() + pollResult()
- submitTask(): POST /ai/tasks,返回 taskId(30s 超时)
- pollResult(): 轮询 GET /ai/tasks/:taskId,500ms 首次延迟,
  2s × 10 次后退避到 5s,总超时 600s(10 min)
- 公有方法签名完全不变:aggregateReport()、generateMindMap()、compareRecall()
- 异常失败时返回 Optional.empty(),调用方降级逻辑不变
- application.yml timeout-seconds: 180 → 600
2026-07-04 16:41:21 +08:00
cat-shark 4ec900577e fix(后端):lpt-ai 超时从 90s 增加到 180s 2026-07-04 16:16:27 +08:00
cat-shark af4f536980 fix(复习):AI 对比结果由 Java 端标注标准树,不再依赖 LLM 输出树结构
根因:LLM 无法可靠输出 CompareResult 所需的完整嵌套树 JSON,
实际返回格式(coverage/matched/missed)与预期不符。

修复:
- AiServiceClient.compareRecall() 返回 Optional<JsonNode>(原始 JSON),
  不再尝试直接反序列化为 CompareResult
- StandardMindMapServiceImpl 新增 buildCompareResultFromAI():
  解析 AI 返回的平铺 matches/missedTitles/extraNodes 列表,
  将 MATCHED|/MISSED| 标注写入标准树,确定性构建 CompareResult
- AI 只负责语义匹配判断,树结构操作由 Java 端保证可靠性
2026-07-04 16:09:07 +08:00
cat-shark 7ac7868511 feat(复习):回忆对比优先走 AI 语义对比,失败时降级为内置算法
- CompareResult.java:新增 evaluation 字段(AI 对比时写入评价文本)
- AiServiceClient.java:新增 compareRecall(),调用 lpt-ai /ai/compare-recall
- StandardMindMapServiceImpl.java:recallCompare() 中注入 AiServiceClient,
  优先 try AI 语义对比,含层级上下文和同义表达识别;
  AI 不可用或失败时自动 fallback 到内置标题匹配算法
- StandardMindMapServiceImplTest.java:适配新增的 AiServiceClient 依赖
2026-07-04 15:47:24 +08:00
cat-shark cd5ee0b404 feat(思维导图):AI 生成接入 lpt-ai 服务
RemoteAiMindMapClient 原为 TODO 空桩,依赖 lpt.ai.endpoint/api-key
配置,从未生效。现改为依赖 AiServiceClient(lpt.ai-service.url),
调用 lpt-ai 的 /ai/generate-mind-map 接口获取 AI 大纲。

变更:
- AiServiceClient 新增 generateMindMap():调用 POST /ai/generate-mind-map
- RemoteAiMindMapClient 重写:注入 AiServiceClient,isAvailable()
  改为检查 AiServiceClient.isConfigured(),generate() 用 lpt-ai
  返回的大纲文本解析为 MindMapNode 树
- StandardMindMapServiceImpl.doGenerate():优先选 AI 客户端
  (非 BUILTIN),AI 失败时自动降级到内置规则引擎
- application.yml:移除不再使用的 lpt.ai.endpoint/api-key 配置
2026-07-04 11:14:27 +08:00
cat-shark baf9ae8761 docs(评审):补充 2026-07-04 交付记录 2026-07-04 00:08:59 +08:00
cat-shark 21c5b8f761 feat(报告):AI 聚合残片生成报告草稿
- AiServiceClient:调用 lpt-ai 独立服务,未配置/失败时返回 empty
- GET /study-sessions/{sessionNum}/report-draft:AI 聚合(含预期对比),降级为残片拼接
- 草稿仅作为编辑起点,最终报告仍由用户确认后保存
- application.yml 增加 lpt.ai-service.url 配置
2026-07-03 23:52:32 +08:00
cat-shark 3c4db2fead feat(复习):feed 智能模式——加权随机采样
- /review/feed?mode=smart:时间衰减 × 回忆掌握度加权采样
- 越久未见、回忆覆盖率越低的内容出现概率越高
- 保持随机性,符合复习模块偶遇式设计理念
- 概率性单元测试验证低掌握内容显著优先
2026-07-03 23:44:40 +08:00
cat-shark 7461b6b5ad feat(优先级):维度权重可配置并即时重算
- 新表 user_priority_weights(每用户一行,默认 0.35/0.25/0.20/0.10/0.10)
- PriorityWeightsService:读取/保存权重,权重和必须为 1,保存后重算全部任务
- TaskController:GET/PUT /tasks/priority-weights
- CalculatedPriorityTool 支持自定义权重
- 修复 updateTask 不重算优先级的问题
2026-07-03 23:41:50 +08:00
cat-shark b67cf238c2 feat(预期):学习预期后端闭环——实体/服务/端点
- 迁移:study_expectations.session_num 改为 varchar 对齐会话编码
- StudyExpectationsEntity / Mapper / Service:每会话一条预期,重复创建覆盖
- StudySessionController:PUT/GET /{sessionNum}/expectation
- 移除空的 studySessionList 占位方法
2026-07-03 23:38:52 +08:00
cat-shark 090d03e251 docs(评审):补充评价回应、研究支撑、技术推荐与修正优先级 2026-07-03 22:56:18 +08:00
cat-shark d173b37a9b docs(评审):架构与设计评审报告 2026-07-03 09:16:00 +08:00
cat-shark 32b247526b feat(复习):标准思维导图生成与回忆对比
- 新增 review_standard_mind_maps / review_recall_records 数据表
- BuiltinMindMapGenerator:从报告/残片/应用场景规则生成标准导图
- MindMapAiClient 接口 + RemoteAiMindMapClient 预留
- StandardMindMapService:生成/编辑/重新生成/回忆对比
- 对比算法:标题归一化 + Bigram Jaccard 模糊匹配
- ReviewController 新增 6 个端点
- MindMapTreeTool / MindMapNode / CompareResult 工具类
- 完整单元测试(10 个,全部通过)
- 更新 docs/review-module-design.md
2026-07-03 08:53:51 +08:00
cat-shark 5f3f35c53b feat: parse uploaded review mind maps 2026-06-20 10:33:14 +08:00
cat-shark f170e85297 feat: complete review workflow support 2026-06-20 09:16:20 +08:00
cat-shark 81543969d8 feat: 新增复习任务统计接口 2026-06-18 00:07:43 +08:00
cat-shark 2cf3533141 feat: 登录密码改用 BCrypt 校验 2026-06-18 00:06:42 +08:00
cat-shark d0c78ceb28 test: 新增 StudySessionsServiceImpl 单元测试 (10 cases)
覆盖场景:ONGOING/PAUSED 状态的消息返回、报告创建、
边界值、会话不存在异常、updateById 调用验证
2026-05-30 17:37:29 +08:00
cat-shark d7e1d9a10c test: 新增 StudySessionsEntity 单元测试 (16 cases)
覆盖场景:直接结束、暂停后结束、暂停继续再结束、多次暂停继续、
边界值(10分钟)、极短时间、已结束会话、PAUSED 状态直接结束等
2026-05-30 17:37:22 +08:00
cat-shark a64daacc71 feat: 结束会话时返回有效时间不足的提示信息
- StudySessionsServiceImpl: 检测 effectiveTime 被清零时返回提示消息
- StudySessionController: 将提示消息通过 CommonResult.success(message) 传给前端
2026-05-30 17:36:56 +08:00
cat-shark 591dc89e53 feat: 有效学习时间不足10分钟不计入总学习时间
- StudySessionsEntity: 新增 MIN_EFFECTIVE_TIME_SECONDS 常量,
  endedStudySession() 中检查 effectiveTime,不足则清零
- StudySessionsService: endedStudySession 返回类型从 void 改为 String
2026-05-30 17:36:50 +08:00
cat-shark c7eb448e33 Merge branch 'feature/fragment-edit' into dev
学习碎片编辑功能:
- 新增碎片更新 API(PUT /report-fragments/{id})
- 新增会话碎片列表查询 API(GET /report-fragments/session/{sessionNum})
- 前端碎片详情页支持内联编辑
- 前端学习会话页展示碎片列表并支持编辑
2026-05-30 16:06:43 +08:00
cat-shark d5835cdf55 feat: 新增碎片更新和会话查询的Controller端点
- PUT /report-fragments/{id} 更新碎片内容
- GET /report-fragments/session/{sessionNum} 获取指定会话的完整碎片列表
2026-05-30 15:50:40 +08:00
cat-shark 487b38e8b0 feat: 新增碎片更新和会话查询的Service实现
- StudyReportFragmentsService 接口新增 updateFragments 和 getFragmentsBySession 方法
- ServiceImpl 实现碎片更新(校验存在性后更新)和按会话编号查询碎片列表
2026-05-30 15:50:34 +08:00
cat-shark e1cd5d5b82 feat: 新增碎片更新请求DTO和MapStruct映射
- 新建 UpdateFragmentsRequest,包含 @NotBlank 校验的 content 字段
- FragmentsConvert 新增 UpdateFragmentsRequest 到 Entity 的映射方法
2026-05-30 15:50:28 +08:00
cat-shark 6bd6b00518 Revert "Revert "revert: 恢复 flyway validate-on-migrate 为 true""
This reverts commit 82948e3ff9.
2026-05-30 09:22:16 +08:00
cat-shark aadf1911ce fix:移除多余的创建语句 2026-05-30 09:22:13 +08:00
cat-shark 82948e3ff9 Revert "revert: 恢复 flyway validate-on-migrate 为 true"
This reverts commit fe6983cb69.
2026-05-28 00:31:50 +08:00
cat-shark fe6983cb69 revert: 恢复 flyway validate-on-migrate 为 true 2026-05-28 00:25:57 +08:00
cat-shark 27cb1b21fb fix: 关闭 flyway validate-on-migrate 临时绕过 checksum 校验 2026-05-28 00:19:30 +08:00
cat-shark 4b2bd5a47a fix: 添加 flyway repair-on-migrate 修复 checksum 不一致 2026-05-28 00:13:59 +08:00
cat-shark b6bc587ea2 fix: 复原 V20250719_2 迁移文件,修复 Flyway 校验失败 2026-05-28 00:00:30 +08:00
cat-shark a92465dfc0 chore: 清理冗余注释,保留字段/业务含义注释 2026-05-27 23:03:43 +08:00
cat-shark 78a24e8394 chore: 修复 mvnw 文件权限 2026-05-27 21:24:15 +08:00
cat-shark 1fc41f1443 feat: 添加 MyBatis-Plus 多租户配置,自动注入行级数据隔离 2026-05-27 21:24:15 +08:00
cat-shark 24d871b430 refactor: 优化其他 Service 实现和 Controller 代码 2026-05-27 21:23:50 +08:00
cat-shark 950c91791f refactor: ReviewServiceImpl 改用 MyBatis-Plus Java API 实现复盘功能 2026-05-27 21:23:44 +08:00
cat-shark f797a712db refactor: 移除 ReviewMapper 自定义 SQL,改用 MyBatis-Plus Java API 2026-05-27 21:23:36 +08:00
cat-shark a932bf52ca refactor: 移除 StudyExpectations 相关代码 2026-05-27 21:23:31 +08:00
cat-shark 4b26ed46ec refactor: 移除 UserTask 相关代码(entity/mapper/service/impl/XML) 2026-05-27 21:23:23 +08:00
cat-shark 8409dc58ab db: 新增迁移脚本,删除不再使用的 user_task 表 2026-05-27 21:23:14 +08:00
cat-shark 0beaed8121 docs: 添加 AGENTS.md 项目规范,记录数据库迁移命名规则 2026-05-27 21:23:09 +08:00
108 changed files with 6416 additions and 531 deletions
+185
View File
@@ -0,0 +1,185 @@
name: lpt-be Build & Deploy
on:
workflow_dispatch:
inputs:
environment:
description: '部署环境(dev 仅允许 dev 分支;prod 仅允许 master/main'
required: true
type: choice
options:
- dev
- prod
env:
REGISTRY: 192.168.123.199:5000
APP: lpt-be
REPO_URL: http://git.cat-shark.xyz/cat-shark/lpt-be.git
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: maven:3.9-eclipse-temurin-17
volumes:
- maven-cache:/root/.m2
steps:
- name: Validate branch ↔ environment
run: |
set -euo pipefail
REF="${{ gitea.ref }}"
BRANCH="${REF#refs/heads/}"
if [[ "$REF" == refs/tags/* ]]; then
echo "ERROR: 请从分支触发部署,不要用 tag。当前 ref=$REF"
exit 1
fi
ENV="${{ inputs.environment }}"
echo "branch=$BRANCH environment=$ENV sha=${{ gitea.sha }}"
case "$ENV" in
prod)
case "$BRANCH" in
master|main) echo "OK: prod 允许从 $BRANCH 部署" ;;
*)
echo "ERROR: prod 只能从 master/main 部署,当前分支是 '$BRANCH'"
echo "请切换到 master 后再 Run workflow,并选择 environment=prod"
exit 1
;;
esac
;;
dev)
case "$BRANCH" in
dev) echo "OK: dev 允许从 $BRANCH 部署" ;;
*)
echo "ERROR: dev 只能从 dev 分支部署,当前分支是 '$BRANCH'"
echo "请切换到 dev 后再 Run workflow,并选择 environment=dev"
echo "(禁止用 master 代码部署到 lpt-dev,避免环境错配)"
exit 1
;;
esac
;;
*)
echo "ERROR: 未知 environment=$ENV"
exit 1
;;
esac
- name: Checkout code
run: |
set -euo pipefail
# maven 容器可能无 git
if ! command -v git >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq git
fi
git clone "$REPO_URL" .
git checkout "${{ gitea.sha }}"
- name: Install Docker CLI
run: |
set -euo pipefail
apt-get update -qq
apt-get install -y -qq docker.io
docker --version
- name: Build with Maven
run: |
set -euo pipefail
mvn clean package -DskipTests -B
- name: Login to Docker Registry
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build & Push Docker image
run: |
set -euo pipefail
ENV="${{ inputs.environment }}"
TAG="${{ gitea.sha }}-$(date +%s)"
echo "Building $REGISTRY/$APP:$TAG (env tag=$ENV)"
docker build -t "$REGISTRY/$APP:$TAG" -t "$REGISTRY/$APP:$ENV" .
docker push "$REGISTRY/$APP:$TAG"
docker push "$REGISTRY/$APP:$ENV"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
fi
if [ -n "${GITEA_ENV:-}" ]; then
echo "IMAGE_TAG=$TAG" >> "$GITEA_ENV"
fi
# runner.temp 在 container job 里可能不可用,用工作区文件兜底
echo "$TAG" > image_tag.txt
mkdir -p /tmp/lpt-ci
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
echo "IMAGE_TAG=$TAG"
- name: Setup kubectl
run: |
set -euo pipefail
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
- name: Create/Update imagePullSecret
run: |
set -euo pipefail
kubectl create secret docker-registry regcred \
--docker-server="$REGISTRY" \
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
-n "lpt-${{ inputs.environment }}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy to K8s
run: |
set -euo pipefail
ENV="${{ inputs.environment }}"
IMAGE_TAG="${IMAGE_TAG:-}"
if [ -z "$IMAGE_TAG" ] && [ -f image_tag.txt ]; then
IMAGE_TAG="$(cat image_tag.txt)"
fi
if [ -z "$IMAGE_TAG" ] && [ -f /tmp/lpt-ci/image_tag.txt ]; then
IMAGE_TAG="$(cat /tmp/lpt-ci/image_tag.txt)"
fi
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG 为空,无法部署"
exit 1
fi
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
# Spring profile 由 K8s Deployment 的 SPRING_PROFILES_ACTIVE 控制,镜像本身不区分
kubectl set image "deployment/$APP" \
"$APP=$REGISTRY/$APP:$IMAGE_TAG" \
-n "lpt-$ENV" --record
kubectl rollout status "deployment/$APP" \
-n "lpt-$ENV" --timeout=5m
- name: Debug on failure
if: failure()
run: |
ENV="${{ inputs.environment }}"
echo "=== Deployment Status ==="
kubectl get deployment "$APP" -n "lpt-$ENV" || true
echo ""
echo "=== Pod Status ==="
kubectl get pods -n "lpt-$ENV" -l "app=$APP" || true
echo ""
echo "=== Recent Events ==="
kubectl get events -n "lpt-$ENV" --sort-by='.lastTimestamp' | tail -20 || true
echo ""
echo "=== Pod Describe (latest) ==="
POD=$(kubectl get pods -n "lpt-$ENV" -l "app=$APP" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true)
if [ -n "${POD:-}" ]; then
kubectl describe pod "$POD" -n "lpt-$ENV" || true
echo ""
echo "=== Pod Logs (latest) ==="
kubectl logs "$POD" -n "lpt-$ENV" --tail=50 || true
fi
- name: Rollback on failure
if: failure()
run: |
set -euo pipefail
ENV="${{ inputs.environment }}"
kubectl rollout undo "deployment/$APP" -n "lpt-$ENV" || true
kubectl rollout status "deployment/$APP" -n "lpt-$ENV" || true
+47
View File
@@ -0,0 +1,47 @@
# 项目规范
## 数据库迁移(Flyway Migration
### 命名规则
- 脚本格式:`V{YYYYMMDD}_{序号}__{描述}.sql`
- 日期必须使用**实际编写日期**,不得使用过去的日期
- 序号从 1 开始,同一天多个脚本递增
- 描述使用下划线分隔的英文短语
### 核心原则
- **不可变性**:已执行的迁移脚本永远不得修改
- **只增不减**:数据库变更只能通过新增迁移脚本实现
- **向后兼容**:新脚本应兼容已有数据
### 操作规范
- 删除表:创建新迁移脚本,使用 `DROP TABLE IF EXISTS`
- 修改表结构:使用 `ALTER TABLE` 语句
- 新增表:创建新迁移脚本,使用 `CREATE TABLE`
## 框架机制
### 行级数据隔离(多租户拦截器)
- **配置类:** `MybatisPlusConfig` 注册 `TenantLineInnerInterceptor`
- **租户字段:** `created_by`(每个业务表的创建人字段)
- **租户值来源:** `StpUtil.getLoginIdAsString()`(当前登录用户)
- **自动注入:** 所有 SELECT/UPDATE/DELETE 语句自动追加 `WHERE created_by = #{当前用户}`
- **归属校验:** 查询单条记录时拦截器自动校验 `created_by`,非本人数据直接返回空
- **排除表:** `user``flyway_schema_history``databasechangelog``databasechangeloglock`
### 自动填充(MetaObjectHandler
- **created_by / updated_by** 插入/更新时自动填充为当前登录用户
- **created_time / updated_time** 插入/更新时自动填充当前时间
### 认证鉴权(Sa-Token
- **会话管理:** 基于 `StpUtil` 的登录/登出/会话查询
- **权限校验:** `@SaCheckPermission` 注解式权限控制
- **未登录处理:** 全局异常处理器捕获 `NotLoginException` 返回 401
## 编码规范
### 数据访问层
- **优先使用纯 MyBatis-Plus Java API**`Wrappers.<T>lambdaQuery()``selectList``selectById` 等),避免手写 XML SQL
- 复杂查询通过 `Wrappers` 链式构建条件,必要时使用 `.apply()` 拼接原生 SQL 片段
+63
View File
@@ -0,0 +1,63 @@
# LPT 后端规范
## 技术栈
- Java 17, Spring Boot 3.2.5
- MyBatis-Plus 3.5.5 + MySQL
- MapStruct 1.5.5(编译期生成代码,DTO 增删字段后需重新编译)
- Sa-Token 1.38.0(认证/鉴权)
- Flyway(数据库迁移)
- Lombok@Data, @Slf4j
## 项目结构
```
controller/ → REST 接口,只做参数校验和路由
service/ → 业务逻辑
impl/
mapper/ → MyBatis-Plus BaseMapper
entity/ → 数据库实体(@TableName, @TableField
dto/ → 请求/响应 DTO
request/
response/
config/ → Spring 配置(WebMvcConfig, JacksonConfig 等)
utils/ → 工具类
common/ → GlobalExceptionHandler, Ops 等
mapStruct/ → MapStruct Converter 接口
```
## 响应规范
- 统一使用 `CommonResult<T>` 包装:`{ code, message, data }`
- 成功:`CommonResult.success(data)` → code=200
- 业务错误:`CommonResult.error(msg)` → code=400, HTTP 200
- 未登录:`GlobalExceptionHandler.handleNotLogin()` → HTTP 401 + code=401
- 参数校验失败走 `MethodArgumentNotValidException` → code=400
## 认证
- SaInterceptor 注册在 `WebMvcConfig`(无 @Profile 限制,所有环境生效)
- 拦截 `/**`,排除 `/login`
- `StpUtil.checkLogin()` 失败 → NotLoginException → GlobalExceptionHandler → 401
- Cookie 名 `satoken`,前端 axios 需 `withCredentials: true`
## 数据库变更
- Flyway 迁移文件:`src/main/resources/db/migration/V{日期}_{序号}__{描述}.sql`
- 文件名日期格式:`yyyyMMdd`
## DTO 转换
- MapStruct 编译期生成 `*ConvertImpl.java`target/generated-sources/
- 同名属性自动映射,`unmappedTargetPolicy = IGNORE`
- 如需自定义映射用 `@Mapping(source, target)`
- **增删 DTO 字段后必须重新编译**,否则 MapStruct 生成代码不含新字段
## CORS
-`CorsProperties` 读取各 profile 的 `cors.allowed-origins` 配置
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`(兼容 allowCredentials
- local profile`allow-credentials: true`, `allowed-origins: '*'`
## 标题抓取
- `TitleFetcher`:静态工具类,支持手动跟随 HTTP→HTTPS 重定向、宽松 SSL
- `UtilsController``GET /utils/fetch-title?url=...` 代理端点
## 运行
- 默认 profilelocalapplication.yml 中 `spring.profiles.active: local`
- IDEA JDK`C:/Users/cat-win/.jdks/ms-17.0.19`
- MavenIDEA 内置 `C:/Program Files/JetBrains/IntelliJ IDEA 2026.1.3/plugins/maven/lib/maven3/bin/mvn`
- 编译:`mvn clean compile -DskipTests`(须用 JDK 17
Vendored
+27 -19
View File
@@ -1,7 +1,7 @@
pipeline { pipeline {
agent none // 全局不指定,局部自己声明 agent none
environment { environment {
IMAGE_NAME = 'lpt-prod:0.0' IMAGE_NAME = "lpt-prod:${env.GIT_COMMIT?.take(8) ?: '0.0'}"
CONTAINER_NAME = 'LPT-prod' CONTAINER_NAME = 'LPT-prod'
CONTAINER_PORT = '8888' CONTAINER_PORT = '8888'
} }
@@ -31,23 +31,24 @@ pipeline {
sh ''' sh '''
docker network create traefik-public || true docker network create traefik-public || true
docker rm -f $CONTAINER_NAME || true docker rm -f $CONTAINER_NAME || true
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\ docker run -d --name $CONTAINER_NAME \
--restart=always \\ --network traefik-public \
-e SPRING_PROFILES_ACTIVE=prod \\ --restart=always \
--label "traefik.enable=true" \\ -e SPRING_PROFILES_ACTIVE=prod \
--label "traefik.docker.network=traefik-public" \\ --label "traefik.enable=true" \
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\ --label "traefik.docker.network=traefik-public" \
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\ --label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\ --label "traefik.http.routers.lpt-api.entrypoints=websecure" \
--label "traefik.http.routers.lpt-api.priority=100" \\ --label "traefik.http.routers.lpt-api.tls.certresolver=le" \
--label "traefik.http.routers.lpt-api.service=lpt-api" \\ --label "traefik.http.routers.lpt-api.priority=100" \
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\ --label "traefik.http.routers.lpt-api.service=lpt-api" \
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\ --label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\ --label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \
--log-driver=loki \\ --label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\ --log-driver=loki \
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \
$IMAGE_NAME $IMAGE_NAME
docker network connect traefik-public $CONTAINER_NAME || true docker network connect mysql-prod_mysql-prod $CONTAINER_NAME || true
''' '''
} }
} }
@@ -57,7 +58,7 @@ pipeline {
steps { steps {
script { script {
def lastStatus = '' def lastStatus = ''
timeout(time: 60, unit: 'SECONDS') { timeout(time: 120, unit: 'SECONDS') {
waitUntil { waitUntil {
def status = sh( def status = sh(
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'", script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
@@ -74,5 +75,12 @@ pipeline {
} }
} }
} }
stage('清理旧镜像') {
agent any
steps {
sh 'docker image prune -af --filter "until=168h" || true'
}
}
} }
} }
+373
View File
@@ -0,0 +1,373 @@
# 学习进度跟踪系统(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.02026 年 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 端用 BuiltinMindMapGeneratorAI 服务未就绪时完全可用
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. 权重配置无 UIP0,需要实施
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`
+437
View File
@@ -0,0 +1,437 @@
# 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. **安全层**:多租户隔离、双重校验、权限控制
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
+164
View File
@@ -0,0 +1,164 @@
# 复习模块设计说明
本文档是对复习功能当前实现边界的工程说明,不修改原始需求文档。
## 核心理解
复习模块包含两类不同层级的体验:碎片化提醒和围绕思维导图的主动回忆。二者都属于复习模块,但不是同一个业务流程。
## 碎片化提醒
碎片化提醒展示用户自己在学习过程中写下的学习报告和学习残片。
这些内容不是完整复习记录,而是日常滚动出现的记忆触发物。用户看到一段自己写过的内容时,如果能立刻回想起上下文,说明相关知识暂时不需要深入复习;如果想不起来,可以点击进入详情页回看,形成一次轻量提醒。
当前对应能力:
- `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 驱动的用户导图评分与改进建议
+73
View File
@@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lpt-be
namespace: lpt-dev
labels:
app: lpt-be
spec:
replicas: 1
selector:
matchLabels:
app: lpt-be
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
app: lpt-be
spec:
imagePullSecrets:
- name: regcred
containers:
- name: lpt-be
image: 192.168.123.199:5000/lpt-be:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8888
env:
- name: SPRING_PROFILES_ACTIVE
value: dev
- name: SPRING_DATASOURCE_URL
valueFrom:
configMapKeyRef:
name: lpt-config
key: SPRING_DATASOURCE_URL
- name: SPRING_DATASOURCE_USERNAME
value: root
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: lpt-secrets
key: mysql-root-password
- name: LPT_AI-SERVICE_URL
valueFrom:
configMapKeyRef:
name: lpt-config
key: LPT_AI-SERVICE_URL
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
livenessProbe:
httpGet:
path: /actuator/health
port: 8888
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health
port: 8888
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: lpt-be
namespace: lpt-dev
labels:
app: lpt-be
spec:
type: NodePort
selector:
app: lpt-be
ports:
- port: 8888
targetPort: 8888
nodePort: 30082
protocol: TCP
Vendored Regular → Executable
View File
+13 -1
View File
@@ -52,6 +52,11 @@
<artifactId>mockito-junit-jupiter</artifactId> <artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId> <artifactId>flyway-core</artifactId>
@@ -129,6 +134,13 @@
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- jBCrypt (独立 BCrypt 实现,无 Spring Security 依赖) -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -142,7 +154,7 @@
<forceJavacCompilerUse>true</forceJavacCompilerUse> <forceJavacCompilerUse>true</forceJavacCompilerUse>
<source>17</source> <source>17</source>
<target>17</target> <target>17</target>
<compilerArgs>--enable-preview</compilerArgs>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
+178
View File
@@ -0,0 +1,178 @@
---
name: comment-cleanup
description: 提交代码前优化 Java 注释,移除冗余描述性注释,保留功能性注释和业务上下文注释。
metadata:
short-description: 清理 Java 代码中的冗余注释
---
# Comment Cleanup
在代码提交前,扫描变更的 Java 文件,清理冗余注释,保留有价值的注释。
## 触发条件
用户要求清理注释、优化注释、提交前检查注释时使用。
## 工作流程
1. 定位待清理的 Java 文件(通常是 `git diff` 中变更的文件)
2. 逐文件扫描注释,按规则分类处理
3. 执行清理(删除或改造)
4. 输出清理报告
## 注释分类规则
### 移除:类型 A — 代码语义重复注释
代码本身已经清晰表达意图时,删除多余的行内注释。
```java
// ❌ 移除:代码已经说清楚了
// 转换报告
List<ReviewFeedItem> reportItems = reports.stream().map(r -> toFeedItem(r)).collect(Collectors.toList());
// ❌ 移除:合并逻辑一眼就能看出
// 合并并按创建时间倒序
return Stream.concat(reportItems.stream(), fragmentItems.stream())
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime).reversed())
.collect(Collectors.toList());
// ❌ 移除:查询目的从变量名已可知
// 先查该任务下的所有 sessionNum
List<String> sessionNums = studySessionsMapper.selectList(...)
```
**判断标准:** 如果删掉注释后,一个熟悉 Java/Spring 的开发者看代码没有任何困惑,就该移除。
**例外:** 方法级的 Javadoc`/** ... */`)即使与代码重复,也保留,因为它服务于 IDE 提示和文档生成。
### 移除:类型 B — 框架机制注释
框架隐式行为(拦截器、MetaObjectHandler、AOP 等)的说明不在代码中重复标注,而是在 `AGENTS.md` 的"框架机制"章节集中描述。代码中的此类注释一律移除。
```java
// ❌ 移除:框架机制已在 AGENTS.md 中说明
// created_by 条件由 TenantLineInnerInterceptor 自动注入
List<StudyReportsEntity> reports = studyReportsMapper.selectList(...)
// ❌ 移除
// 拦截器自动校验归属
return Optional.ofNullable(studyReportsMapper.selectById(id)) ...
// ❌ 移除
// created_by 由 MetaObjectHandler 自动填充
studySessionsServiceImpl.save(...)
```
### 移除:类型 D — 空注释 / 无信息量注释
```java
// ❌ 空 Javadoc
/**
*
*/
@TableField(value = "created_time")
private LocalDateTime createdTime;
// ❌ 纯标注作者(无版本/日期等有价值信息时)
/**
* @author guo
*/
public class GlobalExceptionHandler { ... }
// ❌ 重复 HTTP 状态码(CommonResult.error 已隐含 400
//code:400
return CommonResult.error(ex.getMessage());
```
### 保留:类型 C — 字段/类 Javadoc
字段 Javadoc 描述数据含义,类 Javadoc 描述模块职能。
**字段 Javadoc** 保留,说明字段的业务含义。
```java
// ✅ 保留:字段含义对理解数据模型有帮助
/**
* 账号-登录用
*/
@TableField(value = "user_name")
private String userName;
```
**类 Javadoc** 保留,但只描述类的职能,不包含实现技术细节。
```java
// ❌ 移除:实现技术属于实现细节,不属于类描述
/** 复习模块 Service 实现(纯 MyBatis-Plus Java API */
// ✅ 保留:只描述职能
/** 复习模块 Service 实现 */
```
### 改造:类型 E — 枚举注释
枚举中的中文含义注释应迁移到枚举类的专用字段中,而非用注释标注。
```java
// ❌ 改造前:用注释标注含义
public enum Strategy {
// 创建组
CREATE("C"),
// 更新组
UPDATE("U");
}
// ✅ 改造后:用字段存储含义
public enum Strategy {
CREATE("C", "创建组"),
UPDATE("U", "更新组");
private final String code;
private final String label;
Strategy(String code, String label) {
this.code = code;
this.label = label;
}
public String getCode() { return code; }
public String getLabel() { return label; }
}
```
如果枚举已有 `label`/`desc` 等字段,则直接删除注释。
**常量注释保留不变:**
```java
// ✅ 常量注释保留
// 最大重试次数
private static final int MAX_RETRY = 3;
```
### 保留:类型 F — TODO / FIXME 注释
```java
// ✅ 保留
//todo 参数传递未加密
```
## 执行命令
```bash
# 获取变更的 Java 文件
git diff --cached --name-only --diff-filter=ACMR -- '*.java'
# 对每个文件逐行扫描,按上述规则处理
```
## 输出格式
清理完成后,输出简要报告:
```
✅ 清理完成,共处理 N 个文件:
- 移除冗余注释 X 处
- 改造枚举注释 Y 处(需人工确认新增字段)
- 保留注释 Z 处(字段Javadoc/TODO
```
@@ -0,0 +1,6 @@
schema_version: v1
interface:
display_name: Java 注释清理
short_description: 清理 Java 代码中的冗余注释,保留功能性注释
default_prompt: |
使用 $comment-cleanup 扫描变更的 Java 文件,移除冗余的描述性注释,保留框架机制注释、字段 Javadoc 和 TODO 注释。
@@ -15,41 +15,33 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @author guo
*/
@RestControllerAdvice @RestControllerAdvice
@Slf4j @Slf4j
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler({ErrorParameterException.class}) @ExceptionHandler({ErrorParameterException.class})
public CommonResult errorParameterException(ErrorParameterException ex) { public CommonResult errorParameterException(ErrorParameterException ex) {
//code:400
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) {
//code:400
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) {
//code:500
return CommonResult.serverError(ex.getMessage()); return CommonResult.serverError(ex.getMessage());
} }
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult MyMethodArgumentNotValidException(MethodArgumentNotValidException ex) { public CommonResult MyMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult(); BindingResult bindingResult = ex.getBindingResult();
//code:400
return CommonResult.error(bindingResult.getFieldError().getDefaultMessage()); return CommonResult.error(bindingResult.getFieldError().getDefaultMessage());
} }
@ExceptionHandler(NotLoginException.class) @ExceptionHandler(NotLoginException.class)
public CommonResult handleNotLogin(NotLoginException e, HttpServletRequest req, HttpServletResponse res) { public CommonResult handleNotLogin(NotLoginException e, HttpServletRequest req, HttpServletResponse res) {
//code:401
res.setStatus(HttpStatus.UNAUTHORIZED.value()); res.setStatus(HttpStatus.UNAUTHORIZED.value());
return new CommonResult<>(HttpStatus.UNAUTHORIZED.value(), "未登录,请重新登录", null); return new CommonResult<>(HttpStatus.UNAUTHORIZED.value(), "未登录,请重新登录", null);
} }
@@ -57,8 +49,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);
//code:400
return CommonResult.error(ex.getMessage()); return CommonResult.error(ex.getMessage());
} }
} }
@@ -4,12 +4,13 @@ import jakarta.validation.groups.Default;
/** /**
* 全局数据验证分组 * 全局数据验证分组
* GlobalValidationGroup -> OpenGroups ->ops
* @author guo
*/ */
public interface Ops { public interface Ops {
// 创建组 // 创建组
interface CreateG extends Default {} interface CreateG extends Default {
}
// 更新组 // 更新组
interface UpdateG {} interface UpdateG {
}
} }
@@ -10,21 +10,16 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* @author guo
*/
@Configuration @Configuration
public class JacksonConfig { public class JacksonConfig {
@Bean @Bean
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper mapper = builder.build(); ObjectMapper mapper = builder.build();
// 注册自定义序列化器
SimpleModule module = new SimpleModule(); SimpleModule module = new SimpleModule();
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer()); module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
mapper.registerModule(module); mapper.registerModule(module);
// 关闭序列化为时间戳(使用字符串格式)
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper; return mapper;
@@ -0,0 +1,57 @@
package com.guo.learningprogresstracker.config;
import cn.dev33.satoken.stp.StpUtil;
import net.sf.jsqlparser.expression.StringValue;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.List;
/**
* MyBatis-Plus 多租户配置
* 利用 TenantLineInnerInterceptor 自动在 SELECT/UPDATE/DELETE 语句中
* 注入 WHERE created_by = #{当前登录用户},实现行级数据隔离。
*/
@Configuration
public class MybatisPlusConfig {
/**
* 不需要租户过滤的表(仅排除不含 created_by 的系统表)
*/
private static final List<String> EXCLUDE_TABLES = Arrays.asList(
"user",
"flyway_schema_history",
"databasechangelog",
"databasechangeloglock"
);
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页拦截器必须注册,否则 selectPage 不会生成 LIMIT / COUNT
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new StringValue(StpUtil.getLoginIdAsString());
}
@Override
public String getTenantIdColumn() {
return "created_by";
}
@Override
public boolean ignoreTable(String tableName) {
return EXCLUDE_TABLES.contains(tableName.toLowerCase());
}
}));
return interceptor;
}
}
@@ -6,29 +6,22 @@ import cn.dev33.satoken.stp.StpUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays; import java.util.Arrays;
/**
* @author Administrator
*/
@Profile("dev")
@Configuration @Configuration
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class WebMvcDevConfig implements WebMvcConfigurer { @Slf4j
public class WebMvcConfig implements WebMvcConfigurer {
private final CorsProperties corsProperties; private final CorsProperties corsProperties;
// 注册拦截器
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> { registry.addInterceptor(new SaInterceptor(handle -> {
// OPTIONS 请求直接放行
if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) { if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) {
return; return;
} }
@@ -40,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins())); String[] origins = corsProperties.getAllowedOrigins();
registry.addMapping("/**") boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
.allowedOrigins(corsProperties.getAllowedOrigins()) log.info("允许cors的地址配置:{}", Arrays.toString(origins));
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") if (hasWildcard) {
.allowedHeaders("*") registry.addMapping("/**")
.allowCredentials(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials()); .allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
} else {
registry.addMapping("/**")
.allowedOrigins(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(corsProperties.getAllowCredentials() == null || corsProperties.getAllowCredentials());
}
} }
} }
@@ -1,42 +0,0 @@
package com.guo.learningprogresstracker.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.stp.StpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
@Profile({"prod", "uat"})
@Configuration
@RequiredArgsConstructor
@Slf4j
public class WebMvcProdConfig implements WebMvcConfigurer {
private final CorsProperties corsProperties;
@Override
public void addCorsMappings(CorsRegistry registry) {
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
registry.addMapping("/**")
.allowedOrigins(corsProperties.getAllowedOrigins())
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
// 注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/login");
}
}
@@ -9,9 +9,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> { public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
// 修改为ISO 8601格式,末尾带Z,表示UTC时区
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
public LocalDateTimeSerializer() { public LocalDateTimeSerializer() {
super(LocalDateTime.class); super(LocalDateTime.class);
@@ -22,7 +20,6 @@ public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
if (value == null) { if (value == null) {
gen.writeNull(); gen.writeNull();
} else { } else {
// 这里假设LocalDateTime是UTC时间,直接格式化并加Z
String formattedDate = value.format(formatter); String formattedDate = value.format(formatter);
gen.writeString(formattedDate); gen.writeString(formattedDate);
} }
@@ -13,9 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/**
* @author guo
*/
@CrossOrigin @CrossOrigin
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -1,16 +1,25 @@
package com.guo.learningprogresstracker.controller; package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
import com.guo.learningprogresstracker.entity.CommonResult; import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.impl.ReviewServiceImpl; import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.service.ReviewService;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 复习模块控制层 — 复习内容滚动展示与交互 * 复习模块控制层 — 复习内容滚动展示与交互
@@ -21,15 +30,34 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReviewController { public class ReviewController {
private final ReviewServiceImpl reviewService; private final ReviewService reviewService;
private final StandardMindMapService standardMindMapService;
/** /**
* 获取复习 feed,合并报告和残片按时间倒序 * 获取复习 feed,合并报告和残片按时间倒序
*/ */
@GetMapping("/feed") @GetMapping("/feed")
public CommonResult<List<ReviewFeedItem>> getReviewFeed( public CommonResult<List<ReviewFeedItem>> getReviewFeed(
@RequestParam(defaultValue = "30") int limit) { @RequestParam(defaultValue = "30") int limit,
return CommonResult.success(reviewService.getReviewFeed(limit)); @RequestParam(defaultValue = "recent") String mode) {
return CommonResult.success(reviewService.getReviewFeed(limit, mode));
}
/**
* 获取任务维度的复习统计汇总
*/
@GetMapping("/tasks")
public CommonResult<List<ReviewTaskStats>> getReviewTaskStats() {
return CommonResult.success(reviewService.getReviewTaskStats());
}
/**
* 获取指定任务的复习统计汇总
*/
@GetMapping("/tasks/{taskNum}")
public CommonResult<ReviewTaskStats> getReviewTaskStats(@PathVariable String taskNum) {
return CommonResult.success(reviewService.getReviewTaskStats(taskNum));
} }
/** /**
@@ -55,4 +83,78 @@ public class ReviewController {
public CommonResult<StudyReportFragmentsEntity> getFragmentDetail(@PathVariable int id) throws NotFindEntitiesException { public CommonResult<StudyReportFragmentsEntity> getFragmentDetail(@PathVariable int id) throws NotFindEntitiesException {
return CommonResult.success(reviewService.getFragmentDetail(id)); return CommonResult.success(reviewService.getFragmentDetail(id));
} }
// ============ 标准思维导图与回忆对比 ============
/**
* 获取或自动生成任务的标准思维导图
*/
@GetMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> getStandardMindMap(
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.getOrGenerate(taskNum));
}
/**
* 强制重新生成任务的标准思维导图
* @param mode 生成模式:full(全量覆盖,默认)或 incremental(增量合并)
*/
@PostMapping("/standard-mind-map/{taskNum}/regenerate")
public CommonResult<ReviewStandardMindMapEntity> regenerateStandardMindMap(
@PathVariable String taskNum,
@RequestParam(defaultValue = "full") String mode) throws NotFindEntitiesException, OperationFailedException {
if ("incremental".equals(mode)) {
return CommonResult.success(standardMindMapService.incrementalGenerate(taskNum));
}
return CommonResult.success(standardMindMapService.regenerate(taskNum));
}
/**
* 用户编辑标准思维导图(大纲文本形式)
*/
@PutMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> updateStandardMindMap(
@PathVariable String taskNum,
@Valid @RequestBody UpdateStandardMindMapRequest request) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.updateByOutline(taskNum, request.getOutline()));
}
/**
* 用户提交回忆大纲,与标准导图对比
*/
@PostMapping("/standard-mind-map/{taskNum}/recall")
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
@PathVariable String taskNum,
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getFocusPath()));
}
/**
* 在标准导图中查找与给定内容最匹配的节点
*/
@PostMapping("/standard-mind-map/{taskNum}/find-node")
public CommonResult<Map<String, Object>> findNode(
@PathVariable String taskNum,
@RequestBody Map<String, String> body) throws NotFindEntitiesException, OperationFailedException {
String content = body != null ? body.getOrDefault("content", "") : "";
return CommonResult.success(standardMindMapService.findNode(taskNum, content));
}
/**
* 获取该任务的所有回忆对比记录
*/
@GetMapping("/standard-mind-map/{taskNum}/recall-records")
public CommonResult<List<ReviewRecallRecordEntity>> listRecallRecords(
@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.listRecallRecords(taskNum));
}
/**
* 获取单条回忆对比记录详情
*/
@GetMapping("/standard-mind-map/recall-records/{recordId}")
public CommonResult<ReviewRecallRecordEntity> getRecallRecord(
@PathVariable Integer recordId) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.getRecallRecord(recordId));
}
} }
@@ -1,13 +1,16 @@
package com.guo.learningprogresstracker.controller; package com.guo.learningprogresstracker.controller;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.protobuf.ServiceException; import com.google.protobuf.ServiceException;
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.response.StudySessionResponse; import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.guo.learningprogresstracker.entity.CommonResult; import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudySessionsEntity; import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException; import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapStruct.StudySessionConvert; import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl; import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
@@ -15,8 +18,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.time.LocalDateTime;
/** /**
* 学习会话控制层 * 学习会话控制层
@@ -29,9 +32,25 @@ public class StudySessionController {
private final StudySessionsServiceImpl studySessionsServiceImpl; private final StudySessionsServiceImpl studySessionsServiceImpl;
@PostMapping private final StudyExpectationsService studyExpectationsService;
public void studySessionList() {
/**
* 创建/更新学习会话的学习预期
*/
@PutMapping("/{sessionNum}/expectation")
public CommonResult<StudyExpectationsEntity> upsertExpectation(
@PathVariable("sessionNum") String sessionNum,
@Valid @RequestBody UpsertExpectationRequest request) throws ErrorParameterException {
return CommonResult.success(studyExpectationsService.upsertExpectation(sessionNum, request.getDescription()));
}
/**
* 获取学习会话的学习预期
*/
@GetMapping("/{sessionNum}/expectation")
public CommonResult<StudyExpectationsEntity> getExpectation(
@PathVariable("sessionNum") String sessionNum) {
return CommonResult.success(studyExpectationsService.getBySessionNum(sessionNum));
} }
/** /**
@@ -39,11 +58,9 @@ public class StudySessionController {
*/ */
@GetMapping("/{sessionNum}") @GetMapping("/{sessionNum}")
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException { public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException {
StudySessionsEntity studySessionsEntity = studySessionsServiceImpl.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) // 通过 service 层获取,包含归属校验
.eq(StudySessionsEntity::getSessionNum, sessionNum)) StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); return CommonResult.success(response);
StudySessionResponse studySessionResponse = StudySessionConvert.MAPPER.toStudySessionResponse(studySessionsEntity);
return CommonResult.success(studySessionResponse);
} }
/** /**
@@ -72,8 +89,8 @@ public class StudySessionController {
@PostMapping("/{sessionNum}/study-sessions/ended") @PostMapping("/{sessionNum}/study-sessions/ended")
public CommonResult<Void> endedStudySession(@PathVariable("sessionNum") String sessionNum, public CommonResult<Void> endedStudySession(@PathVariable("sessionNum") String sessionNum,
@Valid @RequestBody EndedStudySessionRequest request) throws ErrorParameterException { @Valid @RequestBody EndedStudySessionRequest request) throws ErrorParameterException {
studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent()); String message = studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
return CommonResult.success(); return message != null ? CommonResult.success(message) : CommonResult.success();
} }
/** /**
@@ -85,4 +102,39 @@ public class StudySessionController {
return CommonResult.success(allFragments); return CommonResult.success(allFragments);
} }
/**
* 生成学习报告草稿:AI 聚合残片(不可用时降级为拼接),返回给前端作为编辑起点
*/
@GetMapping("/{sessionNum}/report-draft")
public CommonResult<String> getReportDraft(@PathVariable("sessionNum") String sessionNum) throws ErrorParameterException {
return CommonResult.success("请求成功", studySessionsServiceImpl.generateReportDraft(sessionNum));
}
/** 分页查询任务的残片历史 */
@GetMapping("/tasks/{taskNum}/fragments")
public CommonResult<Page<StudyReportFragmentsEntity>> getTaskFragments(
@PathVariable String taskNum,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
return CommonResult.success(studySessionsServiceImpl.getTaskFragments(taskNum, page, size, keyword));
}
/** 分页查询任务的报告历史 */
@GetMapping("/tasks/{taskNum}/reports")
public CommonResult<Page<StudyReportsEntity>> getTaskReports(
@PathVariable String taskNum,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
return CommonResult.success(studySessionsServiceImpl.getTaskReports(taskNum, page, size, keyword));
}
/** 查询当前是否有活跃会话(用于跨页面恢复 + 阻止多任务) */
@GetMapping("/active")
public CommonResult<StudySessionResponse> getActiveSession(
@RequestParam(required = false) String excludeTaskNum) {
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
}
} }
@@ -4,15 +4,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.protobuf.ServiceException; import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.common.Ops; import com.guo.learningprogresstracker.common.Ops;
import com.guo.learningprogresstracker.dto.TaskInfo; import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse; import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse; import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.CommonResult; import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.dto.request.TaskRequest; import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
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.service.PriorityWeightsService;
import com.guo.learningprogresstracker.service.TasksService; import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl; import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -27,6 +33,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.rmi.ServerException; import java.rmi.ServerException;
import java.util.List;
/** /**
* 任务控制层 * 任务控制层
@@ -43,10 +50,25 @@ public class TaskController {
private final StudySessionsServiceImpl studySessionsServiceImpl; private final StudySessionsServiceImpl studySessionsServiceImpl;
private final PriorityWeightsService priorityWeightsService;
@GetMapping("/priority-weights")
@Operation(summary = "获取优先级维度权重配置")
public CommonResult<UserPriorityWeightsEntity> getPriorityWeights() {
return CommonResult.success(priorityWeightsService.getWeights());
}
@PutMapping("/priority-weights")
@Operation(summary = "保存优先级维度权重配置并重算全部任务优先级")
public CommonResult<UserPriorityWeightsEntity> savePriorityWeights(
@RequestBody UserPriorityWeightsEntity weights) throws ErrorParameterException {
return CommonResult.success(priorityWeightsService.saveWeights(weights));
}
@PostMapping @PostMapping
@Operation(summary = "添加新任务") @Operation(summary = "添加新任务")
public CommonResult<String> addTask(@RequestBody @Validated({Ops.CreateG.class}) TaskRequest taskRequest) throws ErrorParameterException { public CommonResult<String> addTask(@RequestBody @Validated({Ops.CreateG.class}) TaskRequest taskRequest) throws ErrorParameterException {
return CommonResult.success(tasksService.addTask(taskRequest)); return CommonResult.success("请求成功", tasksService.addTask(taskRequest));
} }
@Operation(summary = "任务详情API") @Operation(summary = "任务详情API")
@@ -69,6 +91,36 @@ public class TaskController {
return CommonResult.success(); return CommonResult.success();
} }
@GetMapping("/{taskNum}/applications")
@Operation(summary = "获取任务应用场景列表")
public CommonResult<List<TaskApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(tasksService.getApplications(taskNum));
}
@PostMapping("/{taskNum}/applications")
@Operation(summary = "创建任务应用场景")
public CommonResult<TaskApplicationEntity> createApplication(
@PathVariable String taskNum,
@Valid @RequestBody CreateTaskApplicationRequest request) throws NotFindEntitiesException {
request.setTaskNum(taskNum);
return CommonResult.success(tasksService.createApplication(request));
}
@PutMapping("/applications/{id}")
@Operation(summary = "更新任务应用场景")
public CommonResult<TaskApplicationEntity> updateApplication(
@PathVariable Integer id,
@Valid @RequestBody UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(tasksService.updateApplication(id, request));
}
@DeleteMapping("/applications/{id}")
@Operation(summary = "删除任务应用场景")
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
tasksService.deleteApplication(id);
return CommonResult.success();
}
@GetMapping @GetMapping
@Operation(summary = "获取所有任务列表") @Operation(summary = "获取所有任务列表")
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum, public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@@ -0,0 +1,26 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.utils.TitleFetcher;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/utils")
public class UtilsController {
@GetMapping("/fetch-title")
@Operation(summary = "获取指定URL的页面标题")
public CommonResult<Map<String, String>> fetchTitle(@RequestParam String url) {
if (url == null || url.isBlank()) {
return CommonResult.error("url 参数不能为空");
}
String title = TitleFetcher.fetchTitle(url);
return CommonResult.success(Map.of("title", title != null ? title : url));
}
}
@@ -1,16 +1,17 @@
package com.guo.learningprogresstracker.controller; package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.CommonResult; import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl; import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import java.util.List;
import org.springframework.web.bind.annotation.RestController;
/** /**
* 学习残片控制层 * 学习残片控制层
@@ -24,12 +25,29 @@ public class reportFragmentsController {
/** /**
* 创建学习残片 * 创建学习残片
* @param request
* @return
*/ */
@PostMapping @PostMapping
public CommonResult<Void> createFragments(@Valid @RequestBody CreateFragmentsRequest request) throws NotFindEntitiesException { public CommonResult<Void> createFragments(@Valid @RequestBody CreateFragmentsRequest request) throws NotFindEntitiesException {
studyReportFragmentsServiceImpl.createFragments(request); studyReportFragmentsServiceImpl.createFragments(request);
return CommonResult.success(); return CommonResult.success();
} }
/**
* 更新学习残片
*/
@PutMapping("/{id}")
public CommonResult<Void> updateFragments(@PathVariable Integer id,
@Valid @RequestBody UpdateFragmentsRequest request) throws NotFindEntitiesException {
studyReportFragmentsServiceImpl.updateFragments(id, request);
return CommonResult.success();
}
/**
* 获取指定会话的所有学习残片
*/
@GetMapping("/session/{sessionNum}")
public CommonResult<List<StudyReportFragmentsEntity>> getFragmentsBySession(@PathVariable String sessionNum) {
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsServiceImpl.getFragmentsBySession(sessionNum);
return CommonResult.success(fragments);
}
} }
@@ -0,0 +1,20 @@
package com.guo.learningprogresstracker.dto;
import lombok.Data;
/**
* 任务维度的复习统计数据。
*/
@Data
public class ReviewTaskStats {
private String taskNum;
private String taskName;
private long reportCount;
private long fragmentCount;
private double effectiveTime;
private long sessionCount;
private double todayEffectiveTime;
private double weekEffectiveTime;
private double avgEffectiveTime;
private double avgEffectivenessRatio;
}
@@ -2,9 +2,6 @@ package com.guo.learningprogresstracker.dto;
import lombok.Data; import lombok.Data;
/**
* @author guo
*/
@Data @Data
public class TaskInfo { public class TaskInfo {
@@ -16,6 +13,8 @@ public class TaskInfo {
private String taskName; private String taskName;
// 任务描述 // 任务描述
private String taskDescription; private String taskDescription;
// 学习材料地址
private String materialUrl;
// 任务优先级 // 任务优先级
private Double taskPriority; private Double taskPriority;
// 上次该任务学习情况 // 上次该任务学习情况
@@ -0,0 +1,19 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateTaskApplicationRequest {
private String taskNum;
@NotBlank(message = "应用项目标题不可为空")
private String title;
private String description;
private String resourceUrl;
private String status;
}
@@ -0,0 +1,17 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 用户回忆对比请求
*/
@Data
public class RecallCompareRequest {
@NotBlank(message = "回忆大纲不可为空")
private String recallOutline;
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
private String focusPath;
}
@@ -0,0 +1,16 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* @author guo
*/
@Data
public class UpdateFragmentsRequest {
/**
* 残片内容,学习内容的描述
*/
@NotBlank(message = "啊?无字天书?")
private String content;
}
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 更新标准思维导图(大纲文本形式)请求
*/
@Data
public class UpdateStandardMindMapRequest {
@NotBlank(message = "思维导图大纲不可为空")
private String outline;
}
@@ -0,0 +1,17 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateTaskApplicationRequest {
@NotBlank(message = "应用项目标题不可为空")
private String title;
private String description;
private String resourceUrl;
private String status;
}
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 创建/更新学习预期请求
*/
@Data
public class UpsertExpectationRequest {
@NotBlank(message = "学习预期不可为空")
private String description;
}
@@ -17,6 +17,12 @@ public class StudySessionResponse {
@Schema(description = "任务编号") @Schema(description = "任务编号")
private String taskNum; private String taskNum;
@Schema(description = "学习材料(Markdown")
private String materialUrl;
@Schema(description = "任务ID")
private Integer taskId;
@Schema(description = "学习开始时间") @Schema(description = "学习开始时间")
private LocalDateTime startTime; private LocalDateTime startTime;
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
*/ */
private String materialUrl; private String materialUrl;
/**
/** /**
* 用户设置的任务紧急性 * 用户设置的任务紧急性
*/ */
@@ -0,0 +1,62 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 复习回忆与标准导图的对比记录
*/
@TableName(value = "review_recall_records")
@Data
public class ReviewRecallRecordEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "standard_map_id")
private Integer standardMapId;
/**
* 复习起点节点路径(以 / 分隔),null 表示任务维度复习
*/
@TableField(value = "focus_path")
private String focusPath;
/**
* 用户回忆绘制的导图大纲文本
*/
@TableField(value = "recall_content")
private String recallContent;
/**
* 与标准导图的结构对比结果 JSON
*/
@TableField(value = "compare_result")
private String compareResult;
/**
* 回忆覆盖率(0-1
*/
@TableField(value = "recall_ratio")
private Double recallRatio;
@TableField(value = "matched_count")
private Integer matchedCount;
@TableField(value = "missed_count")
private Integer missedCount;
@TableField(value = "extra_count")
private Integer extraCount;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,63 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
*/
@TableName(value = "review_standard_mind_maps")
@Data
public class ReviewStandardMindMapEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "title")
private String title;
/**
* 标准导图统一树结构 JSON(根节点:{title, notes, children}
*/
@TableField(value = "content")
private String content;
/**
* 缩进大纲文本,供前端展示与用户编辑
*/
@TableField(value = "outline")
private String outline;
@TableField(value = "summary")
private String summary;
/**
* 生成来源:BUILTIN/AI/USER
*/
@TableField(value = "generator")
private String generator;
@TableField(value = "generator_version")
private String generatorVersion;
@TableField(value = "source_report_count")
private Integer sourceReportCount;
@TableField(value = "source_fragment_count")
private Integer sourceFragmentCount;
@TableField(value = "generated_time")
private LocalDateTime generatedTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -4,28 +4,22 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
import java.io.Serializable;
/** /**
* 存储每次学习开始前的预期 * 存储每次学习开始前的预期
* @TableName study_expectations
*/ */
@TableName(value ="study_expectations") @TableName(value = "study_expectations")
@Data @Data
public class StudyExpectationsEntity extends BaseEntity implements Serializable { public class StudyExpectationsEntity extends BaseEntity implements Serializable {
/**
*
*/
@TableId(value = "expectation_id", type = IdType.AUTO) @TableId(value = "expectation_id", type = IdType.AUTO)
private Integer expectationId; private Integer expectationId;
/** @TableField(value = "session_num")
* private String sessionNum;
*/
@TableField(value = "session_id")
private Integer sessionId;
/** /**
* 学习预期的详细描述 * 学习预期的详细描述
@@ -33,7 +27,6 @@ public class StudyExpectationsEntity extends BaseEntity implements Serializable
@TableField(value = "description") @TableField(value = "description")
private String description; private String description;
@TableField(exist = false) @TableField(exist = false)
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
} }
@@ -33,6 +33,11 @@ public class StudyReportsEntity extends BaseEntity implements Serializable {
@TableField(value = "content") @TableField(value = "content")
private String content; private String content;
/**
* 对应学习会话的预期目标(非数据库字段,查询时填充)
*/
@TableField(exist = false)
private String sessionExpectation;
@TableField(exist = false) @TableField(exist = false)
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@@ -123,7 +123,6 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
public void pausedStudySession(LocalDateTime endTime){ public void pausedStudySession(LocalDateTime endTime){
if (this.getSessionState().equals(StudySessionStateEnum.PAUSED.name())) { if (this.getSessionState().equals(StudySessionStateEnum.PAUSED.name())) {
log.warn("不应出现的情况:暂停了一个状态为【{}】的学习会话({})", this.getSessionState(), this.getSessionNum()); log.warn("不应出现的情况:暂停了一个状态为【{}】的学习会话({})", this.getSessionState(), this.getSessionNum());
//
} else { } else {
this.setEndTime(ObjectUtils.isEmpty(endTime) ? LocalDateTime.now() : endTime); this.setEndTime(ObjectUtils.isEmpty(endTime) ? LocalDateTime.now() : endTime);
this.setActualTime(Duration.between(this.startTime, this.endTime).toSeconds()); this.setActualTime(Duration.between(this.startTime, this.endTime).toSeconds());
@@ -133,6 +132,11 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
} }
} }
/**
* 有效学习时间的最小阈值(秒),低于此值不计入总学习时间
*/
private static final double MIN_EFFECTIVE_TIME_SECONDS = 10 * 60;
/** /**
* 结束会话 * 结束会话
*/ */
@@ -141,7 +145,12 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
log.warn("不应出现的情况:结束了一个状态为【{}】的学习会话",this.getSessionState()); log.warn("不应出现的情况:结束了一个状态为【{}】的学习会话",this.getSessionState());
}else { }else {
if (this.getSessionState().equals(StudySessionStateEnum.ONGOING.name())) { if (this.getSessionState().equals(StudySessionStateEnum.ONGOING.name())) {
this.pausedStudySession(endTime); this.pausedStudySession(LocalDateTime.now());
}
if (this.getEffectiveTime() < MIN_EFFECTIVE_TIME_SECONDS) {
log.info("会话[{}]有效学习时间({}秒)不足10分钟,不计入总学习时间", this.getSessionNum(), this.getEffectiveTime());
this.setEffectiveTime(0);
this.setEffectivenessRatio(0);
} }
this.setSessionState(StudySessionStateEnum.ENDED.name()); this.setSessionState(StudySessionStateEnum.ENDED.name());
} }
@@ -0,0 +1,35 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
@TableName(value = "task_applications")
@Data
public class TaskApplicationEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "title")
private String title;
@TableField(value = "description")
private String description;
@TableField(value = "resource_url")
private String resourceUrl;
@TableField(value = "status")
private String status;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,49 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 用户自定义的任务优先级维度权重(每用户一行,依赖多租户拦截器隔离)
*/
@TableName(value = "user_priority_weights")
@Data
public class UserPriorityWeightsEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "urgency_weight")
private Double urgencyWeight;
@TableField(value = "importance_weight")
private Double importanceWeight;
@TableField(value = "content_difficulty_weight")
private Double contentDifficultyWeight;
@TableField(value = "future_value_weight")
private Double futureValueWeight;
@TableField(value = "subjective_priority_weight")
private Double subjectivePriorityWeight;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/** 系统默认权重 */
public static UserPriorityWeightsEntity defaults() {
UserPriorityWeightsEntity entity = new UserPriorityWeightsEntity();
entity.setUrgencyWeight(0.35);
entity.setImportanceWeight(0.25);
entity.setContentDifficultyWeight(0.20);
entity.setFutureValueWeight(0.10);
entity.setSubjectivePriorityWeight(0.10);
return entity;
}
}
@@ -1,35 +0,0 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import lombok.Data;
/**
* 存储用户-任务之间的对应关系
* @TableName user_task
*/
@TableName(value ="user_task")
@Data
public class UserTaskEntity extends BaseEntity implements Serializable {
@TableId
private String id;
/**
* 用户id
*/
@TableField(value = "user_id")
private Integer userId;
/**
* 任务id
*/
@TableField(value = "task_id")
private Integer taskId;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,35 @@
package com.guo.learningprogresstracker.enums;
import lombok.AllArgsConstructor;
import java.util.Arrays;
import java.util.Locale;
@AllArgsConstructor
public enum TaskApplicationStatusEnum {
TODO("TODO", "待应用"),
DOING("DOING", "应用中"),
DONE("DONE", "已完成");
private final String code;
private final String description;
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
public static TaskApplicationStatusEnum fromCodeOrDefault(String code) {
if (code == null || code.trim().isEmpty()) {
return TODO;
}
String normalized = code.trim().toUpperCase(Locale.ROOT);
return Arrays.stream(values())
.filter(status -> status.code.equals(normalized))
.findFirst()
.orElse(TODO);
}
}
@@ -1,6 +1,7 @@
package com.guo.learningprogresstracker.mapStruct; package com.guo.learningprogresstracker.mapStruct;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy; import org.mapstruct.ReportingPolicy;
@@ -11,4 +12,6 @@ public interface FragmentsConvert {
FragmentsConvert MAPPER = Mappers.getMapper(FragmentsConvert.class); FragmentsConvert MAPPER = Mappers.getMapper(FragmentsConvert.class);
StudyReportFragmentsEntity toFragmentsEntity(CreateFragmentsRequest request); StudyReportFragmentsEntity toFragmentsEntity(CreateFragmentsRequest request);
StudyReportFragmentsEntity toFragmentsEntity(UpdateFragmentsRequest request);
} }
@@ -1,24 +0,0 @@
package com.guo.learningprogresstracker.mapper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 复习模块自定义查询 Mapper
*/
@Mapper
public interface ReviewMapper {
/**
* 获取最近的学习报告和残片,合并排序后返回
*/
List<ReviewFeedItem> selectReviewFeed(@Param("limit") int limit);
/**
* 获取指定任务下的所有报告和残片,按时间倒序
*/
List<ReviewFeedItem> selectTaskReview(@Param("taskNum") String taskNum);
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewRecallRecordMapper extends BaseMapper<ReviewRecallRecordEntity> {
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewStandardMindMapMapper extends BaseMapper<ReviewStandardMindMapEntity> {
}
@@ -1,20 +1,9 @@
package com.guo.learningprogresstracker.mapper; package com.guo.learningprogresstracker.mapper;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
/**
* @author guo
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Mapper
* @createDate 2024-06-09 15:28:48
* @Entity com.guo.learningprogresstracker.entity.StudyExpectationsEntity
*/
@Mapper @Mapper
public interface StudyExpectationsMapper extends BaseMapper<StudyExpectationsEntity> { public interface StudyExpectationsMapper extends BaseMapper<StudyExpectationsEntity> {
} }
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TaskApplicationMapper extends BaseMapper<TaskApplicationEntity> {
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserPriorityWeightsMapper extends BaseMapper<UserPriorityWeightsEntity> {
}
@@ -1,20 +0,0 @@
package com.guo.learningprogresstracker.mapper;
import com.guo.learningprogresstracker.entity.UserTaskEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* @author guo
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Mapper
* @createDate 2024-06-09 15:28:48
* @Entity com.guo.learningprogresstracker.entity.UserTaskEntity
*/
@Mapper
public interface UserTaskMapper extends BaseMapper<UserTaskEntity> {
}
@@ -0,0 +1,44 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.utils.MindMapNode;
import java.util.List;
import java.util.Optional;
/**
* 思维导图 AI 生成客户端抽象。
* <p>后续接入真实 AI 时实现此接口,通过 Spring {@code @Profile} / {@code @ConditionalOnProperty} 切换。
* 当前由内置规则生成器 {@code BuiltinMindMapGenerator} 充当。</p>
*/
public interface MindMapAiClient {
/**
* 是否可用(例如:API Key 已配置)
*/
boolean isAvailable();
/**
* 根据学习数据生成标准思维导图根节点
*
* @param task 学习任务
* @param reports 该任务的全部学习报告
* @param fragments 该任务的全部学习残片
* @param applications 该任务的应用场景(可选)
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
*/
Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint);
/**
* 获取生成器标识(如 {@code BUILTIN} / {@code AI-4.5}
*/
String generatorName();
}
@@ -0,0 +1,21 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
/**
* 优先级权重配置服务
*/
public interface PriorityWeightsService {
/**
* 获取当前用户的权重配置,不存在时返回系统默认值
*/
UserPriorityWeightsEntity getWeights();
/**
* 保存权重配置并重算当前用户全部任务的优先级。
* 五项权重之和必须为 1(容差 0.001)。
*/
UserPriorityWeightsEntity saveWeights(UserPriorityWeightsEntity weights) throws ErrorParameterException;
}
@@ -1,6 +1,7 @@
package com.guo.learningprogresstracker.service; package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
@@ -15,7 +16,17 @@ public interface ReviewService {
/** /**
* 获取复习 feed 列表,合并报告和残片按时间倒序 * 获取复习 feed 列表,合并报告和残片按时间倒序
*/ */
List<ReviewFeedItem> getReviewFeed(int limit); List<ReviewFeedItem> getReviewFeed(int limit, String mode);
/**
* 获取所有任务的复习统计数据
*/
List<ReviewTaskStats> getReviewTaskStats();
/**
* 获取指定任务的复习统计数据
*/
ReviewTaskStats getReviewTaskStats(String taskNum);
/** /**
* 获取指定任务下的所有报告和残片 * 获取指定任务下的所有报告和残片
@@ -0,0 +1,61 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import java.util.List;
import java.util.Map;
/**
* 标准思维导图服务。
* <p>负责标准导图的按需生成、查询、用户编辑,以及用户回忆导图的对比。</p>
*/
public interface StandardMindMapService {
/**
* 获取指定任务的标准思维导图,不存在时自动生成。
*/
ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 强制重新生成指定任务的标准思维导图。
*/
ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 增量更新:保留用户编辑过的节点,合并最新生成的节点。
*/
ReviewStandardMindMapEntity incrementalGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 用户手动编辑标准思维导图(用缩进大纲文本替换)。
*/
ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException;
/**
* 用户提交回忆大纲,与标准导图对比并返回结果。
*
* @param recallOutline 用户回忆的缩进大纲文本
* @param focusPath 可选:标准导图中的节点路径(/ 分隔),指定后仅对比该子树
*/
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath) throws NotFindEntitiesException, OperationFailedException;
/**
* 根据内容文本在标准导图中查找最匹配的节点,返回节点路径。
*
* @return { "path": "节点A / 节点B", "nodeTitle": "节点B", "score": 0.85 }
*/
Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException;
/**
* 获取该任务的所有回忆对比记录。
*/
List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException;
/**
* 获取单条对比记录详情。
*/
ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException;
}
@@ -1,13 +1,20 @@
package com.guo.learningprogresstracker.service; package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity; import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.baomidou.mybatisplus.extension.service.IService; import com.guo.learningprogresstracker.exception.ErrorParameterException;
/** /**
* @author guo * 学习预期服务
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service */
* @createDate 2024-06-09 15:28:48 public interface StudyExpectationsService {
*/
public interface StudyExpectationsService extends IService<StudyExpectationsEntity> {
/**
* 为学习会话创建学习预期(每个会话最多一条,重复创建则覆盖)
*/
StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException;
/**
* 查询学习会话的学习预期,不存在时返回 null
*/
StudyExpectationsEntity getBySessionNum(String sessionNum);
} }
@@ -1,10 +1,13 @@
package com.guo.learningprogresstracker.service; package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.util.List;
/** /**
* @author guo * @author guo
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service * @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service
@@ -13,4 +16,8 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
public interface StudyReportFragmentsService extends IService<StudyReportFragmentsEntity> { public interface StudyReportFragmentsService extends IService<StudyReportFragmentsEntity> {
void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException; void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException;
void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException;
List<StudyReportFragmentsEntity> getFragmentsBySession(String sessionNum);
} }
@@ -2,7 +2,11 @@ package com.guo.learningprogresstracker.service;
import com.google.protobuf.ServiceException; import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse; import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.exception.ErrorParameterException; import com.guo.learningprogresstracker.exception.ErrorParameterException;
@@ -20,9 +24,15 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException; ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException;
void endedStudySession(String sessionNum, String content) throws ErrorParameterException; String endedStudySession(String sessionNum, String content) 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;
Page<StudyReportFragmentsEntity> getTaskFragments(String taskNum, int page, int size, String keyword);
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
StudySessionResponse getActiveSession(String excludeTaskNum);
} }
@@ -3,12 +3,17 @@ package com.guo.learningprogresstracker.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.dto.TaskInfo; import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest; import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse; import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException; import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.rmi.ServerException; import java.rmi.ServerException;
import java.util.List;
/** /**
* @author guo * @author guo
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
void updateTask(String taskId, TaskRequest updatedTask); void updateTask(String taskId, TaskRequest updatedTask);
void deleteTask(String taskId) throws ServerException; void deleteTask(String taskId) throws ServerException;
List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException;
TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException;
void deleteApplication(Integer id) throws NotFindEntitiesException;
} }
@@ -1,13 +0,0 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.UserTaskEntity;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* @author guo
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Service
* @createDate 2024-06-09 15:28:48
*/
public interface UserTaskService extends IService<UserTaskEntity> {
}
@@ -0,0 +1,238 @@
package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* lpt-ai 独立 AI 服务客户端。
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
* <p>
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
* </p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai-service")
@Setter
public class AiServiceClient {
/** lpt-ai 服务地址,如 http://localhost:5199 */
private String url;
/** 超时秒数(等待 AI 任务完成的最长时间) */
private int timeoutSeconds = 600;
private HttpClient httpClient;
private final ObjectMapper objectMapper = new ObjectMapper();
@PostConstruct
void init() {
httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
if (isConfigured()) {
log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds);
} else {
log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)");
}
}
public boolean isConfigured() {
return url != null && !url.isBlank();
}
// ============ 公开 API(签名不变,内部改为 submit + poll============
/**
* 调用 lpt-ai 将残片聚合为学习报告草稿。
*/
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
return Optional.empty();
}
try {
List<Map<String, String>> fragmentBodies = fragments.stream()
.map(content -> Map.of("content", content))
.toList();
Map<String, Object> params = expectation == null || expectation.isBlank()
? Map.of("taskName", taskName, "fragments", fragmentBodies)
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
Optional<JsonNode> result = submitAndWait("aggregate-report", params);
return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 从学习数据生成思维导图大纲。
*/
public Optional<String> generateMindMap(String taskName, String taskDescription,
List<String> reports, List<String> fragments) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> params = Map.of(
"taskName", taskName,
"taskDescription", taskDescription != null ? taskDescription : "",
"reports", reports != null ? reports : List.of(),
"fragments", fragments != null ? fragments : List.of()
);
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 进行语义化回忆对比。
*/
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> params = Map.of(
"taskName", taskName,
"standardOutline", standardOutline,
"recallOutline", recallOutline
);
return submitAndWait("compare-recall", params);
} catch (Exception e) {
log.warn("lpt-ai 回忆对比异常,将降级为内置算法: {}", e.getMessage());
return Optional.empty();
}
}
// ============ 内部异步 submit + poll ============
/** 提交任务并同步等待结果(内部轮询,调用方无感知) */
private Optional<JsonNode> submitAndWait(String type, Map<String, Object> params) {
String taskId = submitTask(type, params);
if (taskId == null) {
return Optional.empty();
}
return pollResult(taskId);
}
/** 提交任务到 lpt-ai /ai/tasks */
private String submitTask(String type, Map<String, Object> params) {
try {
Map<String, Object> body = Map.of("type", type, "params", params);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/tasks"))
.timeout(Duration.ofSeconds(30)) // 提交本身不应超时
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201) {
log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}",
type, response.statusCode(),
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return null;
}
JsonNode json = objectMapper.readTree(response.body());
String taskId = json.path("taskId").asText(null);
if (taskId == null || taskId.isBlank()) {
log.warn("lpt-ai 提交任务成功但未返回 taskId");
return null;
}
log.info("lpt-ai 任务已提交: type={}, taskId={}", type, taskId);
return taskId;
} catch (Exception e) {
log.warn("lpt-ai 提交任务异常: {}", e.getMessage());
return null;
}
}
/** 轮询等待任务完成 */
private Optional<JsonNode> pollResult(String taskId) {
Instant deadline = Instant.now().plusSeconds(timeoutSeconds);
int pollCount = 0;
try {
// 给 worker 一点启动时间
Thread.sleep(500);
while (Instant.now().isBefore(deadline)) {
pollCount++;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/tasks/" + taskId))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
log.warn("lpt-ai 任务 {} 不存在(可能已过期或被清理)", taskId);
return Optional.empty();
}
if (response.statusCode() != 200) {
log.warn("lpt-ai 查询任务失败: taskId={}, status={}", taskId, response.statusCode());
// 临时故障,继续轮询
Thread.sleep(2000);
continue;
}
JsonNode json = objectMapper.readTree(response.body());
String status = json.path("status").asText("");
switch (status) {
case "done":
log.info("lpt-ai 任务完成: taskId={}, 轮询次数={}", taskId, pollCount);
return Optional.ofNullable(json.path("result"));
case "failed":
String err = json.path("error").asText("未知错误");
log.warn("lpt-ai 任务失败: taskId={}, error={}", taskId, err);
return Optional.empty();
case "pending":
case "running":
// 还在处理,继续轮询
break;
default:
log.warn("lpt-ai 未知任务状态: taskId={}, status={}", taskId, status);
return Optional.empty();
}
} catch (Exception e) {
log.warn("lpt-ai 轮询异常 (第{}次): {}", pollCount, e.getMessage());
}
// 退避:前 10 次 2s,之后 5s
long delay = pollCount <= 10 ? 2000 : 5000;
Thread.sleep(delay);
}
log.warn("lpt-ai 任务超时: taskId={}, timeout={}s", taskId, timeoutSeconds);
return Optional.empty();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("lpt-ai 轮询被中断: taskId={}", taskId);
return Optional.empty();
}
}
}
@@ -0,0 +1,181 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
* <p>规则策略:</p>
* <ol>
* <li>根节点 = 任务名称</li>
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
* <li>二级分支 = 该会话下的残片标题</li>
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
* </ol>
*/
@Slf4j
@Component
public class BuiltinMindMapGenerator implements MindMapAiClient {
private static final int MAX_TITLE_LENGTH = 60;
@Override
public boolean isAvailable() {
return true; // 始终可用
}
@Override
public String generatorName() {
return "BUILTIN";
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (reports.isEmpty() && fragments.isEmpty()) {
return Optional.empty();
}
MindMapNode root = new MindMapNode(task.getTaskName() != null ? task.getTaskName() : "学习任务");
root.setNotes(task.getTaskDescription() != null ? task.getTaskDescription() : "");
// 按 session 分组
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
// 合并所有 session
Set<String> allSessions = new LinkedHashSet<>();
allSessions.addAll(reportsBySession.keySet());
allSessions.addAll(fragmentsBySession.keySet());
for (String sessionNum : allSessions) {
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of());
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of());
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录"
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);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) {
String content = report.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode reportNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
reportNode.setNotes(content);
reportNode.setSourceType("REPORT");
reportNode.setSourceId(report.getId());
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);
}
// 应用场景分支(如有)
if (applications != null && !applications.isEmpty()) {
MindMapNode appNode = new MindMapNode("应用场景");
for (TaskApplicationEntity app : applications) {
if (app.getTitle() == null || app.getTitle().isBlank()) continue;
MindMapNode child = new MindMapNode(app.getTitle());
child.setNotes(app.getDescription() != null ? app.getDescription() : "");
child.setSourceType("APPLICATION");
child.setSourceId(app.getId());
appNode.getChildren().add(child);
}
if (!appNode.getChildren().isEmpty()) {
root.getChildren().add(appNode);
}
}
// 去重:同级 title 标准化后合并
deduplicateChildren(root);
log.info("BuiltinMindMapGenerator: 为任务[{}]生成导图,共 {} 个节点,{} 层",
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
return Optional.of(root);
}
/** 同级节点按标准化标题去重 */
private void deduplicateChildren(MindMapNode node) {
if (node.getChildren() == null || node.getChildren().isEmpty()) return;
Map<String, MindMapNode> seen = new LinkedHashMap<>();
for (MindMapNode child : node.getChildren()) {
String key = normalize(child.getTitle());
seen.merge(key, child, (a, b) -> {
// 保留 notes 较长的那个
if (b.getNotes() != null && b.getNotes().length() > (a.getNotes() != null ? a.getNotes().length() : 0)) {
a.setNotes(b.getNotes());
}
// 合并子节点
if (b.getChildren() != null) {
a.getChildren().addAll(b.getChildren());
}
return a;
});
}
node.setChildren(new ArrayList<>(seen.values()));
for (MindMapNode child : node.getChildren()) {
deduplicateChildren(child);
}
}
private static String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
.toLowerCase(Locale.ROOT)
.trim();
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
if (s.length() <= maxLen) return s;
return s.substring(0, maxLen) + "";
}
private static String formatDate(LocalDateTime dt) {
if (dt == null) return "";
return dt.format(DateTimeFormatter.ofPattern("MM-dd"));
}
}
@@ -0,0 +1,99 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.mapper.UserPriorityWeightsMapper;
import com.guo.learningprogresstracker.service.PriorityWeightsService;
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 优先级权重配置服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PriorityWeightsServiceImpl implements PriorityWeightsService {
private static final double SUM_TOLERANCE = 0.001;
private final UserPriorityWeightsMapper weightsMapper;
private final TasksMapper tasksMapper;
@Override
public UserPriorityWeightsEntity getWeights() {
UserPriorityWeightsEntity existing = weightsMapper.selectOne(
Wrappers.<UserPriorityWeightsEntity>lambdaQuery().last("LIMIT 1"));
return existing != null ? existing : UserPriorityWeightsEntity.defaults();
}
@Override
@Transactional
public UserPriorityWeightsEntity saveWeights(UserPriorityWeightsEntity weights) throws ErrorParameterException {
validate(weights);
UserPriorityWeightsEntity existing = weightsMapper.selectOne(
Wrappers.<UserPriorityWeightsEntity>lambdaQuery().last("LIMIT 1"));
if (existing == null) {
weightsMapper.insert(weights);
} else {
weights.setId(existing.getId());
weightsMapper.updateById(weights);
}
recalculateAllTasks(weights);
return weights;
}
/** 用新权重重算当前用户的全部任务优先级 */
private void recalculateAllTasks(UserPriorityWeightsEntity weights) {
List<TaskEntity> tasks = tasksMapper.selectList(Wrappers.lambdaQuery(TaskEntity.class));
for (TaskEntity task : tasks) {
PriorityDto dto = new PriorityDto();
dto.setUrgency(orZero(task.getUrgency()));
dto.setImportance(orZero(task.getImportance()));
dto.setContentDifficulty(orZero(task.getContentDifficulty()));
dto.setFutureValue(orZero(task.getFutureValue()));
dto.setSubjectivePriority(orZero(task.getSubjectivePriority()));
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(dto, weights));
tasksMapper.updateById(task);
}
log.info("权重更新,已重算 {} 个任务的优先级", tasks.size());
}
private void validate(UserPriorityWeightsEntity weights) throws ErrorParameterException {
double[] values = {
orZero(weights.getUrgencyWeight()),
orZero(weights.getImportanceWeight()),
orZero(weights.getContentDifficultyWeight()),
orZero(weights.getFutureValueWeight()),
orZero(weights.getSubjectivePriorityWeight())};
double sum = 0;
for (double v : values) {
if (v < 0 || v > 1) {
throw new ErrorParameterException("权重必须在 0 到 1 之间");
}
sum += v;
}
if (Math.abs(sum - 1.0) > SUM_TOLERANCE) {
throw new ErrorParameterException("五项权重之和必须为 1,当前为 " + sum);
}
}
private static int orZero(Integer v) {
return v == null ? 0 : v;
}
private static double orZero(Double v) {
return v == null ? 0 : v;
}
}
@@ -0,0 +1,82 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 远程 AI 思维导图生成客户端。
* <p>通过 lpt-ai 服务({@code lpt.ai-service.url})调用大语言模型生成思维导图。
* 未配置 lpt-ai 时不可用,会回退到 {@link BuiltinMindMapGenerator}。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RemoteAiMindMapClient implements MindMapAiClient {
private final AiServiceClient aiServiceClient;
@Override
public boolean isAvailable() {
return aiServiceClient.isConfigured();
}
@Override
public String generatorName() {
return "AI";
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (!isAvailable()) {
return Optional.empty();
}
// 提取报告和残片的内容文本
List<String> reportTexts = reports.stream()
.map(StudyReportsEntity::getContent)
.filter(c -> c != null && !c.isBlank())
.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()) {
return Optional.empty();
}
Optional<String> optOutline = aiServiceClient.generateMindMap(
task.getTaskName(),
task.getTaskDescription(),
reportTexts,
fragmentTexts
);
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
log.warn("RemoteAiMindMapClient: lpt-ai 返回空大纲,降级");
return Optional.empty();
}
String outline = optOutline.get();
MindMapNode root = MindMapTreeTool.parseOutline(outline);
log.info("RemoteAiMindMapClient: 为任务[{}]生成 AI 导图,共 {} 个节点,{} 层",
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
return Optional.of(root);
}
}
@@ -1,18 +1,30 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.ReviewMapper; import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
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.TasksMapper;
import com.guo.learningprogresstracker.service.ReviewService; import com.guo.learningprogresstracker.service.ReviewService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List; import java.time.DayOfWeek;
import java.util.Optional; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/** /**
* 复习模块 Service 实现 * 复习模块 Service 实现
@@ -21,18 +33,192 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService { public class ReviewServiceImpl implements ReviewService {
private final ReviewMapper reviewMapper; private static final int DEFAULT_LIMIT = 30;
private static final int MAX_LIMIT = 100;
private static final String RANDOM_MODE = "random";
private static final String SMART_MODE = "smart";
private static final int SMART_CANDIDATE_MULTIPLIER = 5;
private final StudyReportsMapper studyReportsMapper; private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper; private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
@Override @Override
public List<ReviewFeedItem> getReviewFeed(int limit) { public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
return reviewMapper.selectReviewFeed(limit); int safeLimit = normalizeLimit(limit);
if (SMART_MODE.equalsIgnoreCase(mode)) {
return getSmartFeed(safeLimit);
}
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
List<StudyReportsEntity> reports = random
? studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit))
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.orderByDesc(StudyReportsEntity::getCreatedTime)
.last("LIMIT " + safeLimit));
List<StudyReportFragmentsEntity> fragments = random
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit))
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
.last("LIMIT " + safeLimit));
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments);
if (random) {
Collections.shuffle(items);
}
return items.stream().limit(safeLimit).collect(Collectors.toList());
}
/**
* 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。
* 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。
* 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。
*/
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
if (candidates.size() <= safeLimit) {
Collections.shuffle(candidates);
return candidates;
}
// 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习)
Map<String, Double> taskRecallRatio = latestRecallRatioByTask(
candidates.stream().map(ReviewFeedItem::getTaskNum)
.filter(Objects::nonNull).collect(Collectors.toSet()));
LocalDateTime now = LocalDateTime.now();
List<Double> weights = new ArrayList<>(candidates.size());
for (ReviewFeedItem item : candidates) {
weights.add(reviewNeedWeight(item, taskRecallRatio, now));
}
return weightedSample(candidates, weights, safeLimit);
}
/** 单条内容的复习需求权重 */
private double reviewNeedWeight(ReviewFeedItem item, Map<String, Double> taskRecallRatio, LocalDateTime now) {
// 时间衰减:7 天内权重较低,之后随天数增长,90 天封顶
double ageDays = item.getCreatedTime() == null ? 30
: Math.max(0, java.time.Duration.between(item.getCreatedTime(), now).toDays());
double ageFactor = 0.3 + Math.min(ageDays, 90) / 90.0 * 0.7;
// 掌握度:最近回忆覆盖率越低,权重越高
double ratio = item.getTaskNum() == null ? 0
: taskRecallRatio.getOrDefault(item.getTaskNum(), 0D);
double masteryFactor = 1.0 - ratio * 0.7; // 覆盖率 100% 时权重降至 0.3
return ageFactor * masteryFactor;
}
/** 查询每个任务最近一次回忆对比的覆盖率 */
private Map<String, Double> latestRecallRatioByTask(Set<String> taskNums) {
if (taskNums.isEmpty()) {
return Collections.emptyMap();
}
List<ReviewRecallRecordEntity> records = reviewRecallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.in(ReviewRecallRecordEntity::getTaskNum, taskNums)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
Map<String, Double> result = new HashMap<>();
for (ReviewRecallRecordEntity record : records) {
result.putIfAbsent(record.getTaskNum(),
record.getRecallRatio() == null ? 0D : record.getRecallRatio());
}
return result;
}
/** 按权重不放回采样 */
private List<ReviewFeedItem> weightedSample(List<ReviewFeedItem> items, List<Double> weights, int count) {
List<ReviewFeedItem> pool = new ArrayList<>(items);
List<Double> poolWeights = new ArrayList<>(weights);
List<ReviewFeedItem> selected = new ArrayList<>(count);
Random random = new Random();
while (selected.size() < count && !pool.isEmpty()) {
double total = poolWeights.stream().mapToDouble(Double::doubleValue).sum();
double r = random.nextDouble() * total;
double cumulative = 0;
int chosen = pool.size() - 1;
for (int i = 0; i < pool.size(); i++) {
cumulative += poolWeights.get(i);
if (r <= cumulative) {
chosen = i;
break;
}
}
selected.add(pool.remove(chosen));
poolWeights.remove(chosen);
}
return selected;
}
@Override
public List<ReviewTaskStats> getReviewTaskStats() {
List<TaskEntity> tasks = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.orderByDesc(TaskEntity::getCalculatedPriority));
List<ReviewTaskStats> stats = tasks.stream()
.map(this::newTaskStats)
.collect(Collectors.toList());
fillReviewTaskStats(stats);
return stats;
}
@Override
public ReviewTaskStats getReviewTaskStats(String taskNum) {
TaskEntity task = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.eq(TaskEntity::getTaskNum, taskNum)
.last("LIMIT 1"))
.stream()
.findFirst()
.orElse(null);
ReviewTaskStats stats = newTaskStats(taskNum, task == null ? null : task.getTaskName());
fillReviewTaskStats(Collections.singletonList(stats));
return stats;
} }
@Override @Override
public List<ReviewFeedItem> getTaskReview(String taskNum) { public List<ReviewFeedItem> getTaskReview(String taskNum) {
return reviewMapper.selectTaskReview(taskNum); List<String> sessionNums = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
.select(StudySessionsEntity::getSessionNum))
.stream()
.map(StudySessionsEntity::getSessionNum)
.collect(Collectors.toList());
if (sessionNums.isEmpty()) {
return Collections.emptyList();
}
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums)
.orderByDesc(StudyReportsEntity::getCreatedTime));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime));
return mergeAndConvert(reports, fragments);
} }
@Override @Override
@@ -46,4 +232,182 @@ public class ReviewServiceImpl implements ReviewService {
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id)) return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在")); .orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
} }
/**
* 将报告和残片合并转换为 ReviewFeedItem 列表,按创建时间倒序排列
*/
private List<ReviewFeedItem> mergeAndConvert(List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments) {
Set<String> allSessionNums = Stream.concat(
reports.stream().map(StudyReportsEntity::getSessionNum),
fragments.stream().map(StudyReportFragmentsEntity::getSessionNum)
).collect(Collectors.toSet());
if (allSessionNums.isEmpty()) {
return Collections.emptyList();
}
Map<String, String> sessionToTaskMap = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.in(StudySessionsEntity::getSessionNum, allSessionNums)
.select(StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum))
.stream()
.collect(Collectors.toMap(
StudySessionsEntity::getSessionNum,
StudySessionsEntity::getTaskNum,
(a, b) -> a));
Set<String> taskNums = new HashSet<>(sessionToTaskMap.values());
Map<String, String> taskNameMap = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.in(TaskEntity::getTaskNum, taskNums)
.select(TaskEntity::getTaskNum, TaskEntity::getTaskName))
.stream()
.collect(Collectors.toMap(
TaskEntity::getTaskNum,
TaskEntity::getTaskName,
(a, b) -> a));
List<ReviewFeedItem> reportItems = reports.stream().map(r -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(r.getId());
item.setSessionNum(r.getSessionNum());
item.setSourceType("REPORT");
item.setContent(r.getContent());
item.setCreatedTime(r.getCreatedTime());
String taskNum = sessionToTaskMap.get(r.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
List<ReviewFeedItem> fragmentItems = fragments.stream().map(f -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(f.getId());
item.setSessionNum(f.getSessionNum());
item.setSourceType("FRAGMENT");
item.setContent(f.getContent());
item.setCreatedTime(f.getCreatedTime());
String taskNum = sessionToTaskMap.get(f.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
return Stream.concat(reportItems.stream(), fragmentItems.stream())
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());
}
private ReviewTaskStats newTaskStats(TaskEntity task) {
return newTaskStats(task.getTaskNum(), task.getTaskName());
}
private ReviewTaskStats newTaskStats(String taskNum, String taskName) {
ReviewTaskStats stats = new ReviewTaskStats();
stats.setTaskNum(taskNum);
stats.setTaskName(taskName);
return stats;
}
private int normalizeLimit(int limit) {
if (limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private void fillReviewTaskStats(List<ReviewTaskStats> statsList) {
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
.filter(item -> StringUtils.hasText(item.getTaskNum()))
.collect(Collectors.toMap(
ReviewTaskStats::getTaskNum,
item -> item,
(a, b) -> a,
LinkedHashMap::new));
if (statsByTaskNum.isEmpty()) {
return;
}
List<StudySessionsEntity> sessions = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.in(StudySessionsEntity::getTaskNum, statsByTaskNum.keySet()));
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay();
Map<String, String> sessionToTaskNum = new HashMap<>();
Map<String, Double> effectivenessRatioSum = new HashMap<>();
for (StudySessionsEntity session : sessions) {
ReviewTaskStats stats = statsByTaskNum.get(session.getTaskNum());
if (stats == null) {
continue;
}
if (StringUtils.hasText(session.getSessionNum())) {
sessionToTaskNum.put(session.getSessionNum(), session.getTaskNum());
}
double effectiveTime = session.getEffectiveTime();
stats.setSessionCount(stats.getSessionCount() + 1);
stats.setEffectiveTime(stats.getEffectiveTime() + effectiveTime);
effectivenessRatioSum.merge(session.getTaskNum(), session.getEffectivenessRatio(), Double::sum);
LocalDateTime startTime = session.getStartTime();
if (startTime != null && !startTime.isBefore(todayStart)) {
stats.setTodayEffectiveTime(stats.getTodayEffectiveTime() + effectiveTime);
}
if (startTime != null && !startTime.isBefore(weekStart)) {
stats.setWeekEffectiveTime(stats.getWeekEffectiveTime() + effectiveTime);
}
}
statsByTaskNum.forEach((taskNum, stats) -> {
if (stats.getSessionCount() > 0) {
stats.setAvgEffectiveTime(stats.getEffectiveTime() / stats.getSessionCount());
stats.setAvgEffectivenessRatio(effectivenessRatioSum.getOrDefault(taskNum, 0D) / stats.getSessionCount());
}
});
fillReportAndFragmentCount(sessionToTaskNum, statsByTaskNum);
}
private void fillReportAndFragmentCount(Map<String, String> sessionToTaskNum,
Map<String, ReviewTaskStats> statsByTaskNum) {
if (sessionToTaskNum.isEmpty()) {
return;
}
Set<String> sessionNums = sessionToTaskNum.keySet();
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums)
.select(StudyReportsEntity::getSessionNum));
for (StudyReportsEntity report : reports) {
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(report.getSessionNum()));
if (stats != null) {
stats.setReportCount(stats.getReportCount() + 1);
}
}
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
.select(StudyReportFragmentsEntity::getSessionNum));
for (StudyReportFragmentsEntity fragment : fragments) {
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(fragment.getSessionNum()));
if (stats != null) {
stats.setFragmentCount(stats.getFragmentCount() + 1);
}
}
}
} }
@@ -0,0 +1,556 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.*;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.mapper.*;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import com.guo.learningprogresstracker.utils.CompareResult;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
/**
* 标准思维导图服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StandardMindMapServiceImpl implements StandardMindMapService {
private final ReviewStandardMindMapMapper standardMindMapMapper;
private final ReviewRecallRecordMapper recallRecordMapper;
private final TasksMapper tasksMapper;
private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final TaskApplicationMapper taskApplicationMapper;
private final StudySessionsMapper studySessionsMapper;
private final List<MindMapAiClient> aiClients;
private final ObjectMapper objectMapper;
private final AiServiceClient aiServiceClient;
private static final String GENERATOR_BUILTIN = "BUILTIN";
private static final String GENERATOR_USER = "USER";
private static final String GENERATOR_USER_MERGE = "USER_MERGE";
private static final String MATCH_STATUS_MATCHED = "MATCHED";
private static final String MATCH_STATUS_MISSED = "MISSED";
/** 防并发生成:taskNum → 是否正在生成中 */
private final ConcurrentHashMap<String, AtomicBoolean> generatingTasks = new ConcurrentHashMap<>();
// ============ 查询与生成 ============
@Override
public ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
return existing != null ? existing : doGenerate(taskNum);
}
@Override
public ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
// 防并发生成:同一任务正在生成时直接返回当前实体
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
if (!lock.compareAndSet(false, true)) {
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
log.warn("任务[{}]正在生成中,跳过重复请求", taskNum);
return existing;
}
try {
return doGenerate(taskNum);
} finally {
lock.set(false);
generatingTasks.remove(taskNum);
}
}
@Override
@Transactional
public ReviewStandardMindMapEntity incrementalGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) {
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新");
}
// 防并发生成
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
if (!lock.compareAndSet(false, true)) {
log.warn("任务[{}]正在生成中,跳过重复增量请求", taskNum);
return existing;
}
try {
ReviewStandardMindMapEntity fresh = doGenerate(taskNum);
// 合并新旧树:保留用户编辑过的节点,追加新节点
MindMapNode oldRoot = MindMapTreeTool.fromJson(existing.getContent(), objectMapper);
MindMapNode newRoot = MindMapTreeTool.fromJson(fresh.getContent(), objectMapper);
MindMapNode merged = MindMapTreeTool.mergeTrees(oldRoot, newRoot);
String mergedJson = MindMapTreeTool.toJson(merged, objectMapper);
String mergedOutline = MindMapTreeTool.toFullOutline(merged);
existing.setContent(mergedJson);
existing.setOutline(mergedOutline);
existing.setTitle(merged.getTitle() != null ? merged.getTitle() : "思维导图");
existing.setGenerator(GENERATOR_USER_MERGE);
existing.setGeneratorVersion(fresh.getGeneratorVersion());
existing.setSummary("增量更新,共 " + MindMapTreeTool.countNodes(merged) + " 个节点");
existing.setGeneratedTime(LocalDateTime.now());
standardMindMapMapper.updateById(existing);
return existing;
} finally {
lock.set(false);
generatingTasks.remove(taskNum);
}
}
@Override
@Transactional
public ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) {
existing = new ReviewStandardMindMapEntity();
existing.setTaskNum(taskNum);
}
// 解析大纲为树节点
MindMapNode root = MindMapTreeTool.parseOutline(outline);
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
existing.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
existing.setContent(contentJson);
existing.setOutline(outline);
existing.setGenerator(GENERATOR_USER);
existing.setGeneratorVersion(null);
existing.setSummary("用户编辑,共 " + MindMapTreeTool.countNodes(root) + " 个节点");
existing.setGeneratedTime(LocalDateTime.now());
if (existing.getId() == null) {
standardMindMapMapper.insert(existing);
} else {
standardMindMapMapper.updateById(existing);
}
return existing;
}
// ============ 回忆对比 ============
@Override
@Transactional
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
// 1. 获取标准导图(自动生成)
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
// 2. 解析标准树和用户回忆树
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
// 2b. 若指定了起点节点路径,提取该子树作为对比基准
MindMapNode compareRoot = standardRoot;
if (focusPath != null && !focusPath.isBlank()) {
compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath);
if (compareRoot == null) {
log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath);
compareRoot = standardRoot;
}
}
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
CompareResult result;
if (aiServiceClient.isConfigured()) {
String taskName = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"))
.getTaskName();
var optJson = aiServiceClient.compareRecall(
taskName,
MindMapTreeTool.toFullOutline(compareRoot),
recallOutline
);
if (optJson.isPresent()) {
result = buildCompareResultFromAI(optJson.get(), compareRoot);
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
taskNum, result.getRecallRatio(),
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
} else {
result = compareTrees(compareRoot, recallRoot);
}
} else {
result = compareTrees(compareRoot, recallRoot);
}
// 4. 序列化对比结果
String resultJson;
try {
resultJson = objectMapper.writeValueAsString(result);
} catch (Exception e) {
throw new OperationFailedException("对比结果序列化失败");
}
// 5. 保存回忆记录
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
record.setTaskNum(taskNum);
record.setStandardMapId(standard.getId());
record.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null);
record.setRecallContent(recallOutline);
record.setCompareResult(resultJson);
record.setRecallRatio(result.getRecallRatio());
record.setMatchedCount(result.getMatchedCount());
record.setMissedCount(result.getMissedCount());
record.setExtraCount(result.getExtraCount());
recallRecordMapper.insert(record);
log.info("回忆对比: taskNum={}, recallRatio={}, matched={}, missed={}, extra={}",
taskNum, result.getRecallRatio(), result.getMatchedCount(),
result.getMissedCount(), result.getExtraCount());
return standard;
}
// ============ 节点查找 ============
@Override
public Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
MindMapNode root = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode closest = MindMapTreeTool.findClosestNode(root, content);
Map<String, Object> result = new LinkedHashMap<>();
if (closest != null) {
String path = MindMapTreeTool.getPath(root, closest.getTitle());
result.put("path", path);
result.put("nodeTitle", closest.getTitle());
result.put("score", MindMapTreeTool.similarityScore(closest.getTitle(), content));
} else {
result.put("path", "");
result.put("nodeTitle", "");
result.put("score", 0.0);
}
return result;
}
// ============ 对比算法核心 ============
/**
* 两颗树的节点级对比算法。
*/
CompareResult compareTrees(MindMapNode standardRoot, MindMapNode recallRoot) {
CompareResult result = new CompareResult();
// 展平标准树
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
// 展平回忆树(排除根节点本身)
List<MindMapNode> recallFlat = MindMapTreeTool.flatten(recallRoot).stream()
.filter(n -> n != recallRoot)
.collect(Collectors.toList());
// 构建回忆节点标题 → 节点映射(标准化后)
Map<String, MindMapNode> recallTitleMap = new LinkedHashMap<>();
for (MindMapNode node : recallFlat) {
recallTitleMap.merge(normalize(node.getTitle()), node, (a, b) -> a);
}
// 标注标准树
int matched = 0, missed = 0;
for (MindMapNode node : standardFlat) {
if (node == standardRoot) continue; // 跳过根节点
String key = normalize(node.getTitle());
boolean found = recallTitleMap.containsKey(key);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
// 尝试模糊匹配
found = fuzzyMatch(node.getTitle(), recallTitleMap);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
missed++;
}
}
}
// 找出额外节点(用户在回忆中新增的、标准树中没有的)
Set<String> standardNormTitles = standardFlat.stream()
.map(n -> normalize(n.getTitle()))
.collect(Collectors.toSet());
List<CompareResult.FlatNode> extraNodes = new ArrayList<>();
for (MindMapNode node : recallFlat) {
String key = normalize(node.getTitle());
if (!standardNormTitles.contains(key)) {
CompareResult.FlatNode flat = new CompareResult.FlatNode();
flat.setTitle(node.getTitle());
flat.setPath(node.getTitle()); // 简化路径
extraNodes.add(flat);
}
}
int total = matched + missed;
result.setMatchedTree(standardRoot);
result.setExtraNodes(extraNodes);
result.setMatchedCount(matched);
result.setMissedCount(missed);
result.setExtraCount(extraNodes.size());
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
return result;
}
/**
* 从 AI 返回的平铺匹配列表构建 CompareResult,并将 MATCHED/MISSED 标注回标准树。
* <p>AI 只输出哪些节点匹配/遗漏,树结构标注由本方法确定性完成,避免 LLM 输出不可靠的嵌套 JSON。</p>
*/
private CompareResult buildCompareResultFromAI(JsonNode aiJson, MindMapNode standardRoot) {
CompareResult result = new CompareResult();
// 1. 读取 AI 返回的匹配对 → 构建 standardNorm → matchFlag
Set<String> matchedStandardTitles = new HashSet<>();
JsonNode matchesArr = aiJson.path("matches");
if (matchesArr.isArray()) {
for (JsonNode m : matchesArr) {
String stdTitle = m.path("standardTitle").asText(null);
if (stdTitle != null) {
matchedStandardTitles.add(normalize(stdTitle));
}
}
}
// 2. 读取遗漏列表
Set<String> missedTitles = new HashSet<>();
JsonNode missedArr = aiJson.path("missedTitles");
if (missedArr.isArray()) {
for (JsonNode t : missedArr) {
String title = t.asText(null);
if (title != null) missedTitles.add(normalize(title));
}
}
// 3. 标注标准树的每个非根节点
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
int matched = 0, missed = 0;
for (MindMapNode node : standardFlat) {
if (node == standardRoot) continue;
String key = normalize(node.getTitle());
if (matchedStandardTitles.contains(key)) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
missed++;
}
}
// 4. 读取 extraNodes(兼容字符串数组和对象数组两种格式)
List<CompareResult.FlatNode> extras = new ArrayList<>();
JsonNode extrasArr = aiJson.path("extraNodes");
if (extrasArr.isArray()) {
for (JsonNode e : extrasArr) {
CompareResult.FlatNode fn = new CompareResult.FlatNode();
if (e.isTextual()) {
// 字符串格式 ["知识点D1", "知识点D2"]
fn.setTitle(e.asText(""));
fn.setPath("");
} else {
// 对象格式 [{ title: "...", path: "..." }]
fn.setTitle(e.path("title").asText(""));
fn.setPath(e.path("path").asText(""));
}
extras.add(fn);
}
}
// 5. 组装结果
result.setMatchedTree(standardRoot);
result.setExtraNodes(extras);
result.setMatchedCount(matched);
result.setMissedCount(missed);
result.setExtraCount(extras.size());
int total = matched + missed;
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
result.setEvaluation(aiJson.path("evaluation").asText(null));
return result;
}
// ============ 回忆记录查询 ============
@Override
public List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return recallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.eq(ReviewRecallRecordEntity::getTaskNum, taskNum)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
}
@Override
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
}
// ============ 内部方法 ============
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
TaskEntity task = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
if (task == null) {
throw new OperationFailedException("任务[" + taskNum + "]不存在");
}
// 收集学习数据
List<String> sessionNums = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
.select(StudySessionsEntity::getSessionNum))
.stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList());
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.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(
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
if (reports.isEmpty() && fragments.isEmpty()) {
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
}
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
.orElse(null);
if (client == null) {
throw new OperationFailedException("没有可用的思维导图生成器");
}
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
// AI 生成失败时尝试降级到内置生成器
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
log.info("AI 思维导图生成失败,降级到内置生成器");
MindMapAiClient fallback = aiClients.stream()
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
.findFirst().orElse(null);
if (fallback != null) {
optRoot = fallback.generate(task, reports, fragments, applications, null);
}
}
if (optRoot.isEmpty()) {
throw new OperationFailedException("思维导图生成失败");
}
MindMapNode root = optRoot.get();
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
String outline = MindMapTreeTool.toFullOutline(root);
int nodeCount = MindMapTreeTool.countNodes(root);
int depth = MindMapTreeTool.maxDepth(root);
ReviewStandardMindMapEntity entity = queryByTaskNum(taskNum);
boolean create = entity == null;
if (create) {
entity = new ReviewStandardMindMapEntity();
entity.setTaskNum(taskNum);
}
entity.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
entity.setContent(contentJson);
entity.setOutline(outline);
entity.setSummary("" + nodeCount + " 个节点,最大层级 " + depth);
entity.setGenerator(client.generatorName());
entity.setGeneratorVersion("1.0");
entity.setSourceReportCount(reports.size());
entity.setSourceFragmentCount(fragments.size());
entity.setGeneratedTime(LocalDateTime.now());
if (create) {
standardMindMapMapper.insert(entity);
} else {
standardMindMapMapper.updateById(entity);
}
return entity;
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) {
return standardMindMapMapper.selectOne(
Wrappers.<ReviewStandardMindMapEntity>lambdaQuery()
.eq(ReviewStandardMindMapEntity::getTaskNum, taskNum)
.last("LIMIT 1"));
}
// ============ 标题归一化 ============
/**
* 标准化标题用于对比:去空格、去标点、转小写。
*/
static String normalize(String s) {
if (s == null) return "";
// 先移除内部对比标记前缀,再清理标点
String result = s.replaceAll("^(?:MATCHED|MISSED)\\|", "");
result = result.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "");
return result.toLowerCase(Locale.ROOT).trim();
}
/**
* 模糊匹配:计算字符 bigram Jaccard 相似度
*/
static boolean fuzzyMatch(String title, Map<String, MindMapNode> recallTitleMap) {
if (title == null || title.isBlank()) return false;
String norm = normalize(title);
Set<String> bigrams = bigramSet(norm);
if (bigrams.isEmpty()) return false;
for (String recallKey : recallTitleMap.keySet()) {
Set<String> recallBigrams = bigramSet(recallKey);
if (recallBigrams.isEmpty()) continue;
// Jaccard
Set<String> intersection = new HashSet<>(bigrams);
intersection.retainAll(recallBigrams);
Set<String> union = new HashSet<>(bigrams);
union.addAll(recallBigrams);
double similarity = (double) intersection.size() / union.size();
if (similarity >= 0.6) {
return true;
}
}
return false;
}
static Set<String> bigramSet(String s) {
Set<String> set = new HashSet<>();
for (int i = 0; i < s.length() - 1; i++) {
set.add(s.substring(i, i + 2));
}
return set;
}
}
@@ -1,22 +1,52 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity; import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.guo.learningprogresstracker.service.StudyExpectationsService; import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper; import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
* @author guo * 学习预期服务实现
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service实现 */
* @createDate 2024-06-09 15:28:48
*/
@Service @Service
public class StudyExpectationsServiceImpl extends ServiceImpl<StudyExpectationsMapper, StudyExpectationsEntity> @RequiredArgsConstructor
implements StudyExpectationsService{ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
private final StudyExpectationsMapper studyExpectationsMapper;
private final StudySessionsMapper studySessionsMapper;
@Override
public StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException {
boolean sessionExists = studySessionsMapper.exists(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getSessionNum, sessionNum));
if (!sessionExists) {
throw new ErrorParameterException("学习会话[" + sessionNum + "]不存在");
}
StudyExpectationsEntity existing = getBySessionNum(sessionNum);
if (existing == null) {
StudyExpectationsEntity entity = new StudyExpectationsEntity();
entity.setSessionNum(sessionNum);
entity.setDescription(description);
studyExpectationsMapper.insert(entity);
return entity;
}
existing.setDescription(description);
studyExpectationsMapper.updateById(existing);
return existing;
}
@Override
public StudyExpectationsEntity getBySessionNum(String sessionNum) {
return studyExpectationsMapper.selectOne(
Wrappers.<StudyExpectationsEntity>lambdaQuery()
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
.last("LIMIT 1"));
}
} }
@@ -3,6 +3,7 @@ package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
@@ -12,31 +13,49 @@ 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 org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/** /**
* @author guo * 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现 */
* @createDate 2024-06-09 15:28:48
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity> public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
implements StudyReportFragmentsService{ implements StudyReportFragmentsService {
private final StudySessionsMapper studySessionsMapper; private final StudySessionsMapper studySessionsMapper;
@Override @Override
@Transactional
public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException { public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request); StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
if (studySessionsMapper.exists(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity session = studySessionsMapper.selectOne(
.eq(StudySessionsEntity::getSessionNum,entity.getSessionNum()))) { Wrappers.lambdaQuery(StudySessionsEntity.class)
this.save(entity); .eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
}else { if (session == null) {
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]",entity.getSessionNum())); throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]", entity.getSessionNum()));
} }
this.save(entity);
}
@Override
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity existing = this.getById(id);
if (existing == null) {
throw new NotFindEntitiesException(String.format("未能找到学习残片id[%d]", id));
}
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
entity.setId(id);
this.updateById(entity);
}
@Override
public List<StudyReportFragmentsEntity> getFragmentsBySession(String sessionNum) {
return this.list(
Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum)
.orderByAsc(StudyReportFragmentsEntity::getCreatedTime));
} }
} }
@@ -1,6 +1,7 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.protobuf.ServiceException; import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.StudySessionsDto; import com.guo.learningprogresstracker.dto.StudySessionsDto;
@@ -16,10 +17,12 @@ import com.guo.learningprogresstracker.mapStruct.StudySessionConvert;
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 com.guo.learningprogresstracker.service.StudySessionsService; import com.guo.learningprogresstracker.service.StudySessionsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -27,9 +30,7 @@ import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* @author guo * 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
* @description 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -38,10 +39,13 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
implements StudySessionsService { implements StudySessionsService {
public static final int WORK_DURATION = 25; public static final int WORK_DURATION = 25;
private final TasksServiceImpl tasksServiceImpl; private final TasksServiceImpl tasksServiceImpl;
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl; private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
private final StudyReportFragmentsMapper studyReportFragmentsMapper; private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudyReportsMapper studyReportsMapper; private final StudyReportsMapper studyReportsMapper;
private final AiServiceClient aiServiceClient;
private final StudyExpectationsService studyExpectationsService;
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException { public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
TaskEntity taskEntity = tasksServiceImpl.getOneOpt( TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
@@ -69,6 +73,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session); StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
response.setTaskName(taskEntity.getTaskName()); response.setTaskName(taskEntity.getTaskName());
response.setMaterialUrl(taskEntity.getMaterialUrl());
response.setTaskId(taskEntity.getId());
if (session.isOverTime()) { if (session.isOverTime()) {
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!"); response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
@@ -94,21 +100,29 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
@Override @Override
public void endedStudySession(String sessionNum, String content) throws ErrorParameterException { @Transactional
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(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
studySessionsEntity.endedStudySession(); studySessionsEntity.endedStudySession();
this.updateById(studySessionsEntity); this.updateById(studySessionsEntity);
// 创建学习报告
StudyReportsEntity studyReportsEntity = new StudyReportsEntity(); StudyReportsEntity studyReportsEntity = new StudyReportsEntity();
studyReportsEntity.setSessionNum(sessionNum); studyReportsEntity.setSessionNum(sessionNum);
studyReportsEntity.setContent(content); studyReportsEntity.setContent(content);
studyReportsMapper.insert(studyReportsEntity); studyReportsMapper.insert(studyReportsEntity);
if (wasOngoing && studySessionsEntity.getEffectiveTime() == 0) {
return "本次有效学习时间不足10分钟,不计入总学习时间";
}
return null;
} }
@Override @Override
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException { public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskNum, taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在"));
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum)) StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话")); .orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
return StudySessionConvert.MAPPER.toStudySessionResponse(dto); return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
@@ -118,7 +132,6 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException { public ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException {
if (this.exists(Wrappers.lambdaQuery(StudySessionsEntity.class) if (this.exists(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))) { .eq(StudySessionsEntity::getSessionNum, sessionNum))) {
// todo guo 后续可在此补充更高级的整理方式
return studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class) return studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.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));
@@ -128,6 +141,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
} }
@Override @Override
@Transactional
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))
@@ -142,8 +156,110 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
studySessionsEntity.continueStudySession(); studySessionsEntity.continueStudySession();
this.updateById(studySessionsEntity); this.updateById(studySessionsEntity);
} }
/**
* 通过 sessionNum 获取学习会话
*/
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
}
/**
* 生成学习报告草稿:优先调用 lpt-ai 聚合残片,服务不可用时降级为按序拼接。
* 草稿仅作为编辑起点返回,不落库——最终报告由用户确认后经 endedStudySession 保存。
*/
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
ArrayList<String> fragments = getAllFragments(sessionNum);
if (fragments.isEmpty()) {
return "";
}
String taskName = tasksServiceImpl.getOneOpt(
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, session.getTaskNum()))
.map(TaskEntity::getTaskName)
.orElse("学习任务");
String expectation = Optional.ofNullable(studyExpectationsService.getBySessionNum(sessionNum))
.map(e -> e.getDescription())
.orElse(null);
return aiServiceClient.aggregateReport(taskName, fragments, expectation)
.orElseGet(() -> String.join("\n", fragments));
}
// ============ 分页查询任务的历史残片/报告 ============
@Override
public Page<StudyReportFragmentsEntity> getTaskFragments(String taskNum, int page, int size, String keyword) {
Page<StudyReportFragmentsEntity> pg = new Page<>(page, size);
var wrapper = Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.apply("session_num in (select session_num from study_sessions where task_num = {0})", taskNum)
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime);
if (keyword != null && !keyword.isBlank()) {
wrapper.like(StudyReportFragmentsEntity::getContent, keyword);
}
return studyReportFragmentsMapper.selectPage(pg, wrapper);
}
@Override
public Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword) {
Page<StudyReportsEntity> pg = new Page<>(page, size);
var wrapper = Wrappers.lambdaQuery(StudyReportsEntity.class)
.apply("session_num in (select session_num from study_sessions where task_num = {0})", taskNum)
.orderByDesc(StudyReportsEntity::getCreatedTime);
if (keyword != null && !keyword.isBlank()) {
wrapper.like(StudyReportsEntity::getContent, keyword);
}
Page<StudyReportsEntity> result = studyReportsMapper.selectPage(pg, wrapper);
// 填充每个报告对应会话的预期目标
for (StudyReportsEntity report : result.getRecords()) {
if (report.getSessionNum() != null) {
Optional.ofNullable(studyExpectationsService.getBySessionNum(report.getSessionNum()))
.ifPresent(e -> report.setSessionExpectation(e.getDescription()));
}
}
return result;
}
// ============ 活跃会话查询 ============
/**
* 查询当前用户是否有活跃会话(ONGOING 或 PAUSED)。
*
* @param excludeTaskNum 可选,排除指定任务(同一任务继续学习时不会视为冲突)
* @return 活跃会话响应;无活跃会话时返回 null
*/
@Override
public StudySessionResponse getActiveSession(String excludeTaskNum) {
var wrapper = Wrappers.lambdaQuery(StudySessionsEntity.class)
.in(StudySessionsEntity::getSessionState,
StudySessionStateEnum.ONGOING.name(),
StudySessionStateEnum.PAUSED.name())
.orderByDesc(StudySessionsEntity::getCreatedTime)
.last("LIMIT 1");
StudySessionsEntity session = this.getOne(wrapper);
if (session == null) return null;
// 如果活跃会话属于被排除的任务,视为无冲突
if (excludeTaskNum != null && excludeTaskNum.equals(session.getTaskNum())) {
return null;
}
String taskName = tasksServiceImpl.getOneOpt(
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, session.getTaskNum()))
.map(TaskEntity::getTaskName)
.orElse(null);
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
response.setTaskName(taskName);
return response;
}
} }
@@ -5,12 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.dto.PriorityDto; import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.dto.TaskInfo; import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest; import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse; import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.exception.ErrorParameterException; import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapStruct.RequestConvert; import com.guo.learningprogresstracker.mapStruct.RequestConvert;
import com.guo.learningprogresstracker.mapStruct.TaskConvert; import com.guo.learningprogresstracker.mapStruct.TaskConvert;
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
import com.guo.learningprogresstracker.service.PriorityWeightsService;
import com.guo.learningprogresstracker.service.TasksService; import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.mapper.TasksMapper; import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool; import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
@@ -18,35 +25,38 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.rmi.ServerException; import java.util.List;
import java.util.Optional;
/** /**
* @author guo * 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
* @description 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现 */
* @createDate 2024-06-09 15:28:48
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity> public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
implements TasksService{ implements TasksService {
private final TasksMapper tasksMapper; private final TasksMapper tasksMapper;
private final TaskApplicationMapper taskApplicationMapper;
private final PriorityWeightsService priorityWeightsService;
@Override @Override
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) { public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize), Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
Wrappers.lambdaQuery(TaskEntity.class).orderByDesc(TaskEntity::getCalculatedPriority)); Wrappers.lambdaQuery(TaskEntity.class)
.orderByDesc(TaskEntity::getCalculatedPriority));
Page<TaskInfo> response = TaskConvert.MAPPER.toTaskInfoPage(page); Page<TaskInfo> response = TaskConvert.MAPPER.toTaskInfoPage(page);
// todo 需要补充TaskInfo中的lastLearningStatus字段信息
return response; return response;
} }
@Override @Override
public String addTask(TaskRequest taskRequest) throws ErrorParameterException { public String addTask(TaskRequest taskRequest) throws ErrorParameterException {
TaskEntity task= TaskConvert.MAPPER.taskRequestToTaskEntity(taskRequest); TaskEntity task = TaskConvert.MAPPER.taskRequestToTaskEntity(taskRequest);
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class) if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskName, task.getTaskName()))) { .eq(TaskEntity::getTaskName, task.getTaskName()))) {
@@ -55,7 +65,7 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
task.setTaskNum(GenerateNumTool.generateNum("TASK")); task.setTaskNum(GenerateNumTool.generateNum("TASK"));
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest); PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest);
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto)); task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
this.save(task); this.save(task);
return task.getTaskNum(); return task.getTaskNum();
} }
@@ -63,13 +73,15 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
@Override @Override
public TaskInfoResponse getTask(String taskId) { public TaskInfoResponse getTask(String taskId) {
TaskEntity taskEntity = this.getById(taskId); TaskEntity taskEntity = this.getById(taskId);
TaskInfoResponse taskInfoResponse = TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity); return TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity);
return taskInfoResponse;
} }
@Override @Override
public void updateTask(String taskId, TaskRequest updatedTask) { public void updateTask(String taskId, TaskRequest updatedTask) {
TaskEntity existingTask = this.getById(taskId);
if (existingTask == null) {
throw new IllegalArgumentException("任务不存在: " + taskId);
}
TaskEntity taskEntity = TaskConvert.MAPPER.taskRequestToTaskEntity(updatedTask); TaskEntity taskEntity = TaskConvert.MAPPER.taskRequestToTaskEntity(updatedTask);
int id; int id;
try { try {
@@ -78,21 +90,72 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
throw new IllegalArgumentException("任务ID格式错误: " + taskId); throw new IllegalArgumentException("任务ID格式错误: " + taskId);
} }
taskEntity.setId(id); taskEntity.setId(id);
boolean updated = this.updateById(taskEntity); taskEntity.setTaskNum(existingTask.getTaskNum());
if (!updated) { // 维度数据可能变化,更新时重算优先级
throw new IllegalArgumentException("任务不存在或更新失败: " + taskId); PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
} taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
this.updateById(taskEntity);
} }
@Override @Override
public void deleteTask(String taskId) throws ServerException { public void deleteTask(String taskId) {
boolean b = this.removeById(taskId); TaskEntity existingTask = this.getById(taskId);
if(!b){ if (existingTask == null) {
throw new ServerException("未能正常删除任务"); throw new IllegalArgumentException("任务不存在: " + taskId);
}
this.removeById(taskId);
}
@Override
public List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery()
.eq(TaskApplicationEntity::getTaskNum, taskNum)
.orderByDesc(TaskApplicationEntity::getLastModifiedTime)
.orderByDesc(TaskApplicationEntity::getCreatedTime));
}
@Override
public TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException {
ensureTaskExists(request.getTaskNum());
TaskApplicationEntity entity = new TaskApplicationEntity();
entity.setTaskNum(request.getTaskNum());
entity.setTitle(request.getTitle());
entity.setDescription(request.getDescription());
entity.setResourceUrl(request.getResourceUrl());
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.insert(entity);
return entity;
}
@Override
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl());
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.updateById(existing);
return existing;
}
@Override
public void deleteApplication(Integer id) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
taskApplicationMapper.deleteById(existing.getId());
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
} }
} }
private String normalizeApplicationStatus(String status) {
return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode();
}
} }
@@ -7,6 +7,7 @@ import com.guo.learningprogresstracker.exception.AppException;
import com.guo.learningprogresstracker.service.UserService; import com.guo.learningprogresstracker.service.UserService;
import com.guo.learningprogresstracker.mapper.UserMapper; import com.guo.learningprogresstracker.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
@@ -23,12 +24,13 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity>
public String authenticate(String username, String password) throws AppException { public String authenticate(String username, String password) throws AppException {
UserEntity userEntity = this.getOneOpt(Wrappers.<UserEntity>lambdaQuery() UserEntity userEntity = this.getOneOpt(Wrappers.<UserEntity>lambdaQuery()
.eq(UserEntity::getUserName, username) .eq(UserEntity::getUserName, username))
.eq(UserEntity::getUserPassword, password)).orElseThrow(() -> new AppException("账号或密码错误!")); .orElseThrow(() -> new AppException("账号或密码错误!"));
return userEntity.getId();
if (!BCrypt.checkpw(password, userEntity.getUserPassword())) {
throw new AppException("账号或密码错误!");
}
return userEntity.getId();
} }
} }
@@ -1,22 +0,0 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.entity.UserTaskEntity;
import com.guo.learningprogresstracker.service.UserTaskService;
import com.guo.learningprogresstracker.mapper.UserTaskMapper;
import org.springframework.stereotype.Service;
/**
* @author guo
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
@Service
public class UserTaskServiceImpl extends ServiceImpl<UserTaskMapper, UserTaskEntity>
implements UserTaskService{
}
@@ -1,40 +1,29 @@
package com.guo.learningprogresstracker.utils; package com.guo.learningprogresstracker.utils;
import com.guo.learningprogresstracker.dto.PriorityDto; import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
/** /**
* 加权优先级计算工具 * 加权优先级计算工具
*
* @author guo
*/ */
public class CalculatedPriorityTool { public class CalculatedPriorityTool {
/** /**
* 通过用户选择的5个选项,结合定义的权重计算任务的加权优先级。 * 使用系统默认权重计算加权优先级。
* 急迫性 (Urgency) * 急迫性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
* 急迫性代表任务的紧急程度。权重:0.35
* <p>
* 重要性 (Importance)
* 重要性指示任务的重要程度。权重:0.25
* <p>
* 内容难度 (Content Difficulty)
* 内容难度衡量任务内容的复杂性。权重:0.20
* <p>
* 未来价值 (Future Value)
* 未来价值估计完成任务的长期价值。权重:0.10
* <p>
* 主观优先级 (Subjective Priority)
* 主观优先级是用户对任务优先级的个人评估。权重:0.10
*
* @param priorityDto
* @return
*/ */
public static Double calculatedPriority(PriorityDto priorityDto) { public static Double calculatedPriority(PriorityDto priorityDto) {
// 暂时写死权重,也许可以考虑让用户自行配置权重 return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
return (priorityDto.getUrgency() * 0.35) + }
(priorityDto.getImportance() * 0.25) +
(priorityDto.getContentDifficulty() * 0.20) + /**
(priorityDto.getFutureValue() * 0.10) + * 使用用户自定义权重计算加权优先级。
(priorityDto.getSubjectivePriority() * 0.10); */
public static Double calculatedPriority(PriorityDto priorityDto, UserPriorityWeightsEntity weights) {
return (priorityDto.getUrgency() * weights.getUrgencyWeight()) +
(priorityDto.getImportance() * weights.getImportanceWeight()) +
(priorityDto.getContentDifficulty() * weights.getContentDifficultyWeight()) +
(priorityDto.getFutureValue() * weights.getFutureValueWeight()) +
(priorityDto.getSubjectivePriority() * weights.getSubjectivePriorityWeight());
} }
} }
@@ -0,0 +1,47 @@
package com.guo.learningprogresstracker.utils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 思维导图结构对比结果。
* 每个 {@link NodeDiff} 对应标准导图中的一个节点,标注其匹配状态及用户在回忆中撰写的标题。
*/
@Data
public class CompareResult {
/** 对比后的完整树(带 MATCHED/MISSED 标注) */
private MindMapNode matchedTree;
/** 用户追加但标准导图中不存在的节点 */
private List<FlatNode> extraNodes = new ArrayList<>();
/** 回忆覆盖率 0-1 */
private double recallRatio;
/** 命中节点数 */
private int matchedCount;
/** 遗漏节点数 */
private int missedCount;
/** 额外节点数 */
private int extraCount;
/** AI 评价文本(仅 AI 对比时有值,内置对比为 null) */
private String evaluation;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class FlatNode {
private String title;
private String path; // 以 / 分隔的路径
private String sourceType;
private Integer sourceId;
}
}
@@ -7,16 +7,11 @@ import java.util.Date;
/** /**
* 用于生成编码的工具 * 用于生成编码的工具
*
* @author guo
*/ */
@RequiredArgsConstructor @RequiredArgsConstructor
public class GenerateNumTool { public class GenerateNumTool {
private static int getNextSequence() { private static int getNextSequence() {
// 获取当前时间的毫秒级时间戳
long timestamp = System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
// 使用时间戳的最后6位作为序列号
return (int) (timestamp % 1_000_000); return (int) (timestamp % 1_000_000);
} }
@@ -29,22 +24,14 @@ public class GenerateNumTool {
*/ */
public static String generateNum(String prefix, String separator) { public static String generateNum(String prefix, String separator) {
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date()); String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 唯一的后缀
int sequence = getNextSequence(); int sequence = getNextSequence();
// 返回完整的 taskNum
return prefix + separator + currentDate + separator + sequence; return prefix + separator + currentDate + separator + sequence;
} }
public static String generateNum(String prefix) { public static String generateNum(String prefix) {
String separator = "-"; String separator = "-";
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date()); String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 唯一的后缀
int sequence = getNextSequence(); int sequence = getNextSequence();
// 返回完整的 taskNum
return prefix + separator + currentDate + separator + sequence; return prefix + separator + currentDate + separator + sequence;
} }
} }
@@ -0,0 +1,95 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 思维导图树节点 DTO,与 MindMapFileParser 的输出结构兼容。
* 序列化后可在「nodes」和「children」两个 key 下放置子节点列表。
*
* <pre>{@code
* {
* "title": "根标题",
* "notes": "备注",
* "sourceType": "REPORT", // 可选:节点来源类型
* "sourceId": 1, // 可选:来源主键
* "children": [ ... ]
* }
* }</pre>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class MindMapNode {
private String title;
private String notes;
private String sourceType;
private Integer sourceId;
private List<MindMapNode> children;
public MindMapNode(String title) {
this.title = title;
this.notes = "";
this.children = new ArrayList<>();
}
/** 构建 JSON 友好的 Map 结构(兼容 MindMapFileParser 输出风格) */
@SuppressWarnings("unchecked")
public Map<String, Object> toMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", title != null ? title : "");
map.put("notes", notes != null ? notes : "");
if (sourceType != null) map.put("sourceType", sourceType);
if (sourceId != null) map.put("sourceId", sourceId);
List<Object> childMaps = new ArrayList<>();
if (children != null) {
for (MindMapNode c : children) {
childMaps.add(c.toMap());
}
}
map.put("children", childMaps);
return map;
}
/** 从 Map 重建节点 */
public static MindMapNode fromMap(Map<String, Object> map) {
MindMapNode node = new MindMapNode();
node.setTitle((String) map.getOrDefault("title", ""));
node.setNotes((String) map.getOrDefault("notes", ""));
node.setSourceType((String) map.get("sourceType"));
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
node.setSourceId(((Number) map.get("sourceId")).intValue());
}
Object raw = map.getOrDefault("children", map.get("nodes"));
List<MindMapNode> children = new ArrayList<>();
if (raw instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
children.add(fromMap((Map<String, Object>) m));
}
}
}
node.setChildren(children);
return node;
}
/** 反序列化 JSON 字符串为 MindMapNode */
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
try {
Map<String, Object> map = mapper.readValue(json, LinkedHashMap.class);
return fromMap(map);
} catch (Exception e) {
return new MindMapNode("解析失败");
}
}
}
@@ -0,0 +1,375 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import java.util.*;
/**
* 思维导图树结构与缩进大纲文本之间的双向转换工具。
* <p>大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。</p>
*/
public class MindMapTreeTool {
private static final int INDENT_SPACES = 2;
private MindMapTreeTool() {
}
// ============ 序列化:树 → 缩进大纲 ============
/**
* 将根节点序列化为缩进大纲文本
*/
public static String toOutline(MindMapNode root) {
StringBuilder sb = new StringBuilder();
appendOutline(sb, root, 0);
return sb.toString();
}
private static void appendOutline(StringBuilder sb, MindMapNode node, int level) {
if (level > 0) {
sb.append(" ".repeat(level)).append("- ").append(node.getTitle() != null ? node.getTitle() : "").append('\n');
}
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
appendOutline(sb, child, level + 1);
}
}
// level 0 是根节点标题本身不输出,但其子节点输出
if (level == 0 && node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
appendOutline(sb, child, 0);
}
}
}
/**
* 根节点序列化为完整大纲,第一行为根标题
*/
public static String toFullOutline(MindMapNode root) {
StringBuilder sb = new StringBuilder();
sb.append(root.getTitle() != null ? root.getTitle() : "").append('\n');
if (root.getChildren() != null) {
for (MindMapNode child : root.getChildren()) {
appendOutline(sb, child, 1);
}
}
return sb.toString();
}
// ============ 反序列化:缩进大纲 → 树 ============
/**
* 将缩进大纲文本解析为根节点。
* 第一行非空文本作为根标题,后续行作为子节点。
*/
public static MindMapNode parseOutline(String outlineText) {
if (outlineText == null || outlineText.isBlank()) {
return new MindMapNode("未命名");
}
String[] lines = outlineText.split("\\R");
List<String> nonBlank = new ArrayList<>();
for (String line : lines) {
if (!line.trim().isBlank()) {
nonBlank.add(line.stripTrailing());
}
}
if (nonBlank.isEmpty()) {
return new MindMapNode("未命名");
}
MindMapNode root = new MindMapNode(stripMarker(nonBlank.get(0).strip()));
List<MindMapNode> roots = new ArrayList<>();
Deque<StackEntry> stack = new ArrayDeque<>();
stack.push(new StackEntry(-1, roots));
for (int i = 1; i < nonBlank.size(); i++) {
String raw = nonBlank.get(i);
int level = detectLevel(raw);
String title = stripMarker(raw.strip());
MindMapNode child = new MindMapNode(title);
while (stack.peek().level >= level) {
stack.pop();
}
stack.peek().children.add(child);
List<MindMapNode> children = new ArrayList<>();
child.setChildren(children);
stack.push(new StackEntry(level, children));
}
root.setChildren(roots);
return root;
}
private static int detectLevel(String line) {
String trimmed = line.stripLeading();
int indent = line.length() - trimmed.length();
int hashCount = 0;
while (hashCount < trimmed.length() && trimmed.charAt(hashCount) == '#') {
hashCount++;
}
if (hashCount > 0) return hashCount;
if (indent == 0) return 1;
return Math.max(1, indent / INDENT_SPACES + 1);
}
private static String stripMarker(String s) {
return s.replaceFirst("^#{1,6}\\s*", "")
.replaceFirst("^[-*+]\\s*", "")
.replaceFirst("^\\d+\\.\\s*", "")
.strip();
}
private record StackEntry(int level, List<MindMapNode> children) {
}
// ============ 工具方法 ============
/** 展开树为平铺列表(前序遍历) */
public static List<MindMapNode> flatten(MindMapNode root) {
List<MindMapNode> list = new ArrayList<>();
flattenRecursive(root, list);
return list;
}
private static void flattenRecursive(MindMapNode node, List<MindMapNode> acc) {
acc.add(node);
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
flattenRecursive(child, acc);
}
}
}
/** 统计节点总数 */
public static int countNodes(MindMapNode root) {
return flatten(root).size();
}
/** 计算最大深度 */
public static int maxDepth(MindMapNode node) {
if (node.getChildren() == null || node.getChildren().isEmpty()) {
return 1;
}
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
}
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
private static MindMapNode copyNodeShallow(MindMapNode node) {
MindMapNode copy = new MindMapNode();
copy.setTitle(node.getTitle());
copy.setNotes(node.getNotes());
copy.setSourceType(node.getSourceType());
copy.setSourceId(node.getSourceId());
return copy;
}
/**
* 按路径提取子树:以 / 分隔的节点标题路径,提取目标节点为根的新树。
* @param root 完整树根
* @param path 节点路径,如 "根标题 / 分支A / 子节点B"
* @return 以路径末端节点为根的深拷贝子树,匹配失败时返回 null
*/
public static MindMapNode extractSubtree(MindMapNode root, String path) {
if (root == null || path == null || path.isBlank()) return null;
String[] segments = path.split("\\s*/\\s*");
MindMapNode current = root;
for (int i = 0; i < segments.length; i++) {
String seg = segments[i].trim();
if (seg.isEmpty()) continue;
if (current.getTitle() != null && normalizeForMerge(current.getTitle()).equals(normalizeForMerge(seg))) {
continue; // 当前节点已匹配,看下一段
}
MindMapNode found = null;
if (current.getChildren() != null) {
for (MindMapNode child : current.getChildren()) {
if (child.getTitle() != null && normalizeForMerge(child.getTitle()).equals(normalizeForMerge(seg))) {
found = child;
break;
}
}
}
if (found != null) {
current = found;
} else {
return null;
}
}
return deepCopy(current);
}
/** 深拷贝节点及其子树 */
private static MindMapNode deepCopy(MindMapNode node) {
if (node == null) return null;
MindMapNode copy = new MindMapNode();
copy.setTitle(node.getTitle());
copy.setNotes(node.getNotes());
copy.setSourceType(node.getSourceType());
copy.setSourceId(node.getSourceId());
List<MindMapNode> children = new ArrayList<>();
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
children.add(deepCopy(child));
}
}
copy.setChildren(children);
return copy;
}
/**
* 查找与 content 最相似的节点。基于 title 的 bigram Jaccard 相似度。
* @return 得分最高的节点,若所有节点得分均低于 0.1 则返回 null
*/
public static MindMapNode findClosestNode(MindMapNode root, String content) {
if (root == null || content == null || content.isBlank()) return null;
List<MindMapNode> flat = flatten(root);
MindMapNode best = null;
double bestScore = 0.0;
for (MindMapNode node : flat) {
if (node.getTitle() == null || node.getTitle().isBlank()) continue;
double score = similarityScore(node.getTitle(), content);
// notes 也参与匹配,但权重减半
if (node.getNotes() != null && !node.getNotes().isBlank()) {
double noteScore = similarityScore(node.getNotes(), content);
score = Math.max(score, noteScore * 0.5);
}
if (score > bestScore) {
bestScore = score;
best = node;
}
}
return bestScore > 0.1 ? best : null;
}
/**
* 获取节点在树中的路径(以 / 分隔的 title 序列)
*/
public static String getPath(MindMapNode root, String targetTitle) {
if (root == null || targetTitle == null) return "";
List<String> path = new ArrayList<>();
if (findPathRecursive(root, targetTitle, path)) {
return String.join(" / ", path);
}
return "";
}
private static boolean findPathRecursive(MindMapNode node, String targetTitle, List<String> path) {
if (node == null) return false;
path.add(node.getTitle() != null ? node.getTitle() : "");
if (normalizeForMerge(node.getTitle()).equals(normalizeForMerge(targetTitle))) return true;
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
if (findPathRecursive(child, targetTitle, path)) return true;
}
}
path.remove(path.size() - 1);
return false;
}
/** bigram Jaccard 相似度 */
public static double similarityScore(String a, String b) {
if (a == null || b == null) return 0;
String na = normalizeForMerge(a);
String nb = normalizeForMerge(b);
if (na.isEmpty() || nb.isEmpty()) return 0;
if (na.equals(nb)) return 1.0;
// 子串匹配
if (na.contains(nb) || nb.contains(na)) return 0.9;
Set<String> bigramsA = bigrams(na);
Set<String> bigramsB = bigrams(nb);
if (bigramsA.isEmpty() && bigramsB.isEmpty()) return 0;
Set<String> union = new HashSet<>(bigramsA);
union.addAll(bigramsB);
Set<String> intersect = new HashSet<>(bigramsA);
intersect.retainAll(bigramsB);
return (double) intersect.size() / union.size();
}
private static Set<String> bigrams(String s) {
Set<String> set = new LinkedHashSet<>();
for (int i = 0; i < s.length() - 1; i++) {
set.add(s.substring(i, i + 2));
}
return set;
}
/**
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
*
* @param oldRoot 旧树根(用户可能编辑过)
* @param newRoot 新树根(AI/BUILTIN 最新生成)
* @return 合并后的树
*/
public static MindMapNode mergeTrees(MindMapNode oldRoot, MindMapNode newRoot) {
if (oldRoot == null) return newRoot;
if (newRoot == null) return oldRoot;
MindMapNode result = copyNodeShallow(oldRoot);
result.setChildren(mergeChildren(oldRoot.getChildren(), newRoot.getChildren()));
return result;
}
/** 递归合并子节点列表 */
private static List<MindMapNode> mergeChildren(List<MindMapNode> oldChildren, List<MindMapNode> newChildren) {
Map<String, MindMapNode> oldByNormalized = new LinkedHashMap<>();
if (oldChildren != null) {
for (MindMapNode child : oldChildren) {
oldByNormalized.put(normalizeForMerge(child.getTitle()), child);
}
}
List<MindMapNode> merged = new ArrayList<>();
Set<String> usedKeys = new HashSet<>();
if (newChildren != null) {
for (MindMapNode newNode : newChildren) {
String key = normalizeForMerge(newNode.getTitle());
MindMapNode oldNode = oldByNormalized.get(key);
if (oldNode != null) {
// 旧节点存在:保留旧节点标题,递归合并子节点
MindMapNode kept = copyNodeShallow(oldNode);
kept.setChildren(mergeChildren(oldNode.getChildren(), newNode.getChildren()));
merged.add(kept);
usedKeys.add(key);
} else {
// 新节点:直接添加
merged.add(newNode);
}
}
}
// 添加旧树中有但新树中没有的节点(用户添加的额外节点)
if (oldChildren != null) {
for (MindMapNode oldNode : oldChildren) {
if (!usedKeys.contains(normalizeForMerge(oldNode.getTitle()))) {
merged.add(oldNode);
}
}
}
return merged;
}
private static String normalizeForMerge(String s) {
if (s == null) return "";
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
.toLowerCase(Locale.ROOT)
.trim();
}
/** 序列化整棵树为 JSON */
public static String toJson(MindMapNode root, ObjectMapper mapper) {
try {
return mapper.writeValueAsString(root.toMap());
} catch (Exception e) {
return "{\"title\":\"序列化失败\"}";
}
}
/** 从 JSON 反序列化 */
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
return MindMapNode.fromJson(json, mapper);
}
}
@@ -0,0 +1,273 @@
package com.guo.learningprogresstracker.utils;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
@Slf4j
public class TitleFetcher {
private static final Pattern TITLE_PATTERN = Pattern.compile(
"<title[^>]*>([^<]+)</title>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
private static final Pattern CHARSET_PATTERN = Pattern.compile(
"charset=([\\w\\-]+)", Pattern.CASE_INSENSITIVE);
private static final int MAX_REDIRECTS = 5;
private static final int TIMEOUT_MS = 8_000;
private static volatile boolean sslRelaxed;
static {
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAll, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
sslRelaxed = true;
} catch (Exception e) {
log.warn("无法配置宽松 SSL: {}", e.getMessage());
}
}
public static String fetchTitle(String url) {
if (url == null || url.isBlank()) return null;
String lower = url.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return null;
String currentUrl = url;
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
String title = doFetch(currentUrl);
if (title != null) return title;
// 检查是否需要跟随重定向
String redirect = getRedirect(currentUrl);
if (redirect != null && !redirect.equals(currentUrl)) {
currentUrl = redirect;
continue;
}
return null;
}
return null;
}
private static String doFetch(String url) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
if (conn instanceof HttpsURLConnection) {
// SSL 已全局宽松配置
}
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
conn.setRequestMethod("GET");
conn.setInstanceFollowRedirects(false); // 手动处理重定向
conn.setRequestProperty("Connection", "close");
conn.setRequestProperty("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
conn.setRequestProperty("Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
int status = conn.getResponseCode();
if (status >= 200 && status < 400) {
String contentType = conn.getContentType();
if (contentType != null && !contentType.toLowerCase().contains("text/html")
&& !contentType.toLowerCase().contains("application/xhtml")) {
if (!contentType.toLowerCase().contains("text/")) return null;
}
Charset charset = detectCharset(contentType);
String body = readBody(conn, charset);
if (body == null) return null;
Matcher matcher = TITLE_PATTERN.matcher(body);
if (matcher.find()) {
String title = matcher.group(1).trim();
title = title.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replaceAll("\\s+", " ").trim();
return title.isEmpty() ? null : title;
}
}
return null;
} catch (Exception e) {
log.debug("获取页面标题失败: url={}, error={}", url, e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** 获取重定向地址,支持跨协议跳转 */
private static String getRedirect(String url) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
conn.setRequestMethod("HEAD");
conn.setInstanceFollowRedirects(false);
conn.setRequestProperty("User-Agent",
"Mozilla/5.0 (compatible; LPT/1.0)");
int status = conn.getResponseCode();
if (status == HttpURLConnection.HTTP_MOVED_PERM // 301
|| status == HttpURLConnection.HTTP_MOVED_TEMP // 302
|| status == HttpURLConnection.HTTP_SEE_OTHER // 303
|| status == 307
|| status == 308) {
String location = conn.getHeaderField("Location");
if (location != null && !location.isBlank()) {
// 处理相对路径
if (!location.toLowerCase().startsWith("http")) {
URI base = URI.create(url);
location = base.resolve(location).toString();
}
// 跨协议切换(HTTP→HTTPS)也允许
if (location.toLowerCase().startsWith("http://")
|| location.toLowerCase().startsWith("https://")) {
return location;
}
}
}
return null;
} catch (Exception e) {
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* 将文本中的裸 URL 替换为 [标题](url) 格式(已有 [text](url) 的保持不变)。
* 并行抓取标题,整体超时 15 秒。
*/
public static String embedTitles(String text) {
if (text == null || text.isBlank()) return text;
// 1) 保护已有 [text](url)
List<String> protectedLinks = new ArrayList<>();
Matcher mdLink = Pattern.compile("\\[([^\\]]+)\\]\\(([^)]+)\\)").matcher(text);
StringBuffer sb = new StringBuffer();
while (mdLink.find()) {
protectedLinks.add(mdLink.group(0));
mdLink.appendReplacement(sb, "__MDLINK_" + (protectedLinks.size() - 1) + "__");
}
mdLink.appendTail(sb);
String work = sb.toString();
// 2) 提取裸 URL 并去重
Set<String> bareUrls = new LinkedHashSet<>();
Matcher um = Pattern.compile("https?://[^\\s)\u3001\uFF09\u300D\u300B<>]+").matcher(work);
while (um.find()) {
String url = um.group().replaceAll("[.。,;!??)】」』\\]]+$", "");
bareUrls.add(url);
}
if (bareUrls.isEmpty()) return text;
// 3) 并行抓取标题
Map<String, String> titleMap = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
CompletableFuture<Void>[] futures = new CompletableFuture[bareUrls.size()];
int fi = 0;
for (String url : bareUrls) {
final String u = url;
futures[fi++] = CompletableFuture.runAsync(() -> {
String title = fetchTitle(u);
if (title != null) titleMap.put(u, title);
});
}
try {
CompletableFuture.allOf(futures).get(15, TimeUnit.SECONDS);
} catch (Exception e) {
log.debug("批量获取标题超时或失败: {}", e.getMessage());
}
// 4) 替换裸 URL → [标题](url)
for (String url : bareUrls) {
String title = titleMap.get(url);
if (title != null) {
work = work.replace(url, "[" + title.replace("]", "\\]") + "](" + url + ")");
}
}
// 5) 还原 [text](url)
for (int i = 0; i < protectedLinks.size(); i++) {
work = work.replace("__MDLINK_" + i + "__", protectedLinks.get(i));
}
return work;
}
private static Charset detectCharset(String contentType) {
if (contentType != null) {
Matcher m = CHARSET_PATTERN.matcher(contentType);
if (m.find()) {
try { return Charset.forName(m.group(1)); } catch (Exception ignored) {}
}
}
return StandardCharsets.UTF_8;
}
private static String readBody(HttpURLConnection conn, Charset charset) throws IOException {
InputStream is;
try {
is = conn.getInputStream();
} catch (IOException e) {
is = conn.getErrorStream();
}
if (is == null) return null;
String encoding = conn.getContentEncoding();
try {
if ("gzip".equalsIgnoreCase(encoding)) {
is = new GZIPInputStream(is);
} else if ("deflate".equalsIgnoreCase(encoding)) {
is = new InflaterInputStream(is);
}
} catch (IOException ignored) {
try { is.close(); } catch (Exception ignored2) {}
is = conn.getInputStream();
}
byte[] buf = new byte[65536];
int total = 0;
try {
int n;
while (total < buf.length && (n = is.read(buf, total, buf.length - total)) != -1) {
total += n;
}
} finally {
try { is.close(); } catch (Exception ignored) {}
}
return new String(buf, 0, total, charset);
}
}
+7 -1
View File
@@ -50,4 +50,10 @@ management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info # 或 "*" include: health,info # 或 "*"
# lpt-ai 独立 AI 服务(可选,未配置时降级为内置规则引擎)
lpt:
ai-service:
url: http://localhost:5199
timeout-seconds: 600
@@ -0,0 +1,2 @@
-- 删除不再需要的 user_task 表
DROP TABLE IF EXISTS user_task;
@@ -0,0 +1 @@
ALTER TABLE `user` MODIFY COLUMN `user_password` varchar(60) NOT NULL COMMENT '账号密码(BCrypt哈希)';
@@ -0,0 +1,36 @@
create table review_applications
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
title varchar(255) not null comment '应用项目标题',
description text null comment '应用项目描述',
resource_url varchar(1024) null comment '相关链接',
status varchar(30) not null default 'TODO' comment '状态:TODO/DOING/DONE',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_applications_task_num (task_num)
)
comment '复习模块:学习内容可应用项目';
create table review_mind_maps
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
title varchar(255) not null comment '思维导图标题',
content text not null comment '思维导图内容',
content_format varchar(30) not null default 'TEXT' comment '内容格式:TEXT/MERMAID/JSON',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
unique key uk_review_mind_maps_task_num (task_num)
)
comment '复习模块:任务思维导图';
@@ -0,0 +1,26 @@
alter table review_mind_maps
add column source_type varchar(30) not null default 'MANUAL' comment '来源类型:MANUAL/FILE';
alter table review_mind_maps
add column file_name varchar(255) null comment '原始文件名';
alter table review_mind_maps
add column file_path varchar(1024) null comment '服务端文件路径';
alter table review_mind_maps
add column file_format varchar(30) null comment '文件格式:XMIND/MARKDOWN/OPML/FREEMIND/TEXT';
alter table review_mind_maps
add column parsed_content mediumtext null comment '解析后的统一结构JSON';
alter table review_mind_maps
add column parse_status varchar(30) not null default 'SUCCESS' comment '解析状态:SUCCESS/FAILED';
alter table review_mind_maps
add column parse_error text null comment '解析错误';
alter table review_mind_maps
add column summary text null comment '解析摘要';
alter table review_mind_maps
add column last_parsed_time datetime null comment '最近解析时间';
@@ -0,0 +1,41 @@
create table review_sessions
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
target_title varchar(255) not null comment '本次复习的具体知识目标',
recall_content text null comment '用户主动回忆内容',
knowledge_network text null comment '用户重构出的体系化知识网络',
reflection text null comment '复习后的反思与缺口',
status varchar(30) not null default 'ONGOING' comment '状态:ONGOING/COMPLETED',
started_time datetime not null comment '开始时间',
completed_time datetime null comment '完成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_sessions_task_num (task_num)
)
comment '复习模块:完整复习会话';
create table review_records
(
id int auto_increment comment 'id无业务含义'
primary key,
review_session_id int not null comment '复习会话ID',
source_type varchar(30) not null comment '关联来源:REPORT/FRAGMENT',
source_id int not null comment '来源ID',
recall_level varchar(30) not null default 'UNCERTAIN' comment '回忆程度:REMEMBERED/UNCERTAIN/FORGOTTEN',
note text null comment '对照后的说明',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_records_session_id (review_session_id),
index idx_review_records_source (source_type, source_id)
)
comment '复习模块:完整复习完成后与学习报告/残片的对照关系';
@@ -0,0 +1 @@
alter table review_applications rename to task_applications;
@@ -0,0 +1,2 @@
drop table if exists review_records;
drop table if exists review_sessions;
@@ -0,0 +1,45 @@
create table review_standard_mind_maps
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
title varchar(255) not null comment '标准思维导图标题',
content mediumtext not null comment '标准导图统一树结构JSON',
outline mediumtext null comment '标准导图缩进大纲文本(用于展示与编辑)',
summary text null comment '生成摘要',
generator varchar(30) not null default 'BUILTIN' comment '生成来源:BUILTIN/AI/USER',
generator_version varchar(64) null comment '生成器版本或AI模型名',
source_report_count int default 0 not null comment '生成时参考的学习报告数',
source_fragment_count int default 0 not null comment '生成时参考的学习残片数',
generated_time datetime null comment '最近生成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
unique key uk_review_standard_mind_maps_task_num (task_num)
)
comment '复习模块:任务标准思维导图(由内置规则或AI从学习报告/残片生成,用户可修改)';
create table review_recall_records
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
standard_map_id int null comment '对比时使用的标准导图ID',
recall_content mediumtext not null comment '用户回忆绘制的导图大纲文本',
compare_result mediumtext null comment '与标准导图的结构对比结果JSON',
recall_ratio double null comment '回忆覆盖率(0-1',
matched_count int default 0 not null comment '回忆命中的节点数',
missed_count int default 0 not null comment '遗漏的节点数',
extra_count int default 0 not null comment '标准导图之外的新增节点数',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_recall_records_task_num (task_num)
)
comment '复习模块:复习回忆与标准导图的对比记录';
@@ -0,0 +1,2 @@
alter table study_expectations
modify session_num varchar(255) not null comment '会话编码';
@@ -0,0 +1,17 @@
create table user_priority_weights
(
id int auto_increment comment 'id无业务含义'
primary key,
urgency_weight double not null default 0.35 comment '紧急性权重',
importance_weight double not null default 0.25 comment '重要性权重',
content_difficulty_weight double not null default 0.20 comment '内容难度权重',
future_value_weight double not null default 0.10 comment '未来价值权重',
subjective_priority_weight double not null default 0.10 comment '主观优先级权重',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符'
)
comment '用户自定义的任务优先级维度权重(每用户一行)';
@@ -0,0 +1 @@
ALTER TABLE tasks MODIFY material_url TEXT null comment '学习材料链接(每行一个)';
@@ -0,0 +1,5 @@
-- 为回忆对比记录添加 session_num 字段,支持会话维度的复习过滤
ALTER TABLE review_recall_records
ADD COLUMN session_num VARCHAR(255) NULL AFTER standard_map_id;
CREATE INDEX idx_recall_records_session_num ON review_recall_records (session_num);
@@ -0,0 +1,3 @@
-- 为回忆对比记录添加 focus_path 字段,支持节点级复习范围
ALTER TABLE review_recall_records
ADD COLUMN focus_path VARCHAR(512) NULL AFTER session_num;
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis-org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.guo.learningprogresstracker.mapper.ReviewMapper">
<select id="selectReviewFeed" resultType="com.guo.learningprogresstracker.dto.ReviewFeedItem">
SELECT
combined.id,
combined.session_num AS sessionNum,
combined.source_type AS sourceType,
combined.content,
combined.created_time AS createdTime,
t.task_num AS taskNum,
t.task_name AS taskName
FROM (
SELECT r.id, r.session_num, r.content, r.created_time, 'REPORT' AS source_type
FROM study_reports r
WHERE r.deleted = 0
UNION ALL
SELECT f.id, f.session_num, f.content, f.created_time, 'FRAGMENT' AS source_type
FROM study_report_fragments f
WHERE f.deleted = 0
) combined
JOIN study_sessions ss ON combined.session_num = ss.session_num AND ss.deleted = 0
JOIN tasks t ON ss.task_num = t.task_num AND t.deleted = 0
ORDER BY combined.created_time DESC
LIMIT #{limit}
</select>
<select id="selectTaskReview" resultType="com.guo.learningprogresstracker.dto.ReviewFeedItem">
SELECT
combined.id,
combined.session_num AS sessionNum,
combined.source_type AS sourceType,
combined.content,
combined.created_time AS createdTime,
t.task_num AS taskNum,
t.task_name AS taskName
FROM (
SELECT r.id, r.session_num, r.content, r.created_time, 'REPORT' AS source_type
FROM study_reports r
WHERE r.deleted = 0
UNION ALL
SELECT f.id, f.session_num, f.content, f.created_time, 'FRAGMENT' AS source_type
FROM study_report_fragments f
WHERE f.deleted = 0
) combined
JOIN study_sessions ss ON combined.session_num = ss.session_num AND ss.deleted = 0
JOIN tasks t ON ss.task_num = t.task_num AND t.deleted = 0
WHERE t.task_num = #{taskNum}
ORDER BY combined.created_time DESC
</select>
</mapper>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.guo.learningprogresstracker.mapper.StudyExpectationsMapper">
<resultMap id="BaseResultMap" type="com.guo.learningprogresstracker.entity.StudyExpectationsEntity">
<id property="expectationId" column="expectation_id" jdbcType="INTEGER"/>
<result property="sessionId" column="session_id" jdbcType="INTEGER"/>
<result property="description" column="description" jdbcType="VARCHAR"/>
<result property="createdTime" column="created_time" jdbcType="TIMESTAMP"/>
<result property="createdBy" column="created_by" jdbcType="VARCHAR"/>
<result property="lastModifiedTime" column="last_modified_time" jdbcType="TIMESTAMP"/>
<result property="lastModifiedBy" column="last_modified_by" jdbcType="VARCHAR"/>
</resultMap>
<sql id="Base_Column_List">
expectation_id,session_id,description,
created_time,created_by,last_modified_time,
last_modified_by
</sql>
</mapper>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.guo.learningprogresstracker.mapper.UserTaskMapper">
<resultMap id="BaseResultMap" type="com.guo.learningprogresstracker.entity.UserTaskEntity">
<result property="id" column="id" jdbcType="VARCHAR"/>
<result property="userId" column="user_id" jdbcType="INTEGER"/>
<result property="taskId" column="task_id" jdbcType="INTEGER"/>
<result property="createdTime" column="created_time" jdbcType="TIMESTAMP"/>
<result property="createdBy" column="created_by" jdbcType="VARCHAR"/>
<result property="lastModifiedTime" column="last_modified_time" jdbcType="TIMESTAMP"/>
<result property="lastModifiedBy" column="last_modified_by" jdbcType="VARCHAR"/>
</resultMap>
<sql id="Base_Column_List">
id,user_id,task_id,
created_time,created_by,last_modified_time,
last_modified_by
</sql>
</mapper>
@@ -1,7 +1,9 @@
package com.guo.learningprogresstracker; package com.guo.learningprogresstracker;
import cn.dev33.satoken.stp.StpUtil;
import com.guo.learningprogresstracker.entity.TestTableEntity; import com.guo.learningprogresstracker.entity.TestTableEntity;
import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl; import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -19,6 +21,11 @@ public class DataSourceTest {
@Autowired @Autowired
TestTableServiceImpl testTableService; TestTableServiceImpl testTableService;
@BeforeEach
void setUp() {
StpUtil.login("1", "test_driver");
}
@Test @Test
public void testAdd(){ public void testAdd(){
TestTableEntity testTableEntity = new TestTableEntity(); TestTableEntity testTableEntity = new TestTableEntity();
@@ -30,8 +37,12 @@ public class DataSourceTest {
@Test @Test
public void testDelete(){ public void testDelete(){
TestTableEntity testTableEntity = new TestTableEntity();
testTableEntity.setIdName("guo_test_delete");
testTableService.save(testTableEntity);
HashMap<String, Object> stringStringHashMap = new HashMap<>(); HashMap<String, Object> stringStringHashMap = new HashMap<>();
stringStringHashMap.put("id_name", "guo_test"); stringStringHashMap.put("id_name", "guo_test_delete");
boolean delete = testTableService.removeByMap(stringStringHashMap); boolean delete = testTableService.removeByMap(stringStringHashMap);
assertTrue(delete,"删除idName为guo_test的数据失败"); assertTrue(delete,"删除idName为guo_test的数据失败");
@@ -18,8 +18,6 @@ import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -45,12 +43,12 @@ class TaskControllerTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
StpUtil.login("测试用户-创建任务", "test_driver"); StpUtil.login("1", "test_driver");
} }
@Test @Test
void addTask() throws Exception { void addTask() throws Exception {
TaskRequest taskRequest = getTaskRequest(); TaskRequest taskRequest = getTaskRequest("测试任务名称-" + System.nanoTime());
log.info("testInfo"); log.info("testInfo");
String s = jacksonObjectMapper.writeValueAsString(taskRequest); String s = jacksonObjectMapper.writeValueAsString(taskRequest);
System.out.println("Token Value: " + StpUtil.getTokenValue()); System.out.println("Token Value: " + StpUtil.getTokenValue());
@@ -63,30 +61,16 @@ class TaskControllerTest {
} }
private static TaskRequest getTaskRequest() { private static TaskRequest getTaskRequest(String taskName) {
TaskRequest taskRequest = mock(TaskRequest.class); TaskRequest taskRequest = new TaskRequest();
taskRequest.setTaskName(taskName);
when(taskRequest.getId()).thenReturn(null); taskRequest.setTaskDescription("测试任务描述");
// 设置学习任务的名称 taskRequest.setMaterialUrl("http://example.com/material");
when(taskRequest.getTaskName()).thenReturn("测试任务名称"); taskRequest.setUrgency(5);
taskRequest.setImportance(3);
// 设置学习材料的存储URL taskRequest.setContentDifficulty(4);
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material"); taskRequest.setFutureValue(4);
taskRequest.setSubjectivePriority(1);
// 设置用户设置的任务紧急性
when(taskRequest.getUrgency()).thenReturn(5);
// 设置用户设置的任务重要性
when(taskRequest.getImportance()).thenReturn(3);
// 设置任务的内容难度
when(taskRequest.getContentDifficulty()).thenReturn(4);
// 设置任务的未来价值
when(taskRequest.getFutureValue()).thenReturn(4);
// 设置用户对任务的主观优先级
when(taskRequest.getSubjectivePriority()).thenReturn(1);
return taskRequest; return taskRequest;
} }
@@ -108,10 +92,8 @@ class TaskControllerTest {
@Test @Test
void updateTask() throws Exception { void updateTask() throws Exception {
TaskRequest taskRequest = mock(TaskRequest.class); TaskRequest taskRequest = getTaskRequest("测试数据名称");
when(taskRequest.getTaskName()).thenReturn("测试数据名称"); taskRequest.setId(1);
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material");
when(taskRequest.getId()).thenReturn(1);
String s = jacksonObjectMapper.writeValueAsString(taskRequest); String s = jacksonObjectMapper.writeValueAsString(taskRequest);
mockMvc.perform(put("/tasks/"+taskRequest.getId()) mockMvc.perform(put("/tasks/"+taskRequest.getId())
@@ -126,9 +108,9 @@ class TaskControllerTest {
@Test @Test
void deleteTask() throws Exception { void deleteTask() throws Exception {
CommonResult commonResult = taskController.addTask(getTaskRequest()); CommonResult commonResult = taskController.addTask(getTaskRequest("待删除任务-" + System.nanoTime()));
assertEquals(commonResult.getCode(),200); assertEquals(commonResult.getCode(),200);
TaskEntity taskEntity = tasksServiceImpl.getOne(Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, commonResult.getMessage())); TaskEntity taskEntity = tasksServiceImpl.getOne(Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, commonResult.getData()));
assertNotNull(taskEntity,"未能找到新创建的任务实体"); assertNotNull(taskEntity,"未能找到新创建的任务实体");
mockMvc.perform(delete("/tasks/"+taskEntity.getId()) mockMvc.perform(delete("/tasks/"+taskEntity.getId())
.header("satoken",StpUtil.getTokenValue())) .header("satoken",StpUtil.getTokenValue()))
@@ -145,4 +127,4 @@ class TaskControllerTest {
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").exists()); .andExpect(jsonPath("$.data").exists());
} }
} }
@@ -0,0 +1,301 @@
package com.guo.learningprogresstracker.entity;
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class StudySessionsEntityTest {
private StudySessionsEntity createOngoingSession(int minutesAgo) {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_TEST");
session.setStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setLastStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setEffectiveTime(0);
session.setActualTime(0);
session.setSessionState(StudySessionStateEnum.ONGOING.name());
return session;
}
/**
* 场景1:直接结束,有效时间 < 10分钟 → effectiveTime 应被清零
*/
@Test
void endedSession_effectiveTimeLessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(5);
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "有效时间不足10分钟应被清零");
assertEquals(0, session.getEffectivenessRatio(), "有效时间比应被清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景2:直接结束,有效时间 = 10分钟(边界值)→ 保留(600 不 < 600
*/
@Test
void endedSession_effectiveTimeExactly10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(10);
session.endedStudySession();
assertTrue(session.getEffectiveTime() >= 10 * 60, "有效时间等于10分钟应保留(阈值为 < 600)");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景3:直接结束,有效时间 > 10分钟 → effectiveTime 应保留
*/
@Test
void endedSession_effectiveTimeMoreThan10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(15);
session.endedStudySession();
assertTrue(session.getEffectiveTime() >= 15 * 60, "有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景4:暂停后直接结束(未继续),暂停段 < 10分钟 → 清零
*/
@Test
void endedSession_pausedThenEnded_lessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(5);
session.pausedStudySession(LocalDateTime.now());
assertEquals(StudySessionStateEnum.PAUSED.name(), session.getSessionState());
assertTrue(session.getEffectiveTime() > 0, "暂停后应有有效时间");
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "暂停后结束,有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景5:暂停后继续,再结束,总有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_pausedContinuedThenEnded_lessThan10Min_shouldZeroOut() throws Exception {
StudySessionsEntity session = createOngoingSession(8);
// 暂停(累加3分钟:从8分钟前到5分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(5));
// 继续学习,模拟又学习了2分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(2));
session.endedStudySession();
// 总有效时间 ≈ 3+2 = 5分钟 < 10分钟 → 清零
assertEquals(0, session.getEffectiveTime(), "总有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景6:暂停后继续,再结束,总有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_pausedContinuedThenEnded_moreThan10Min_shouldKeep() throws Exception {
StudySessionsEntity session = createOngoingSession(20);
// 暂停(累加8分钟:从20分钟前到12分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
// 继续学习,模拟又学习了12分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(12));
session.endedStudySession();
// 总有效时间 ≈ 8+12 = 20分钟 > 10分钟 → 保留
assertTrue(session.getEffectiveTime() > 0, "总有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景7:多次暂停-继续,总有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_multiplePauseContinue_lessThan10Min_shouldZeroOut() throws Exception {
StudySessionsEntity session = createOngoingSession(15);
// 第一段:3分钟(15分钟前 → 12分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
// 继续,模拟第二段学习3分钟(12分钟前 → 9分钟前)
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(9));
// 第二段暂停,累加3分钟
session.pausedStudySession(LocalDateTime.now().minusMinutes(6));
// 继续,模拟第三段学习3分钟(6分钟前 → 3分钟前)
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(3));
// 总计 3+3+3 = 9分钟 < 10分钟
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "多次暂停继续,总有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景8:多次暂停-继续,总有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_multiplePauseContinue_moreThan10Min_shouldKeep() throws Exception {
StudySessionsEntity session = createOngoingSession(25);
// 第一段:5分钟(25分钟前 → 20分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(20));
// 继续,模拟第二段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(15));
// 第二段暂停,累加5分钟
session.pausedStudySession(LocalDateTime.now().minusMinutes(10));
// 继续,模拟第三段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
// 总计 5+5+5 = 15分钟 > 10分钟
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "多次暂停继续,总有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景9:已结束的会话再次结束 → 不应重复处理
*/
@Test
void endedSession_alreadyEnded_shouldNotChange() {
StudySessionsEntity session = createOngoingSession(5);
session.endedStudySession();
double effectiveTimeAfterFirstEnd = session.getEffectiveTime();
session.endedStudySession();
assertEquals(effectiveTimeAfterFirstEnd, session.getEffectiveTime(), "已结束的会话不应重复处理");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景10:有效时间非常短(1分钟) → 清零
*/
@Test
void endedSession_veryShortSession_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(1);
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "极短会话的有效时间应被清零");
assertEquals(0, session.getEffectivenessRatio());
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景11:有效时间恰好超过阈值(11分钟) → 保留
*/
@Test
void endedSession_justOverThreshold_shouldKeep() {
StudySessionsEntity session = createOngoingSession(11);
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "超过阈值的有效时间应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景12:暂停期间不应计入有效时间,只计实际学习的段
*/
@Test
void endedSession_pauseTimeNotCounted_shouldCalcCorrectly() throws Exception {
StudySessionsEntity session = createOngoingSession(30);
// 学习5分钟(30分钟前 → 25分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(25));
double firstSegment = session.getEffectiveTime();
assertTrue(firstSegment >= 5 * 60 - 1 && firstSegment <= 5 * 60 + 1,
"第一段有效时间应约5分钟,实际: " + firstSegment);
// 暂停20分钟(不应计入有效时间)
// 继续学习4分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(4));
session.endedStudySession();
// 有效时间应约为 5+4=9分钟 < 10分钟 → 清零
assertEquals(0, session.getEffectiveTime(), "暂停期间不应计入有效时间,9分钟 < 10分钟阈值应清零");
}
/**
* 场景13:暂停后继续,有效时间恰好在阈值边界 → 验证边界精度
*/
@Test
void endedSession_borderlineWithPause_shouldHandleCorrectly() throws Exception {
StudySessionsEntity session = createOngoingSession(20);
// 第一段:6分钟(20分钟前 → 14分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(14));
// 继续,模拟第二段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
// 总计 6+5 = 11分钟 > 10分钟 → 保留
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "总计11分钟应超过阈值被保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景14ONGOING 状态直接结束,effectiveTime 为 0(刚开始就结束) → 0 < 600 → 清零
*/
@Test
void endedSession_immediatelyEnded_shouldZeroOut() {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_INSTANT");
session.setStartTime(LocalDateTime.now());
session.setLastStartTime(LocalDateTime.now());
session.setEffectiveTime(0);
session.setActualTime(0);
session.setSessionState(StudySessionStateEnum.ONGOING.name());
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "刚开始就结束,有效时间应为0");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景15:暂停后直接结束(PAUSED 状态),有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_pausedThenDirectlyEnded_lessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(7);
// 暂停(累加7分钟有效时间)
session.pausedStudySession(LocalDateTime.now());
session.setEffectiveTime(7 * 60); // 精确设置为7分钟
// PAUSED 状态直接结束(不经过 ONGOING)
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "PAUSED 状态结束,7分钟 < 10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景16:暂停后直接结束(PAUSED 状态),有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_pausedThenDirectlyEnded_moreThan10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(15);
// 暂停(累加15分钟有效时间)
session.pausedStudySession(LocalDateTime.now());
// PAUSED 状态直接结束
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "PAUSED 状态结束,15分钟 > 10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
}

Some files were not shown because too many files have changed in this diff Show More