104 Commits
Author SHA1 Message Date
cat-shark 3e4c0645ad docs: 修正紧急性维度注释命名 2026-09-13 22:55:26 +08:00
cat-shark 11a7c8aaea chore: 添加 .gitattributes 规范行尾 2026-08-26 21:13:14 +08:00
cat-shark e691dfaa95 docs: 更新 API 端点总览 2026-08-26 21:13:14 +08:00
cat-shark a6f684c8b7 feat: 新增会话误操作结束接口 2026-08-15 21:24:59 +08:00
cat-shark 0413013f24 docs: 移除仓库文档 2026-08-05 23:35:10 +08:00
cat-shark 9595ad92ee merge: 合并残片报告拆分到dev 2026-08-03 23:20:01 +08:00
cat-shark 6678c2b8c0 refactor: 优化用户可见错误提示 2026-08-03 23:08:26 +08:00
cat-shark b434520eff refactor: 标准导图仅使用报告生成 2026-08-03 23:08:19 +08:00
cat-shark 5b6f7d6a2a refactor: 首页feed仅返回残片 2026-08-03 23:08:14 +08:00
cat-shark c8d0584f11 docs: 补充用户提示规范 2026-08-03 23:08:08 +08:00
cat-shark 8c272dc1dc fix: 流水线kubeconfig改用临时目录 2026-08-02 10:51:03 +08:00
cat-shark e42b15600a chore: 移除Jenkins配置 2026-08-02 10:21:05 +08:00
cat-shark e32226d7d2 docs: 合并CLAUDE规范 2026-08-01 00:15:56 +08:00
cat-shark b61736eb2b docs: migrate .pi/SYSTEM.md to AGENTS.md 2026-08-01 00:13:05 +08:00
cat-shark 8ed5631969 perf: multi-stage Dockerfile + remove Maven from CI — Docker layer caching instead 2026-07-26 23:07:56 +08:00
cat-shark 65ee61c6c9 fix: add shell: bash to CI steps — maven container uses sh by default 2026-07-26 22:06:33 +08:00
cat-shark 09e67cd834 chore: remove k8s/ — migrated to lpt-infra 2026-07-26 21:35:51 +08:00
cat-shark fd317b7932 docs: 补充Git提交规范 2026-07-23 23:03:21 +08:00
cat-shark 859dab9401 docs: add .pi/SYSTEM.md with project overview 2026-07-23 23:01:47 +08:00
cat-sharkandClaude Opus 4.8 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-sharkandClaude Opus 4.8 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-sharkandClaude Opus 4.8 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
110 changed files with 5594 additions and 717 deletions
+4
View File
@@ -0,0 +1,4 @@
# 行尾规范:文本文件统一 LF,Windows 批处理保留 CRLF
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+152
View File
@@ -0,0 +1,152 @@
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
env:
KUBECONFIG: /tmp/kubeconfig
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
git clone "$REPO_URL" .
git checkout "${{ gitea.sha }}"
- 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"
echo "IMAGE_TAG=$TAG"
echo "$TAG" > image_tag.txt
mkdir -p /tmp/lpt-ci
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
- 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/
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > /tmp/kubeconfig
chmod 600 /tmp/kubeconfig
- 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="$(cat image_tag.txt 2>/dev/null || cat /tmp/lpt-ci/image_tag.txt 2>/dev/null || echo '')"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG 为空,无法部署"
exit 1
fi
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
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
+177
View File
@@ -0,0 +1,177 @@
# LPT 后端服务(lpt-be
> Java Spring Boot 后端,提供 REST API、数据库访问、认证鉴权。
## Git 提交规范
- 提交信息必须简短且使用中文,不要使用英文长句。
- 格式:`类型: 简述`,例如 `feat: 新增用户登录接口``fix: 修复多租户拦截器空指针``docs: 补充API文档`
## 技术栈
- Java 17
- 框架:Spring Boot 3.2.5
- ORMMyBatis-Plus 3.5.5
- 数据库:MySQL 8.0
- 认证:Sa-Token 1.38.0Cookie 名 `satoken`
- 数据库迁移:Flyway
- 对象映射:MapStruct 1.5.5
- 连接池:Druid 1.2.8
- 密码加密:jBCrypt 0.4
- API 文档:Knife4j (OpenAPI 3)
- Lombok@Data, @Slf4j
## 项目结构
```text
lpt-be/src/main/java/com/guo/learningprogresstracker/
├── controller/ → REST 接口层
├── service/ → 业务逻辑层(接口 + impl)
├── mapper/ → MyBatis-Plus BaseMapper
├── entity/ → 数据库实体
├── dto/ → 请求/响应 DTO
├── config/ → Spring 配置
├── common/ → 全局异常处理、Ops
├── mapStruct/ → MapStruct Converter
├── enums/ → 枚举类
├── exception/ → 自定义异常
└── utils/ → 工具类
```
- controller 只做参数校验和路由。
- service/impl 承载业务逻辑。
- mapper 使用 MyBatis-Plus BaseMapper。
- entity 使用 `@TableName``@TableField` 映射数据库字段。
- mapStruct 承载 DTO 转换。
## API 端点总览(44 个)
| 模块 | 端点数 | 端点 |
|------|--------|------|
| 登录认证 | 2 | `POST /login`, `POST /logout` |
| 学习会话 | 11 | `PUT /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}`, `POST /study-sessions/{sessionNum}/study-sessions/continue`, `POST /study-sessions/{sessionNum}/study-sessions/pause`, `POST /study-sessions/{sessionNum}/study-sessions/ended`, `GET /study-sessions/{sessionNum}/all-fragments`, `GET /study-sessions/{sessionNum}/report-draft`, `GET /study-sessions/tasks/{taskNum}/fragments`, `GET /study-sessions/tasks/{taskNum}/reports`, `GET /study-sessions/active` |
| 任务管理 | 13 | `GET/POST /tasks`, `GET/PUT/DELETE /tasks/{taskId}`, `GET/PUT /tasks/priority-weights`, `GET/POST /tasks/{taskNum}/applications`, `PUT/DELETE /tasks/applications/{id}`, `GET /tasks/{taskNum}/study-sessions/start-or-continue`, `GET /tasks/{taskNum}/not-ended-study-session` |
| 复习与导图 | 13 | `GET /review/feed`, `GET /review/tasks`, `GET /review/tasks/{taskNum}`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/regenerate`, `PUT /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/recall`, `POST /review/standard-mind-map/{taskNum}/find-node`, `GET /review/standard-mind-map/{taskNum}/recall-records`, `GET /review/standard-mind-map/recall-records/{recordId}` |
| 学习残片 | 3 | `POST /report-fragments`, `PUT /report-fragments/{id}`, `GET /report-fragments/session/{sessionNum}` |
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
| 测试 | 1 | `GET /test/{id}` |
## 数据库(10 个核心表)
`users`, `tasks`, `study_sessions`, `study_reports`, `study_report_fragments`, `study_expectations`, `task_applications`, `review_standard_mind_maps`, `review_recall_records`, `user_priority_weights`
## 关键机制
- 多租户隔离:`TenantLineInnerInterceptor` 自动注入 `WHERE created_by = #{当前用户}`,排除 `user`/`flyway_schema_history` 表。
- 自动填充:`MetaObjectHandler` 自动填充 `created_by`/`updated_by`/`created_time`/`updated_time`
- 认证鉴权:Sa-Token 拦截 `/**` 排除 `/login`Cookie `satoken`,支持 `@SaCheckPermission` 注解式权限。
- 响应格式:`CommonResult<T>` 统一包装 `{ code, message, data }`
- CORS:可配置,local profile 允许所有来源。
## 响应规范
- 成功:`CommonResult.success(data)`code=200。
- 业务错误:`CommonResult.error(msg)`code=400HTTP 200。
- 未登录:`GlobalExceptionHandler.handleNotLogin()`HTTP 401 + code=401。
- 参数校验失败:`MethodArgumentNotValidException`code=400。
- 用户可见错误文案必须口语化、可理解,避免“无法生成”“不存在”等技术化表述;技术细节写入日志。例如无学习报告时应提示“这个任务还没开始学习哦,学习后产生学习报告后再来吧”,而不是“没有学习报告,无法生成思维导图”。
## DTO 转换
- MapStruct 编译期生成 `*ConvertImpl.java`,同名属性自动映射。
- 默认 `unmappedTargetPolicy = IGNORE`
- 自定义映射使用 `@Mapping(source, target)`
- 增删 DTO 字段后必须重新编译,否则生成代码不含新字段。
## CORS
-`CorsProperties` 读取各 profile 的 `cors.allowed-origins`
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`,兼容 `allowCredentials`
## 标题抓取
- `TitleFetcher`:静态工具类,支持 HTTP→HTTPS 重定向和宽松 SSL。
- `UtilsController``GET /utils/fetch-title?url=...` 代理端点。
## 运行环境
- 默认 profilelocal`application.yml``spring.profiles.active: local`)。
- 编译命令:`mvn clean compile -DskipTests`,需要 JDK 17。
## 配置文件
| Profile | 文件 |
|---------|------|
| local | `application-local.yml` |
| dev | `application-dev.yml` |
| uat | `application-uat.yml` |
| prod | `application-prod.yml` |
| 公共 | `application.yml` |
## AI 服务依赖
- 配置:`lpt.ai-service.url=http://localhost:5199`
- 超时:600s
- AI 服务不可用时自动降级到内置规则引擎(`BuiltinMindMapGenerator`
## 启动命令
```bash
mvn clean compile -DskipTests # 编译(需要 JDK 17
mvn spring-boot:run # 启动(默认 profile: local
```
## 关联项目
| 项目 | 路径 | 端口 | 说明 |
|------|------|------|------|
| lpt-fe | `../lpt-fe/` | 5158 | Vue 3 前端,通过 `/api` 代理调用本服务 |
| lpt-ai | `../lpt-ai/` | 5199 | AI 服务,本服务通过 HTTP 调用其异步任务接口 |
## 项目规范
## 数据库迁移(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 片段
+17 -10
View File
@@ -1,17 +1,24 @@
# 使用官方 OpenJDK 作为基础镜像
FROM eclipse-temurin:17-jre
# 设置工作目录
# Stage 1: Maven 构建(Docker 层缓存自动缓存依赖)
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
# 拷贝打包好的 JAR 文件到容器
COPY target/LPT.jar LPT.jar
# 先复制 pom.xml,单独一层 —— 只要依赖不变,这层就被缓存
COPY pom.xml .
RUN mvn dependency:go-offline -B -q
# 再复制源码构建
COPY src/ ./src/
RUN mvn package -DskipTests -B -q
# Stage 2: 运行时(精简 JRE
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/LPT.jar LPT.jar
# 暴露应用运行的端口
EXPOSE 8888
# 启动应用
ENTRYPOINT ["java", "-jar", "LPT.jar"]
# 健康检查
HEALTHCHECK --interval=10s --timeout=5s CMD curl --fail http://localhost:8888/actuator/health || exit 1
HEALTHCHECK --interval=10s --timeout=5s \
CMD curl --fail http://localhost:8888/actuator/health || exit 1
Vendored
-78
View File
@@ -1,78 +0,0 @@
pipeline {
agent none // 全局不指定,局部自己声明
environment {
IMAGE_NAME = 'lpt-prod:0.0'
CONTAINER_NAME = 'LPT-prod'
CONTAINER_PORT = '8888'
}
stages {
stage('Build') {
agent {
docker {
image 'maven:3.9.6-eclipse-temurin-17'
args '-v /root/.m2:/root/.m2'
}
}
steps {
sh 'mvn -B -DskipTests clean package'
}
}
stage('Build Docker Image') {
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
steps {
sh 'docker build -t $IMAGE_NAME .'
}
}
stage('Run Docker Container') {
agent any
steps {
sh '''
docker network create traefik-public || true
docker rm -f $CONTAINER_NAME || true
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\
--restart=always \\
-e SPRING_PROFILES_ACTIVE=prod \\
--label "traefik.enable=true" \\
--label "traefik.docker.network=traefik-public" \\
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\
--label "traefik.http.routers.lpt-api.priority=100" \\
--label "traefik.http.routers.lpt-api.service=lpt-api" \\
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\
--log-driver=loki \\
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
$IMAGE_NAME
docker network connect traefik-public $CONTAINER_NAME || true
'''
}
}
stage('Check Docker Status') {
agent any
steps {
script {
def lastStatus = ''
timeout(time: 60, unit: 'SECONDS') {
waitUntil {
def status = sh(
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
returnStdout: true
).trim()
if (status != lastStatus) {
echo "Container health: ${status}"
lastStatus = status
}
return (status == 'healthy')
}
}
}
}
}
}
}
-78
View File
@@ -1,78 +0,0 @@
pipeline {
agent none // 全局不指定,局部自己声明
environment {
IMAGE_NAME = 'lpt-dev:0.0'
CONTAINER_NAME = 'LPT-dev'
CONTAINER_PORT = '8888'
}
stages {
stage('Build') {
agent {
docker {
image 'maven:3.9.6-eclipse-temurin-17'
args '-v /root/.m2:/root/.m2'
}
}
steps {
sh 'mvn -B -DskipTests clean package'
}
}
stage('Build Docker Image') {
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
steps {
sh 'docker build -t $IMAGE_NAME .'
}
}
stage('Run Docker Container') {
agent any
steps {
sh '''
docker network create traefik-public || true
docker rm -f $CONTAINER_NAME || true
docker run -d --name $CONTAINER_NAME --network mysql_default \\
--restart=always \\
-e SPRING_PROFILES_ACTIVE=dev \\
--label "traefik.enable=true" \\
--label "traefik.docker.network=traefik-public" \\
--label 'traefik.http.routers.lpt-api-dev.rule=Host(`lpt-dev.cat-shark.xyz`) && PathPrefix(`/api`)' \\
--label "traefik.http.routers.lpt-api-dev.entrypoints=websecure" \\
--label "traefik.http.routers.lpt-api-dev.tls.certresolver=le" \\
--label "traefik.http.routers.lpt-api-dev.priority=100" \\
--label "traefik.http.routers.lpt-api-dev.service=lpt-api-dev" \\
--label "traefik.http.routers.lpt-api-dev.middlewares=lpt-api-dev-strip" \\
--label "traefik.http.middlewares.lpt-api-dev-strip.stripprefix.prefixes=/api" \\
--label "traefik.http.services.lpt-api-dev.loadbalancer.server.port=$CONTAINER_PORT" \\
--log-driver=loki \\
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
$IMAGE_NAME
docker network connect traefik-public $CONTAINER_NAME || true
'''
}
}
stage('Check Docker Status') {
agent any
steps {
script {
def lastStatus = ''
timeout(time: 60, unit: 'SECONDS') {
waitUntil {
def status = sh(
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
returnStdout: true
).trim()
if (status != lastStatus) {
echo "Container health: ${status}"
lastStatus = status
}
return (status == 'healthy')
}
}
}
}
}
}
}
Vendored Regular → Executable
View File
+13 -1
View File
@@ -52,6 +52,11 @@
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
@@ -129,6 +134,13 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- jBCrypt (独立 BCrypt 实现,无 Spring Security 依赖) -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
</dependencies>
<build>
@@ -142,7 +154,7 @@
<forceJavacCompilerUse>true</forceJavacCompilerUse>
<source>17</source>
<target>17</target>
<compilerArgs>--enable-preview</compilerArgs>
<annotationProcessorPaths>
<path>
<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 注释。
@@ -5,6 +5,7 @@ import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.exception.AppException;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -15,41 +16,42 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @author guo
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler({ErrorParameterException.class})
public CommonResult errorParameterException(ErrorParameterException ex) {
//code:400
log.warn("参数异常: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({NotFindEntitiesException.class})
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
//code:400
log.warn("数据不存在: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({OperationFailedException.class})
public CommonResult operationFailedException(OperationFailedException ex) {
log.warn("操作失败: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({AppException.class})
public CommonResult AppException(AppException ex) {
//code:500
log.error("业务异常: {}", ex.getMessage(), ex);
return CommonResult.serverError(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult MyMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
//code:400
return CommonResult.error(bindingResult.getFieldError().getDefaultMessage());
}
@ExceptionHandler(NotLoginException.class)
public CommonResult handleNotLogin(NotLoginException e, HttpServletRequest req, HttpServletResponse res) {
//code:401
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return new CommonResult<>(HttpStatus.UNAUTHORIZED.value(), "未登录,请重新登录", null);
}
@@ -57,8 +59,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public CommonResult Exception(Exception ex) {
log.error("系统异常", ex);
//code:400
return CommonResult.error(ex.getMessage());
return CommonResult.error("操作没有成功,请稍后再试");
}
}
}
@@ -4,12 +4,13 @@ import jakarta.validation.groups.Default;
/**
* 全局数据验证分组
* GlobalValidationGroup -> OpenGroups ->ops
* @author guo
*/
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;
/**
* @author guo
*/
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper mapper = builder.build();
// 注册自定义序列化器
SimpleModule module = new SimpleModule();
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
mapper.registerModule(module);
// 关闭序列化为时间戳使用字符串格式
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
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.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;
/**
* @author Administrator
*/
@Profile("dev")
@Configuration
@Slf4j
@RequiredArgsConstructor
public class WebMvcDevConfig implements WebMvcConfigurer {
@Slf4j
public class WebMvcConfig implements WebMvcConfigurer {
private final CorsProperties corsProperties;
// 注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> {
// OPTIONS 请求直接放行
if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) {
return;
}
@@ -40,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
@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(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials());
String[] origins = corsProperties.getAllowedOrigins();
boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
log.info("允许cors的地址配置:{}", Arrays.toString(origins));
if (hasWildcard) {
registry.addMapping("/**")
.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;
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
// 修改为ISO 8601格式,末尾带Z,表示UTC时区
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
public LocalDateTimeSerializer() {
super(LocalDateTime.class);
@@ -22,7 +20,6 @@ public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
if (value == null) {
gen.writeNull();
} else {
// 这里假设LocalDateTime是UTC时间,直接格式化并加Z
String formattedDate = value.format(formatter);
gen.writeString(formattedDate);
}
@@ -13,9 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* @author guo
*/
@CrossOrigin
@RestController
@RequiredArgsConstructor
@@ -1,16 +1,25 @@
package com.guo.learningprogresstracker.controller;
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.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
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 org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 复习模块控制层 — 复习内容滚动展示与交互
@@ -21,15 +30,34 @@ import java.util.List;
@RequiredArgsConstructor
public class ReviewController {
private final ReviewServiceImpl reviewService;
private final ReviewService reviewService;
private final StandardMindMapService standardMindMapService;
/**
* 获取复习 feed合并报告和残片按时间倒序
* 获取复习 feed仅返回学习残片(首页滚动条使用)
*/
@GetMapping("/feed")
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
@RequestParam(defaultValue = "30") int limit) {
return CommonResult.success(reviewService.getReviewFeed(limit));
@RequestParam(defaultValue = "30") int 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 {
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,17 @@
package com.guo.learningprogresstracker.controller;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
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.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.mapStruct.StudySessionConvert;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
@@ -15,8 +19,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.time.LocalDateTime;
/**
* 学习会话控制层
@@ -29,21 +33,35 @@ public class StudySessionController {
private final StudySessionsServiceImpl studySessionsServiceImpl;
@PostMapping
public void studySessionList() {
private final StudyExpectationsService studyExpectationsService;
/**
* 创建/更新学习会话的学习预期
*/
@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));
}
/**
* 通过sessionNum获取一个【学习会话】
*/
@GetMapping("/{sessionNum}")
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException {
StudySessionsEntity studySessionsEntity = studySessionsServiceImpl.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
StudySessionResponse studySessionResponse = StudySessionConvert.MAPPER.toStudySessionResponse(studySessionsEntity);
return CommonResult.success(studySessionResponse);
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "请提供学习会话编号") @PathVariable String sessionNum) throws ErrorParameterException {
// 通过 service 层获取,包含归属校验
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
return CommonResult.success(response);
}
/**
@@ -72,7 +90,17 @@ public class StudySessionController {
@PostMapping("/{sessionNum}/study-sessions/ended")
public CommonResult<Void> endedStudySession(@PathVariable("sessionNum") String sessionNum,
@Valid @RequestBody EndedStudySessionRequest request) throws ErrorParameterException {
studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
String message = studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
return message != null ? CommonResult.success(message) : CommonResult.success();
}
/**
* 误操作结束一个【学习会话】:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
*/
@PostMapping("/{sessionNum}/study-sessions/abort")
public CommonResult<Void> abortStudySession(@PathVariable("sessionNum") String sessionNum,
@Valid @RequestBody AbortStudySessionRequest request) throws ErrorParameterException {
studySessionsServiceImpl.abortStudySession(sessionNum, request.getConfirmation());
return CommonResult.success();
}
@@ -85,4 +113,39 @@ public class StudySessionController {
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.guo.learningprogresstracker.common.Ops;
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.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
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.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.PriorityWeightsService;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
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 java.rmi.ServerException;
import java.util.List;
/**
* 任务控制层
@@ -43,10 +50,25 @@ public class TaskController {
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
@Operation(summary = "添加新任务")
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")
@@ -69,6 +91,36 @@ public class TaskController {
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
@Operation(summary = "获取所有任务列表")
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@@ -80,7 +132,7 @@ public class TaskController {
* 开始或继续一个【学习会话】
*/
@GetMapping("/{taskNum}/study-sessions/start-or-continue")
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "taskNum不可为空")
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "请提供任务编号")
@PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
return CommonResult.success(response);
@@ -91,7 +143,7 @@ public class TaskController {
* 通过学习任务num获取该任务的未结束会话
*/
@GetMapping("/{taskNum}/not-ended-study-session")
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "taskNum不可为空")
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "请提供任务编号")
@PathVariable String taskNum) throws ErrorParameterException {
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
return CommonResult.success(response);
@@ -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;
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.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 学习残片控制层
@@ -24,12 +25,29 @@ public class reportFragmentsController {
/**
* 创建学习残片
* @param request
* @return
*/
@PostMapping
public CommonResult<Void> createFragments(@Valid @RequestBody CreateFragmentsRequest request) throws NotFindEntitiesException {
studyReportFragmentsServiceImpl.createFragments(request);
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);
}
}
@@ -5,7 +5,7 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* 复习滚动 feed 条目,合并展示学习报告和残片
* 复习滚动 feed 条目;首页仅使用残片,任务详情仍可同时包含报告和残片
*/
@Data
public class ReviewFeedItem {
@@ -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;
/**
* @author guo
*/
@Data
public class TaskInfo {
@@ -16,6 +13,8 @@ public class TaskInfo {
private String taskName;
// 任务描述
private String taskDescription;
// 学习材料地址
private String materialUrl;
// 任务优先级
private Double taskPriority;
// 上次该任务学习情况
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 误操作结束学习会话的请求体
*/
@Data
public class AbortStudySessionRequest {
@NotBlank(message = "请填写确认语")
private String confirmation;
}
@@ -16,6 +16,6 @@ public class CreateFragmentsRequest {
/**
* 残片内容,学习内容的描述
*/
@NotBlank(message = "啊?无字天书?")
@NotBlank(message = "请填写学习内容")
private String content;
}
@@ -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;
}
@@ -8,6 +8,6 @@ import lombok.Data;
*/
@Data
public class EndedStudySessionRequest {
@NotBlank(message = "啊?搞无字天书是吧?报告内容不可为空")
@NotBlank(message = "请填写学习报告内容")
private String content;
}
@@ -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;
}
@@ -15,16 +15,16 @@ import lombok.Data;
public class TaskRequest {
public interface Create{}
@Null(message = "创建时不可指定任务ID",groups = Ops.CreateG.class)
@NotNull(message = "更新时必须指定任务ID",groups = Ops.UpdateG.class)
@Null(message = "创建任务时不需要填写任务编号",groups = Ops.CreateG.class)
@NotNull(message = "缺少要更新的任务信息,请刷新后重试",groups = Ops.UpdateG.class)
private Integer id;
/**
* 学习任务的名称
*/
@NotEmpty(message = "必须指定非空的任务名称", groups = Ops.CreateG.class)
@NotEmpty(message = "请填写任务名称", groups = Ops.CreateG.class)
private String taskName;
@NotEmpty(message = "必须指定非空的任务描述", groups = Ops.CreateG.class)
@NotEmpty(message = "请填写任务描述", groups = Ops.CreateG.class)
private String taskDescription;
/**
@@ -35,41 +35,41 @@ public class TaskRequest {
/**
* 用户设置的任务紧急性
*/
@NotNull(message = "必须指定【任务紧急性】指标", groups = Ops.CreateG.class)
@Min(value = 0,message = "urgency参数值必须大于等于0")
@Max(value = 5,message = "urgency参数值必须小于等于5")
@NotNull(message = "请设置任务紧急性", groups = Ops.CreateG.class)
@Min(value = 0,message = "任务紧急性不能小于 0")
@Max(value = 5,message = "任务紧急性不能大于 5")
private Integer urgency;
/**
* 用户设置的任务重要性
*/
@NotNull(message = "必须指定【任务重要性】指标", groups = Ops.CreateG.class)
@Min(value = 0,message = "importance参数值必须大于等于0")
@Max(value = 5,message = "importance参数值必须小于等于5")
@NotNull(message = "请设置任务重要性", groups = Ops.CreateG.class)
@Min(value = 0,message = "任务重要性不能小于 0")
@Max(value = 5,message = "任务重要性不能大于 5")
private Integer importance;
/**
* 任务的内容难度
*/
@NotNull(message = "必须指定【内容难度】指标", groups = Ops.CreateG.class)
@Min(value = 0,message = "contentDifficulty参数值必须大于等于0")
@Max(value = 5,message = "contentDifficulty参数值必须小于等于5")
@NotNull(message = "请设置内容难度", groups = Ops.CreateG.class)
@Min(value = 0,message = "内容难度不能小于 0")
@Max(value = 5,message = "内容难度不能大于 5")
private Integer contentDifficulty;
/**
* 任务的未来价值
*/
@NotNull(message = "必须指定【未来价值】指标", groups = Ops.CreateG.class)
@Min(value = 0,message = "futureValue参数值必须大于等于0")
@Max(value = 5,message = "futureValue参数值必须小于等于5")
@NotNull(message = "请设置未来价值", groups = Ops.CreateG.class)
@Min(value = 0,message = "未来价值不能小于 0")
@Max(value = 5,message = "未来价值不能大于 5")
private Integer futureValue;
/**
* 用户对任务的主观优先级
*/
@NotNull(message = "必须指定【主观优先级】指标", groups = Ops.CreateG.class)
@Min(value = 0,message = "subjectivePriority参数值必须大于等于0")
@Max(value = 5,message = "subjectivePriority参数值必须小于等于5")
@NotNull(message = "请设置主观优先级", groups = Ops.CreateG.class)
@Min(value = 0,message = "主观优先级不能小于 0")
@Max(value = 5,message = "主观优先级不能大于 5")
private Integer subjectivePriority;
}
@@ -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 = "任务编号")
private String taskNum;
@Schema(description = "学习材料(Markdown")
private String materialUrl;
@Schema(description = "任务ID")
private Integer taskId;
@Schema(description = "学习开始时间")
private LocalDateTime startTime;
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
*/
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.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;
import java.io.Serializable;
/**
* 存储每次学习开始前的预期
* @TableName study_expectations
*/
@TableName(value ="study_expectations")
@TableName(value = "study_expectations")
@Data
public class StudyExpectationsEntity extends BaseEntity implements Serializable {
/**
*
*/
@TableId(value = "expectation_id", type = IdType.AUTO)
private Integer expectationId;
/**
*
*/
@TableField(value = "session_id")
private Integer sessionId;
@TableField(value = "session_num")
private String sessionNum;
/**
* 学习预期的详细描述
@@ -33,7 +27,6 @@ public class StudyExpectationsEntity extends BaseEntity implements Serializable
@TableField(value = "description")
private String description;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
}
@@ -33,6 +33,11 @@ public class StudyReportsEntity extends BaseEntity implements Serializable {
@TableField(value = "content")
private String content;
/**
* 对应学习会话的预期目标(非数据库字段,查询时填充)
*/
@TableField(exist = false)
private String sessionExpectation;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
@@ -123,7 +123,6 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
public void pausedStudySession(LocalDateTime endTime){
if (this.getSessionState().equals(StudySessionStateEnum.PAUSED.name())) {
log.warn("不应出现的情况:暂停了一个状态为【{}】的学习会话({})", this.getSessionState(), this.getSessionNum());
//
} else {
this.setEndTime(ObjectUtils.isEmpty(endTime) ? LocalDateTime.now() : endTime);
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());
}else {
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());
}
@@ -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;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
@@ -11,4 +12,6 @@ public interface FragmentsConvert {
FragmentsConvert MAPPER = Mappers.getMapper(FragmentsConvert.class);
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;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
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
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,41 @@
package com.guo.learningprogresstracker.service;
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 applications 该任务的应用场景(可选)
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
*/
Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
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;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
@@ -13,9 +14,19 @@ import java.util.List;
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,25 @@
package com.guo.learningprogresstracker.service;
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 extends IService<StudyExpectationsEntity> {
* 学习预期服务
*/
public interface StudyExpectationsService {
/**
* 为学习会话创建学习预期(每个会话最多一条,重复创建则覆盖)
*/
StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException;
/**
* 查询学习会话的学习预期,不存在时返回 null
*/
StudyExpectationsEntity getBySessionNum(String sessionNum);
/**
* 删除学习会话的学习预期(误操作结束时会话不留任何数据)
*/
void deleteBySessionNum(String sessionNum);
}
@@ -1,10 +1,13 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.util.List;
/**
* @author guo
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service
@@ -13,4 +16,8 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
public interface StudyReportFragmentsService extends IService<StudyReportFragmentsEntity> {
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.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.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
@@ -20,9 +24,20 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException;
void endedStudySession(String sessionNum, String content) throws ErrorParameterException;
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
/**
* 误操作结束学习会话:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
*/
void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException;
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
void 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.service.IService;
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.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.rmi.ServerException;
import java.util.List;
/**
* @author guo
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
void updateTask(String taskId, TaskRequest updatedTask);
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,237 @@
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) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> params = Map.of(
"taskName", taskName,
"taskDescription", taskDescription != null ? taskDescription : "",
"reports", reports != null ? reports : 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,155 @@
package com.guo.learningprogresstracker.service.impl;
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<TaskApplicationEntity> applications,
String clientHint) {
if (reports == null || reports.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()
.filter(r -> r.getSessionNum() != null)
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
for (Map.Entry<String, List<StudyReportsEntity>> entry : reportsBySession.entrySet()) {
List<StudyReportsEntity> sessReports = entry.getValue();
// 会话分支标题:取第一条报告的前 60 字作为摘要
StudyReportsEntity firstReport = sessReports.get(0);
String sessionTitle = truncate(firstReport.getContent(), MAX_TITLE_LENGTH);
if (firstReport.getCreatedTime() != null) {
sessionTitle = formatDate(firstReport.getCreatedTime()) + " " + sessionTitle;
}
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);
}
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,75 @@
package com.guo.learningprogresstracker.service.impl;
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<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());
if (reportTexts.isEmpty()) {
return Optional.empty();
}
Optional<String> optOutline = aiServiceClient.generateMindMap(
task.getTaskName(),
task.getTaskDescription(),
reportTexts
);
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,49 +1,412 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
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.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.service.ReviewService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 复习模块 Service 实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
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 StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
@Override
public List<ReviewFeedItem> getReviewFeed(int limit) {
return reviewMapper.selectReviewFeed(limit);
public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
int safeLimit = normalizeLimit(limit);
if (SMART_MODE.equalsIgnoreCase(mode)) {
return getSmartFeed(safeLimit);
}
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
List<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(List.of(), 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<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<ReviewFeedItem> candidates = mergeAndConvert(List.of(), fragments);
if (candidates.size() <= safeLimit) {
Collections.shuffle(candidates);
return candidates;
}
// 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习)
Map<String, Double> taskRecallRatio = latestRecallRatioByTask(
candidates.stream().map(ReviewFeedItem::getTaskNum)
.filter(Objects::nonNull).collect(Collectors.toSet()));
LocalDateTime now = LocalDateTime.now();
List<Double> weights = new ArrayList<>(candidates.size());
for (ReviewFeedItem item : candidates) {
weights.add(reviewNeedWeight(item, taskRecallRatio, now));
}
return weightedSample(candidates, weights, safeLimit);
}
/** 单条内容的复习需求权重 */
private double reviewNeedWeight(ReviewFeedItem item, Map<String, Double> taskRecallRatio, LocalDateTime now) {
// 时间衰减:7 天内权重较低,之后随天数增长,90 天封顶
double ageDays = item.getCreatedTime() == null ? 30
: Math.max(0, java.time.Duration.between(item.getCreatedTime(), now).toDays());
double ageFactor = 0.3 + Math.min(ageDays, 90) / 90.0 * 0.7;
// 掌握度:最近回忆覆盖率越低,权重越高
double ratio = item.getTaskNum() == null ? 0
: taskRecallRatio.getOrDefault(item.getTaskNum(), 0D);
double masteryFactor = 1.0 - ratio * 0.7; // 覆盖率 100% 时权重降至 0.3
return ageFactor * masteryFactor;
}
/** 查询每个任务最近一次回忆对比的覆盖率 */
private Map<String, Double> latestRecallRatioByTask(Set<String> taskNums) {
if (taskNums.isEmpty()) {
return Collections.emptyMap();
}
List<ReviewRecallRecordEntity> records = reviewRecallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.in(ReviewRecallRecordEntity::getTaskNum, taskNums)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
Map<String, Double> result = new HashMap<>();
for (ReviewRecallRecordEntity record : records) {
result.putIfAbsent(record.getTaskNum(),
record.getRecallRatio() == null ? 0D : record.getRecallRatio());
}
return result;
}
/** 按权重不放回采样 */
private List<ReviewFeedItem> weightedSample(List<ReviewFeedItem> items, List<Double> weights, int count) {
List<ReviewFeedItem> pool = new ArrayList<>(items);
List<Double> poolWeights = new ArrayList<>(weights);
List<ReviewFeedItem> selected = new ArrayList<>(count);
Random random = new Random();
while (selected.size() < count && !pool.isEmpty()) {
double total = poolWeights.stream().mapToDouble(Double::doubleValue).sum();
double r = random.nextDouble() * total;
double cumulative = 0;
int chosen = pool.size() - 1;
for (int i = 0; i < pool.size(); i++) {
cumulative += poolWeights.get(i);
if (r <= cumulative) {
chosen = i;
break;
}
}
selected.add(pool.remove(chosen));
poolWeights.remove(chosen);
}
return selected;
}
@Override
public List<ReviewTaskStats> getReviewTaskStats() {
List<TaskEntity> tasks = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.orderByDesc(TaskEntity::getCalculatedPriority));
List<ReviewTaskStats> stats = tasks.stream()
.map(this::newTaskStats)
.collect(Collectors.toList());
fillReviewTaskStats(stats);
return stats;
}
@Override
public ReviewTaskStats getReviewTaskStats(String taskNum) {
TaskEntity task = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.eq(TaskEntity::getTaskNum, taskNum)
.last("LIMIT 1"))
.stream()
.findFirst()
.orElse(null);
ReviewTaskStats stats = newTaskStats(taskNum, task == null ? null : task.getTaskName());
fillReviewTaskStats(Collections.singletonList(stats));
return stats;
}
@Override
public List<ReviewFeedItem> getTaskReview(String taskNum) {
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
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在"));
.orElseThrow(() -> {
log.warn("学习报告[{}]不存在", id);
return new NotFindEntitiesException("这份学习报告不存在或已被删除");
});
}
@Override
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
.orElseThrow(() -> {
log.warn("学习残片[{}]不存在", id);
return new NotFindEntitiesException("这条学习残片不存在或已被删除");
});
}
/**
* 将报告和残片合并转换为 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))) {
log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
}
}
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,562 @@
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 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) {
log.warn("任务[{}]标准思维导图尚不存在,无法增量更新", taskNum);
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) {
log.error("任务[{}]对比结果序列化失败", taskNum, 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(() -> {
log.warn("回忆记录[{}]不存在", recordId);
return new NotFindEntitiesException("这条回忆记录不存在或已被删除");
});
}
// ============ 内部方法 ============
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
TaskEntity task = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
if (task == null) {
log.warn("任务[{}]不存在,无法生成思维导图", taskNum);
throw new OperationFailedException("这个任务不存在或已被删除");
}
// 收集学习数据
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<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
if (reports.isEmpty()) {
log.warn("任务[{}]没有学习报告,无法生成思维导图", taskNum);
throw new OperationFailedException("这个任务还没开始学习哦,学习后产生学习报告后再来吧");
}
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
.orElse(null);
if (client == null) {
log.warn("任务[{}]没有可用的思维导图生成器", taskNum);
throw new OperationFailedException("思维导图暂时生成不了,请稍后再试");
}
Optional<MindMapNode> optRoot = client.generate(task, reports, 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, applications, null);
}
}
if (optRoot.isEmpty()) {
log.warn("任务[{}]思维导图生成失败,已尝试全部生成器", taskNum);
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(0);
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))) {
log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
}
}
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,62 @@
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.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.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* @author guo
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
* 学习预期服务实现
*/
@Slf4j
@Service
public class StudyExpectationsServiceImpl extends ServiceImpl<StudyExpectationsMapper, StudyExpectationsEntity>
implements StudyExpectationsService{
@RequiredArgsConstructor
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) {
log.warn("学习会话[{}]不存在", sessionNum);
throw new ErrorParameterException("这次学习会话不存在或已结束");
}
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"));
}
@Override
public void deleteBySessionNum(String sessionNum) {
studyExpectationsMapper.delete(
Wrappers.<StudyExpectationsEntity>lambdaQuery()
.eq(StudyExpectationsEntity::getSessionNum, sessionNum));
}
}
@@ -3,6 +3,7 @@ package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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.StudySessionsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
@@ -11,32 +12,54 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* @author guo
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
implements StudyReportFragmentsService{
implements StudyReportFragmentsService {
private final StudySessionsMapper studySessionsMapper;
@Override
@Transactional
public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
if (studySessionsMapper.exists(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum,entity.getSessionNum()))) {
this.save(entity);
}else {
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]",entity.getSessionNum()));
StudySessionsEntity session = studySessionsMapper.selectOne(
Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
if (session == null) {
log.warn("未能找到学习会话sessionNum[{}]", entity.getSessionNum());
throw new NotFindEntitiesException("这次学习会话不存在或已结束");
}
this.save(entity);
}
@Override
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity existing = this.getById(id);
if (existing == null) {
log.warn("未能找到学习残片id[{}]", id);
throw new NotFindEntitiesException("这条学习残片不存在或已被删除");
}
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;
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.google.protobuf.ServiceException;
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.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.service.StudySessionsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -27,9 +30,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
/**
* @author guo
* @description 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
* 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
*/
@Slf4j
@Service
@@ -38,15 +39,26 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
implements StudySessionsService {
public static final int WORK_DURATION = 25;
/**
* 误操作结束的确认语:用户必须输入完全一致的内容才能零数据关闭会话
*/
public static final String MISOPERATION_CONFIRMATION = "我误操作导致开启了本次学习";
private final TasksServiceImpl tasksServiceImpl;
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudyReportsMapper studyReportsMapper;
private final AiServiceClient aiServiceClient;
private final StudyExpectationsService studyExpectationsService;
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum))
.orElseThrow(() -> new NotFindEntitiesException(String.format("[%s]不存在", taskNum)));
.orElseThrow(() -> {
log.warn("[{}]不存在", taskNum);
return new NotFindEntitiesException("这个任务不存在或已被删除");
});
StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
@@ -69,6 +81,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
response.setTaskName(taskEntity.getTaskName());
response.setMaterialUrl(taskEntity.getMaterialUrl());
response.setTaskId(taskEntity.getId());
if (session.isOverTime()) {
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
@@ -82,10 +96,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
.orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
throw new ErrorParameterException("会话[" + sessionNum + "]已经暂停,请勿重复暂停");
throw new ErrorParameterException("这次学习会话已经暂停了,不用重复暂停");
}
studySessionsEntity.calculatePointerPosition();
studySessionsEntity.pausedStudySession(endTime);
@@ -94,23 +108,59 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
@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)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
.orElseThrow(() -> sessionNotFound(sessionNum));
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
studySessionsEntity.endedStudySession();
this.updateById(studySessionsEntity);
// 创建学习报告
StudyReportsEntity studyReportsEntity = new StudyReportsEntity();
studyReportsEntity.setSessionNum(sessionNum);
studyReportsEntity.setContent(content);
studyReportsMapper.insert(studyReportsEntity);
if (wasOngoing && studySessionsEntity.getEffectiveTime() == 0) {
return "本次有效学习时间不足10分钟,不计入总学习时间";
}
return null;
}
@Override
@Transactional
public void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
log.warn("会话[{}]已结束,不能误操作结束", sessionNum);
throw new ErrorParameterException("这次学习会话已经结束,不能再误操作结束了");
}
if (!MISOPERATION_CONFIRMATION.equals(confirmation == null ? "" : confirmation.trim())) {
log.warn("会话[{}]误操作结束的确认语不正确", sessionNum);
throw new ErrorParameterException("确认语输入不正确,请核对后重新输入");
}
long fragmentCount = studyReportFragmentsMapper.selectCount(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum));
if (fragmentCount > 0) {
log.warn("会话[{}]已产生{}条学习残片,不能误操作结束", sessionNum, fragmentCount);
throw new ErrorParameterException("这次学习已经产生了学习残片,不能按误操作结束了哦");
}
// 严格零数据:学习预期与会话本身一并删除,系统视角下会话从未发生
studyExpectationsService.deleteBySessionNum(sessionNum);
this.removeById(studySessionsEntity.getId());
log.info("会话[{}]误操作结束,未产生任何学习数据", sessionNum);
}
@Override
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskNum, taskNum))
.orElseThrow(() -> taskNotFound(taskNum));
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
.orElseThrow(() -> {
log.warn("任务[{}]不存在进行中或暂停中的会话", taskNum);
return new ErrorParameterException("这个任务当前没有进行中或已暂停的学习会话");
});
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
}
@@ -118,32 +168,144 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException {
if (this.exists(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))) {
// todo guo 后续可在此补充更高级的整理方式
return studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
} else {
throw new ErrorParameterException("会话[" + sessionNum + "]不存在");
throw sessionNotFound(sessionNum);
}
}
@Override
@Transactional
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
.orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
throw new ErrorParameterException("会话[" + sessionNum + "]已经开始,请勿重复开始");
throw new ErrorParameterException("这次学习会话已经开始了,不用重复开始");
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
throw new ErrorParameterException("会话[" + sessionNum + "]处于结束状态,不可开始该会话!");
throw new ErrorParameterException("这次学习会话已经结束,无法再次开始");
}
studySessionsEntity.continueStudySession();
this.updateById(studySessionsEntity);
}
/**
* 通过 sessionNum 获取学习会话
*/
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> sessionNotFound(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(() -> sessionNotFound(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;
}
private ErrorParameterException sessionNotFound(String sessionNum) {
log.warn("会话[{}]不存在", sessionNum);
return new ErrorParameterException("这次学习会话不存在或已结束");
}
private ErrorParameterException taskNotFound(String taskNum) {
log.warn("任务[{}]不存在", taskNum);
return new ErrorParameterException("这个任务不存在或已被删除");
}
}
@@ -5,12 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.dto.PriorityDto;
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.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
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.mapper.TasksMapper;
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
@@ -18,44 +25,48 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.rmi.ServerException;
import java.util.List;
import java.util.Optional;
/**
* @author guo
* @description 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
implements TasksService{
implements TasksService {
private final TasksMapper tasksMapper;
private final TaskApplicationMapper taskApplicationMapper;
private final PriorityWeightsService priorityWeightsService;
@Override
public Page<TaskInfo> taskList(Integer pageNum, Integer 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);
// todo 需要补充TaskInfo中的lastLearningStatus字段信息
return response;
}
@Override
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)
.eq(TaskEntity::getTaskName, task.getTaskName()))) {
throw new ErrorParameterException(String.format("任务名[%s]重复", task.getTaskName()));
log.warn("任务名[{}]重复", task.getTaskName());
throw new ErrorParameterException("已经有同名任务了,换个任务名称吧");
}
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest);
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto));
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
this.save(task);
return task.getTaskNum();
}
@@ -63,13 +74,15 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
@Override
public TaskInfoResponse getTask(String taskId) {
TaskEntity taskEntity = this.getById(taskId);
TaskInfoResponse taskInfoResponse = TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity);
return taskInfoResponse;
return TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity);
}
@Override
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);
int id;
try {
@@ -78,21 +91,79 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
}
taskEntity.setId(id);
boolean updated = this.updateById(taskEntity);
if (!updated) {
throw new IllegalArgumentException("任务不存在或更新失败: " + taskId);
}
taskEntity.setTaskNum(existingTask.getTaskNum());
// 维度数据可能变化,更新时重算优先级
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
this.updateById(taskEntity);
}
@Override
public void deleteTask(String taskId) throws ServerException {
boolean b = this.removeById(taskId);
if(!b){
throw new ServerException("未能正常删除任务");
public void deleteTask(String taskId) {
TaskEntity existingTask = this.getById(taskId);
if (existingTask == null) {
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(() -> {
log.warn("应用场景[{}]不存在", id);
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
});
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(() -> {
log.warn("应用场景[{}]不存在", id);
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
});
taskApplicationMapper.deleteById(existing.getId());
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
}
}
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.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt;
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 {
UserEntity userEntity = this.getOneOpt(Wrappers.<UserEntity>lambdaQuery()
.eq(UserEntity::getUserName, username)
.eq(UserEntity::getUserPassword, password)).orElseThrow(() -> new AppException("账号或密码错误!"));
return userEntity.getId();
.eq(UserEntity::getUserName, username))
.orElseThrow(() -> new AppException("账号或密码错误!"));
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;
import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
/**
* 加权优先级计算工具
*
* @author guo
*/
public class CalculatedPriorityTool {
/**
* 通过用户选择的5个选项,结合定义的权重计算任务的加权优先级。
* 急迫性 (Urgency)
* 急迫性代表任务的紧急程度。权重:0.35
* <p>
* 重要性 (Importance)
* 重要性指示任务的重要程度。权重:0.25
* <p>
* 内容难度 (Content Difficulty)
* 内容难度衡量任务内容的复杂性。权重:0.20
* <p>
* 未来价值 (Future Value)
* 未来价值估计完成任务的长期价值。权重:0.10
* <p>
* 主观优先级 (Subjective Priority)
* 主观优先级是用户对任务优先级的个人评估。权重:0.10
*
* @param priorityDto
* @return
* 使用系统默认权重计算加权优先级。
* 紧急性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
*/
public static Double calculatedPriority(PriorityDto priorityDto) {
// 暂时写死权重,也许可以考虑让用户自行配置权重
return (priorityDto.getUrgency() * 0.35) +
(priorityDto.getImportance() * 0.25) +
(priorityDto.getContentDifficulty() * 0.20) +
(priorityDto.getFutureValue() * 0.10) +
(priorityDto.getSubjectivePriority() * 0.10);
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
}
/**
* 使用用户自定义权重计算加权优先级。
*/
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
public class GenerateNumTool {
private static int getNextSequence() {
// 获取当前时间的毫秒级时间戳
long timestamp = System.currentTimeMillis();
// 使用时间戳的最后6位作为序列号
return (int) (timestamp % 1_000_000);
}
@@ -29,22 +24,14 @@ public class GenerateNumTool {
*/
public static String generateNum(String prefix, String separator) {
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 唯一的后缀
int sequence = getNextSequence();
// 返回完整的 taskNum
return prefix + separator + currentDate + separator + sequence;
}
public static String generateNum(String prefix) {
String separator = "-";
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 唯一的后缀
int sequence = getNextSequence();
// 返回完整的 taskNum
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:
web:
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;
import cn.dev33.satoken.stp.StpUtil;
import com.guo.learningprogresstracker.entity.TestTableEntity;
import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -19,6 +21,11 @@ public class DataSourceTest {
@Autowired
TestTableServiceImpl testTableService;
@BeforeEach
void setUp() {
StpUtil.login("1", "test_driver");
}
@Test
public void testAdd(){
TestTableEntity testTableEntity = new TestTableEntity();
@@ -30,8 +37,12 @@ public class DataSourceTest {
@Test
public void testDelete(){
TestTableEntity testTableEntity = new TestTableEntity();
testTableEntity.setIdName("guo_test_delete");
testTableService.save(testTableEntity);
HashMap<String, Object> stringStringHashMap = new HashMap<>();
stringStringHashMap.put("id_name", "guo_test");
stringStringHashMap.put("id_name", "guo_test_delete");
boolean delete = testTableService.removeByMap(stringStringHashMap);
assertTrue(delete,"删除idName为guo_test的数据失败");

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