27 Commits

Author SHA1 Message Date
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
48 changed files with 1277 additions and 441 deletions
+47
View File
@@ -0,0 +1,47 @@
# 项目规范
## 数据库迁移(Flyway Migration
### 命名规则
- 脚本格式:`V{YYYYMMDD}_{序号}__{描述}.sql`
- 日期必须使用**实际编写日期**,不得使用过去的日期
- 序号从 1 开始,同一天多个脚本递增
- 描述使用下划线分隔的英文短语
### 核心原则
- **不可变性**:已执行的迁移脚本永远不得修改
- **只增不减**:数据库变更只能通过新增迁移脚本实现
- **向后兼容**:新脚本应兼容已有数据
### 操作规范
- 删除表:创建新迁移脚本,使用 `DROP TABLE IF EXISTS`
- 修改表结构:使用 `ALTER TABLE` 语句
- 新增表:创建新迁移脚本,使用 `CREATE TABLE`
## 框架机制
### 行级数据隔离(多租户拦截器)
- **配置类:** `MybatisPlusConfig` 注册 `TenantLineInnerInterceptor`
- **租户字段:** `created_by`(每个业务表的创建人字段)
- **租户值来源:** `StpUtil.getLoginIdAsString()`(当前登录用户)
- **自动注入:** 所有 SELECT/UPDATE/DELETE 语句自动追加 `WHERE created_by = #{当前用户}`
- **归属校验:** 查询单条记录时拦截器自动校验 `created_by`,非本人数据直接返回空
- **排除表:** `user``flyway_schema_history``databasechangelog``databasechangeloglock`
### 自动填充(MetaObjectHandler
- **created_by / updated_by** 插入/更新时自动填充为当前登录用户
- **created_time / updated_time** 插入/更新时自动填充当前时间
### 认证鉴权(Sa-Token
- **会话管理:** 基于 `StpUtil` 的登录/登出/会话查询
- **权限校验:** `@SaCheckPermission` 注解式权限控制
- **未登录处理:** 全局异常处理器捕获 `NotLoginException` 返回 401
## 编码规范
### 数据访问层
- **优先使用纯 MyBatis-Plus Java API**`Wrappers.<T>lambdaQuery()``selectList``selectById` 等),避免手写 XML SQL
- 复杂查询通过 `Wrappers` 链式构建条件,必要时使用 `.apply()` 拼接原生 SQL 片段
Vendored Regular → Executable
View File
+8 -1
View File
@@ -129,6 +129,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 +149,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 注释。
@@ -15,41 +15,33 @@ 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
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({NotFindEntitiesException.class})
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
//code:400
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({AppException.class})
public CommonResult AppException(AppException ex) {
//code:500
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 +49,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public CommonResult Exception(Exception ex) {
log.error("系统异常", ex);
//code:400
return CommonResult.error(ex.getMessage());
}
}
}
@@ -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,54 @@
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.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();
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;
}
}
@@ -13,9 +13,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
/**
* @author Administrator
*/
@Profile("dev")
@Configuration
@Slf4j
@@ -24,11 +21,9 @@ public class WebMvcDevConfig 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;
}
@@ -30,10 +30,8 @@ public class WebMvcProdConfig implements WebMvcConfigurer {
.allowCredentials(true);
}
// 注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/login");
@@ -9,8 +9,6 @@ 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'");
public LocalDateTimeSerializer() {
@@ -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,6 +1,7 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
@@ -32,6 +33,22 @@ public class ReviewController {
return CommonResult.success(reviewService.getReviewFeed(limit));
}
/**
* 获取任务维度的复习统计汇总
*/
@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));
}
/**
* 获取指定任务下的所有报告和残片
*/
@@ -1,13 +1,10 @@
package com.guo.learningprogresstracker.controller;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapStruct.StudySessionConvert;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
@@ -15,8 +12,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;
/**
* 学习会话控制层
@@ -39,11 +36,9 @@ public class StudySessionController {
*/
@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);
// 通过 service 层获取,包含归属校验
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
return CommonResult.success(response);
}
/**
@@ -72,8 +67,8 @@ 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());
return CommonResult.success();
String message = studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
return message != null ? CommonResult.success(message) : CommonResult.success();
}
/**
@@ -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);
}
}
@@ -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 {
@@ -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;
}
@@ -1,39 +0,0 @@
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 java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 存储每次学习开始前的预期
* @TableName 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 = "description")
private String description;
@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());
}
@@ -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;
}
@@ -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);
}
@@ -1,20 +0,0 @@
package com.guo.learningprogresstracker.mapper;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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> {
}
@@ -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> {
}
@@ -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;
@@ -17,6 +18,16 @@ public interface ReviewService {
*/
List<ReviewFeedItem> getReviewFeed(int limit);
/**
* 获取所有任务的复习统计数据
*/
List<ReviewTaskStats> getReviewTaskStats();
/**
* 获取指定任务的复习统计数据
*/
ReviewTaskStats getReviewTaskStats(String taskNum);
/**
* 获取指定任务下的所有报告和残片
*/
@@ -1,13 +0,0 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* @author guo
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service
* @createDate 2024-06-09 15:28:48
*/
public interface StudyExpectationsService extends IService<StudyExpectationsEntity> {
}
@@ -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);
}
@@ -20,7 +20,7 @@ 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 pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
@@ -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> {
}
@@ -1,18 +1,28 @@
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.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.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.service.ReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import 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 实现
@@ -21,18 +31,80 @@ import java.util.Optional;
@RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService {
private final ReviewMapper reviewMapper;
private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
@Override
public List<ReviewFeedItem> getReviewFeed(int limit) {
return reviewMapper.selectReviewFeed(limit);
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.orderByDesc(StudyReportsEntity::getCreatedTime)
.last("LIMIT " + limit));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
.last("LIMIT " + limit));
return mergeAndConvert(reports, fragments);
}
@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
@@ -46,4 +118,168 @@ public class ReviewServiceImpl implements ReviewService {
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
}
/**
* 将报告和残片合并转换为 ReviewFeedItem 列表,按创建时间倒序排列
*/
private List<ReviewFeedItem> mergeAndConvert(List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments) {
Set<String> allSessionNums = Stream.concat(
reports.stream().map(StudyReportsEntity::getSessionNum),
fragments.stream().map(StudyReportFragmentsEntity::getSessionNum)
).collect(Collectors.toSet());
if (allSessionNums.isEmpty()) {
return Collections.emptyList();
}
Map<String, String> sessionToTaskMap = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.in(StudySessionsEntity::getSessionNum, allSessionNums)
.select(StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum))
.stream()
.collect(Collectors.toMap(
StudySessionsEntity::getSessionNum,
StudySessionsEntity::getTaskNum,
(a, b) -> a));
Set<String> taskNums = new HashSet<>(sessionToTaskMap.values());
Map<String, String> taskNameMap = tasksMapper.selectList(
Wrappers.<TaskEntity>lambdaQuery()
.in(TaskEntity::getTaskNum, taskNums)
.select(TaskEntity::getTaskNum, TaskEntity::getTaskName))
.stream()
.collect(Collectors.toMap(
TaskEntity::getTaskNum,
TaskEntity::getTaskName,
(a, b) -> a));
List<ReviewFeedItem> reportItems = reports.stream().map(r -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(r.getId());
item.setSessionNum(r.getSessionNum());
item.setSourceType("REPORT");
item.setContent(r.getContent());
item.setCreatedTime(r.getCreatedTime());
String taskNum = sessionToTaskMap.get(r.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
List<ReviewFeedItem> fragmentItems = fragments.stream().map(f -> {
ReviewFeedItem item = new ReviewFeedItem();
item.setId(f.getId());
item.setSessionNum(f.getSessionNum());
item.setSourceType("FRAGMENT");
item.setContent(f.getContent());
item.setCreatedTime(f.getCreatedTime());
String taskNum = sessionToTaskMap.get(f.getSessionNum());
item.setTaskNum(taskNum);
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
return item;
}).collect(Collectors.toList());
return Stream.concat(reportItems.stream(), fragmentItems.stream())
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());
}
private ReviewTaskStats newTaskStats(TaskEntity task) {
return newTaskStats(task.getTaskNum(), task.getTaskName());
}
private ReviewTaskStats newTaskStats(String taskNum, String taskName) {
ReviewTaskStats stats = new ReviewTaskStats();
stats.setTaskNum(taskNum);
stats.setTaskName(taskName);
return stats;
}
private 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);
}
}
}
}
@@ -1,22 +0,0 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
import org.springframework.stereotype.Service;
/**
* @author guo
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
@Service
public class StudyExpectationsServiceImpl extends ServiceImpl<StudyExpectationsMapper, StudyExpectationsEntity>
implements StudyExpectationsService{
}
@@ -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;
@@ -13,30 +14,46 @@ import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author guo
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
* @createDate 2024-06-09 15:28:48
*/
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
*/
@Service
@RequiredArgsConstructor
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
implements StudyReportFragmentsService{
implements StudyReportFragmentsService {
private final StudySessionsMapper studySessionsMapper;
@Override
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) {
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]", entity.getSessionNum()));
}
this.save(entity);
}
@Override
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity existing = this.getById(id);
if (existing == null) {
throw new NotFindEntitiesException(String.format("未能找到学习残片id[%d]", id));
}
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
entity.setId(id);
this.updateById(entity);
}
@Override
public List<StudyReportFragmentsEntity> getFragmentsBySession(String sessionNum) {
return this.list(
Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum)
.orderByAsc(StudyReportFragmentsEntity::getCreatedTime));
}
}
@@ -27,9 +27,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,6 +36,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
implements StudySessionsService {
public static final int WORK_DURATION = 25;
private final TasksServiceImpl tasksServiceImpl;
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
@@ -94,21 +93,28 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
@Override
public void endedStudySession(String sessionNum, String content) throws ErrorParameterException {
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 + "]不存在"));
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
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskNum, taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在"));
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
@@ -118,7 +124,6 @@ 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));
@@ -142,8 +147,15 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
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(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
}
}
@@ -19,34 +19,29 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.rmi.ServerException;
/**
* @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;
@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()))) {
@@ -63,13 +58,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 +75,15 @@ 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);
}
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);
}
}
@@ -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{
}
@@ -4,8 +4,6 @@ import com.guo.learningprogresstracker.dto.PriorityDto;
/**
* 加权优先级计算工具
*
* @author guo
*/
public class CalculatedPriorityTool {
@@ -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,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哈希)';
@@ -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>
@@ -0,0 +1,301 @@
package com.guo.learningprogresstracker.entity;
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class StudySessionsEntityTest {
private StudySessionsEntity createOngoingSession(int minutesAgo) {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_TEST");
session.setStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setLastStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setEffectiveTime(0);
session.setActualTime(0);
session.setSessionState(StudySessionStateEnum.ONGOING.name());
return session;
}
/**
* 场景1:直接结束,有效时间 < 10分钟 → effectiveTime 应被清零
*/
@Test
void endedSession_effectiveTimeLessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(5);
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "有效时间不足10分钟应被清零");
assertEquals(0, session.getEffectivenessRatio(), "有效时间比应被清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景2:直接结束,有效时间 = 10分钟(边界值)→ 保留(600 不 < 600
*/
@Test
void endedSession_effectiveTimeExactly10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(10);
session.endedStudySession();
assertTrue(session.getEffectiveTime() >= 10 * 60, "有效时间等于10分钟应保留(阈值为 < 600)");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景3:直接结束,有效时间 > 10分钟 → effectiveTime 应保留
*/
@Test
void endedSession_effectiveTimeMoreThan10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(15);
session.endedStudySession();
assertTrue(session.getEffectiveTime() >= 15 * 60, "有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景4:暂停后直接结束(未继续),暂停段 < 10分钟 → 清零
*/
@Test
void endedSession_pausedThenEnded_lessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(5);
session.pausedStudySession(LocalDateTime.now());
assertEquals(StudySessionStateEnum.PAUSED.name(), session.getSessionState());
assertTrue(session.getEffectiveTime() > 0, "暂停后应有有效时间");
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "暂停后结束,有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景5:暂停后继续,再结束,总有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_pausedContinuedThenEnded_lessThan10Min_shouldZeroOut() throws Exception {
StudySessionsEntity session = createOngoingSession(8);
// 暂停(累加3分钟:从8分钟前到5分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(5));
// 继续学习,模拟又学习了2分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(2));
session.endedStudySession();
// 总有效时间 ≈ 3+2 = 5分钟 < 10分钟 → 清零
assertEquals(0, session.getEffectiveTime(), "总有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景6:暂停后继续,再结束,总有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_pausedContinuedThenEnded_moreThan10Min_shouldKeep() throws Exception {
StudySessionsEntity session = createOngoingSession(20);
// 暂停(累加8分钟:从20分钟前到12分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
// 继续学习,模拟又学习了12分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(12));
session.endedStudySession();
// 总有效时间 ≈ 8+12 = 20分钟 > 10分钟 → 保留
assertTrue(session.getEffectiveTime() > 0, "总有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景7:多次暂停-继续,总有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_multiplePauseContinue_lessThan10Min_shouldZeroOut() throws Exception {
StudySessionsEntity session = createOngoingSession(15);
// 第一段:3分钟(15分钟前 → 12分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
// 继续,模拟第二段学习3分钟(12分钟前 → 9分钟前)
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(9));
// 第二段暂停,累加3分钟
session.pausedStudySession(LocalDateTime.now().minusMinutes(6));
// 继续,模拟第三段学习3分钟(6分钟前 → 3分钟前)
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(3));
// 总计 3+3+3 = 9分钟 < 10分钟
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "多次暂停继续,总有效时间不足10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景8:多次暂停-继续,总有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_multiplePauseContinue_moreThan10Min_shouldKeep() throws Exception {
StudySessionsEntity session = createOngoingSession(25);
// 第一段:5分钟(25分钟前 → 20分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(20));
// 继续,模拟第二段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(15));
// 第二段暂停,累加5分钟
session.pausedStudySession(LocalDateTime.now().minusMinutes(10));
// 继续,模拟第三段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
// 总计 5+5+5 = 15分钟 > 10分钟
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "多次暂停继续,总有效时间超过10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景9:已结束的会话再次结束 → 不应重复处理
*/
@Test
void endedSession_alreadyEnded_shouldNotChange() {
StudySessionsEntity session = createOngoingSession(5);
session.endedStudySession();
double effectiveTimeAfterFirstEnd = session.getEffectiveTime();
session.endedStudySession();
assertEquals(effectiveTimeAfterFirstEnd, session.getEffectiveTime(), "已结束的会话不应重复处理");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景10:有效时间非常短(1分钟) → 清零
*/
@Test
void endedSession_veryShortSession_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(1);
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "极短会话的有效时间应被清零");
assertEquals(0, session.getEffectivenessRatio());
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景11:有效时间恰好超过阈值(11分钟) → 保留
*/
@Test
void endedSession_justOverThreshold_shouldKeep() {
StudySessionsEntity session = createOngoingSession(11);
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "超过阈值的有效时间应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景12:暂停期间不应计入有效时间,只计实际学习的段
*/
@Test
void endedSession_pauseTimeNotCounted_shouldCalcCorrectly() throws Exception {
StudySessionsEntity session = createOngoingSession(30);
// 学习5分钟(30分钟前 → 25分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(25));
double firstSegment = session.getEffectiveTime();
assertTrue(firstSegment >= 5 * 60 - 1 && firstSegment <= 5 * 60 + 1,
"第一段有效时间应约5分钟,实际: " + firstSegment);
// 暂停20分钟(不应计入有效时间)
// 继续学习4分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(4));
session.endedStudySession();
// 有效时间应约为 5+4=9分钟 < 10分钟 → 清零
assertEquals(0, session.getEffectiveTime(), "暂停期间不应计入有效时间,9分钟 < 10分钟阈值应清零");
}
/**
* 场景13:暂停后继续,有效时间恰好在阈值边界 → 验证边界精度
*/
@Test
void endedSession_borderlineWithPause_shouldHandleCorrectly() throws Exception {
StudySessionsEntity session = createOngoingSession(20);
// 第一段:6分钟(20分钟前 → 14分钟前)
session.pausedStudySession(LocalDateTime.now().minusMinutes(14));
// 继续,模拟第二段学习5分钟
session.continueStudySession();
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
// 总计 6+5 = 11分钟 > 10分钟 → 保留
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "总计11分钟应超过阈值被保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景14ONGOING 状态直接结束,effectiveTime 为 0(刚开始就结束) → 0 < 600 → 清零
*/
@Test
void endedSession_immediatelyEnded_shouldZeroOut() {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_INSTANT");
session.setStartTime(LocalDateTime.now());
session.setLastStartTime(LocalDateTime.now());
session.setEffectiveTime(0);
session.setActualTime(0);
session.setSessionState(StudySessionStateEnum.ONGOING.name());
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "刚开始就结束,有效时间应为0");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景15:暂停后直接结束(PAUSED 状态),有效时间 < 10分钟 → 清零
*/
@Test
void endedSession_pausedThenDirectlyEnded_lessThan10Min_shouldZeroOut() {
StudySessionsEntity session = createOngoingSession(7);
// 暂停(累加7分钟有效时间)
session.pausedStudySession(LocalDateTime.now());
session.setEffectiveTime(7 * 60); // 精确设置为7分钟
// PAUSED 状态直接结束(不经过 ONGOING)
session.endedStudySession();
assertEquals(0, session.getEffectiveTime(), "PAUSED 状态结束,7分钟 < 10分钟应清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景16:暂停后直接结束(PAUSED 状态),有效时间 > 10分钟 → 保留
*/
@Test
void endedSession_pausedThenDirectlyEnded_moreThan10Min_shouldKeep() {
StudySessionsEntity session = createOngoingSession(15);
// 暂停(累加15分钟有效时间)
session.pausedStudySession(LocalDateTime.now());
// PAUSED 状态直接结束
session.endedStudySession();
assertTrue(session.getEffectiveTime() > 0, "PAUSED 状态结束,15分钟 > 10分钟应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
}
@@ -0,0 +1,213 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class StudySessionsServiceImplTest {
@Spy
@InjectMocks
private StudySessionsServiceImpl studySessionsService;
@Mock
private StudyReportsMapper studyReportsMapper;
@Mock
private StudyReportFragmentsMapper studyReportFragmentsMapper;
@Mock
private StudySessionsMapper studySessionsMapper;
@BeforeEach
void setUp() {
// stub updateById,避免调用真实的 getBaseMapper()
doReturn(true).when(studySessionsService).updateById(any());
}
private StudySessionsEntity createSession(String state, int minutesAgo) {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_TEST");
session.setSessionState(state);
session.setStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setLastStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
session.setEffectiveTime(0);
session.setActualTime(0);
return session;
}
private void mockGetOneOpt(StudySessionsEntity session) {
doReturn(Optional.of(session)).when(studySessionsService).getOneOpt(any(Wrapper.class));
}
/**
* 场景1ONGOING 状态,有效时间 < 10分钟 → 返回提示消息
*/
@Test
void endedStudySession_ongoing_lessThan10Min_shouldReturnMessage() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertEquals("本次有效学习时间不足10分钟,不计入总学习时间", message);
assertEquals(0, session.getEffectiveTime(), "effectiveTime 应被清零");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
verify(studyReportsMapper).insert(any(StudyReportsEntity.class));
}
/**
* 场景2ONGOING 状态,有效时间 > 10分钟 → 返回 null(正常结束)
*/
@Test
void endedStudySession_ongoing_moreThan10Min_shouldReturnNull() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 15);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertNull(message, "有效时间超过10分钟不应返回提示");
assertTrue(session.getEffectiveTime() > 0, "effectiveTime 应保留");
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
}
/**
* 场景3PAUSED 状态,有效时间 < 10分钟 → 返回 nullPAUSED 不触发提示)
*/
@Test
void endedStudySession_paused_lessThan10Min_shouldReturnNull() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.PAUSED.name(), 5);
session.setEffectiveTime(5 * 60);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertNull(message, "PAUSED 状态结束不返回提示(用户已主动暂停)");
assertEquals(0, session.getEffectiveTime(), "effectiveTime 仍应被实体层清零");
}
/**
* 场景4PAUSED 状态,有效时间 > 10分钟 → 返回 nulleffectiveTime 保留
*/
@Test
void endedStudySession_paused_moreThan10Min_shouldReturnNull() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.PAUSED.name(), 15);
session.setEffectiveTime(15 * 60);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertNull(message);
assertTrue(session.getEffectiveTime() > 0, "effectiveTime 应保留");
}
/**
* 场景5:无论是否触发清零,学习报告都应正常创建
*/
@Test
void endedStudySession_shouldAlwaysCreateReport() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 3);
mockGetOneOpt(session);
studySessionsService.endedStudySession("SESSION_TEST", "测试报告内容");
verify(studyReportsMapper).insert(argThat(report ->
"SESSION_TEST".equals(((StudyReportsEntity) report).getSessionNum())
&& "测试报告内容".equals(((StudyReportsEntity) report).getContent())
));
}
/**
* 场景6:ONGOING 状态,有效时间恰好 = 10分钟 → 有效时间保留(600 不 < 600
*/
@Test
void endedStudySession_ongoing_exactly10Min_shouldReturnNull() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 10);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertNull(message, "有效时间等于10分钟应保留(阈值为 < 600)");
assertTrue(session.getEffectiveTime() >= 10 * 60, "effectiveTime 应保留");
}
/**
* 场景7:会话不存在 → 抛出异常
*/
@Test
void endedStudySession_sessionNotFound_shouldThrowException() {
doReturn(Optional.empty()).when(studySessionsService).getOneOpt(any(Wrapper.class));
assertThrows(Exception.class, () ->
studySessionsService.endedStudySession("NOT_EXIST", "内容"));
}
/**
* 场景8:ONGOING 状态,有效时间极短(1分钟) → 返回提示消息
*/
@Test
void endedStudySession_ongoing_veryShortTime_shouldReturnMessage() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 1);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_TEST", "学习内容");
assertEquals("本次有效学习时间不足10分钟,不计入总学习时间", message);
assertEquals(0, session.getEffectiveTime());
}
/**
* 场景9:ONGOING 状态,刚开始就结束(effectiveTime ≈ 0 → 返回提示消息
*/
@Test
void endedStudySession_ongoing_immediatelyEnded_shouldReturnMessage() throws Exception {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_INSTANT");
session.setSessionState(StudySessionStateEnum.ONGOING.name());
session.setStartTime(LocalDateTime.now());
session.setLastStartTime(LocalDateTime.now());
session.setEffectiveTime(0);
session.setActualTime(0);
mockGetOneOpt(session);
String message = studySessionsService.endedStudySession("SESSION_INSTANT", "内容");
assertEquals("本次有效学习时间不足10分钟,不计入总学习时间", message);
assertEquals(0, session.getEffectiveTime());
}
/**
* 场景10:确认 updateById 被调用(会话状态持久化)
*/
@Test
void endedStudySession_shouldCallUpdateById() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
mockGetOneOpt(session);
studySessionsService.endedStudySession("SESSION_TEST", "内容");
verify(studySessionsService).updateById(session);
}
}
@@ -0,0 +1,24 @@
package com.guo.learningprogresstracker.utils;
import org.junit.jupiter.api.Test;
import org.mindrot.jbcrypt.BCrypt;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BCryptPasswordGeneratorTest {
@Test
void generatePasswordHash() {
String password = System.getProperty("password", "admin");
int rounds = Integer.parseInt(System.getProperty("rounds", "10"));
String hash = BCrypt.hashpw(password, BCrypt.gensalt(rounds));
System.out.println("BCrypt hash:");
System.out.println(hash);
System.out.println("SQL:");
System.out.printf("UPDATE `user` SET `user_password` = '%s' WHERE `user_name` = 'admin';%n", hash);
assertTrue(BCrypt.checkpw(password, hash));
}
}