diff --git a/sql/mysql/education/002-education-practice-session-rollback.sql b/sql/mysql/education/002-education-practice-session-rollback.sql new file mode 100644 index 00000000..01233e9d --- /dev/null +++ b/sql/mysql/education/002-education-practice-session-rollback.sql @@ -0,0 +1,28 @@ +-- ============================================= +-- Education 模块 — 练习会话与题目快照回滚 +-- Ticket #6 / Migration 002 +-- ============================================= +-- +-- WARNING: This file contains NO executable SQL. +-- Destructive rollback (DROP TABLE) requires manual operator verification. +-- +-- Manual rollback procedure (operator must execute): +-- 1. Verify no other tables depend on these tables: +-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME +-- FROM information_schema.KEY_COLUMN_USAGE +-- WHERE REFERENCED_TABLE_NAME IN ('education_practice_session', 'education_practice_question') +-- AND TABLE_SCHEMA = DATABASE(); +-- Result MUST be empty before proceeding. +-- +-- 2. Verify the tables contain only data from this migration: +-- SELECT COUNT(*) AS session_count FROM education_practice_session; +-- SELECT COUNT(*) AS question_count FROM education_practice_question; +-- Operator must confirm these counts are acceptable to destroy. +-- +-- 3. After verification, execute: +-- DROP TABLE IF EXISTS education_practice_question; +-- DROP TABLE IF EXISTS education_practice_session; +-- +-- DO NOT uncomment or execute the lines below without operator verification. +-- -- DROP TABLE IF EXISTS education_practice_question; +-- -- DROP TABLE IF EXISTS education_practice_session; diff --git a/sql/mysql/education/002-education-practice-session.sql b/sql/mysql/education/002-education-practice-session.sql new file mode 100644 index 00000000..5ef104ba --- /dev/null +++ b/sql/mysql/education/002-education-practice-session.sql @@ -0,0 +1,101 @@ +-- ============================================= +-- Education 模块 — 练习会话与题目快照 DDL +-- Ticket #6: 练习会话创建、题目快照、恢复与状态机 +-- Migration: 002 +-- Prerequisites: 000-education-schema.sql (database creation) +-- 001-education-tenant-seed.sql (tenant seed data) +-- ============================================= + +-- ============================================= +-- Preconditions +-- ============================================= +-- This migration MUST fail if either table already exists (no IF NOT EXISTS). +-- Operator is expected to verify: +-- SELECT COUNT(*) FROM information_schema.tables +-- WHERE table_schema = DATABASE() +-- AND table_name IN ('education_practice_session', 'education_practice_question'); +-- Result MUST be 0 before executing this migration. + +-- ============================================= +-- 练习会话表 +-- ============================================= +-- Indexes: +-- uk_tenant_client_session — per-tenant uniqueness for clientSessionId idempotency. +-- Used by: selectByTenantAndClientSessionId (idempotent create check), +-- DuplicateKeyException catch for concurrent-create race resolution. +-- idx_tenant_user_status — covers getCurrentSession (latest ACTIVE by tenant+user) +-- and ownership queries. Column order: (tenant_id, user_id, status) so the +-- index supports both filtering by tenant+user and tenant+user+status. +-- Lock impact: INSERT acquires next-key lock on uk_tenant_client_session unique key; +-- concurrent inserts with same (tenant_id, client_session_id) serialize naturally. +-- No additional table-level locks required. +CREATE TABLE `education_practice_session` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话主键', + `tenant_id` BIGINT NOT NULL COMMENT '租户编号', + `user_id` BIGINT NOT NULL COMMENT 'Member 用户编号', + `client_session_id` VARCHAR(36) NOT NULL COMMENT '客户端生成的会话标识(UUID),用于幂等创建', + `status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + COMMENT '会话状态:ACTIVE-进行中, SUBMITTED-已提交, EXPIRED-已过期, CANCELLED-已取消', + `question_count` INT NOT NULL DEFAULT 0 COMMENT '题目总数', + `collection_id` VARCHAR(64) DEFAULT NULL COMMENT '源题集 ID', + `node_id` VARCHAR(64) DEFAULT NULL COMMENT '源目录节点 ID', + `type` VARCHAR(32) DEFAULT NULL COMMENT '筛选题型', + `difficulty` VARCHAR(32) DEFAULT NULL COMMENT '筛选难度', + `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tenant_client_session` (`tenant_id`, `client_session_id`), + KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话'; + +-- ============================================= +-- 练习会话题目快照表 +-- ============================================= +-- Indexes: +-- uk_session_sequence — per-session uniqueness for question sequence numbers. +-- Used by: insertBatch to ensure no duplicate sequences within a session. +-- idx_session_id — covers selectBySessionIdOrderBySequence (load all questions +-- for a session, ordered by sequence). Also used by cascade delete lookups. +-- Lock impact: INSERT acquires gap locks within session_id range on uk_session_sequence; +-- concurrent inserts into different sessions are independent. +-- Options column: JSON data type stores only label, content, order — never isCorrect. +-- Application layer (optionsToSafeJson) strips correctness before storage. +CREATE TABLE `education_practice_question` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` BIGINT NOT NULL COMMENT '租户编号', + `session_id` BIGINT NOT NULL COMMENT '会话 ID', + `sequence` INT NOT NULL COMMENT '题目序号(1-based,服务端固定)', + `question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID', + `content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '快照时的题目内容版本', + `stem` TEXT NOT NULL COMMENT '题干快照', + `type` VARCHAR(32) NOT NULL COMMENT '题型快照', + `difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照', + `options` JSON NOT NULL COMMENT '选项快照 JSON(不含 isCorrect)', + `selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案', + `is_answered` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否已作答', + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_session_sequence` (`session_id`, `sequence`), + KEY `idx_session_id` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话题目快照'; + +-- ============================================= +-- Post-migration verification queries +-- ============================================= +-- Verify tables exist with correct structure: +-- SHOW CREATE TABLE education_practice_session; +-- SHOW CREATE TABLE education_practice_question; +-- Verify unique keys are enforced: +-- SHOW INDEX FROM education_practice_session WHERE Key_name = 'uk_tenant_client_session'; +-- SHOW INDEX FROM education_practice_question WHERE Key_name = 'uk_session_sequence'; +-- Verify no orphan data (should be 0 after fresh migration): +-- SELECT COUNT(*) FROM education_practice_session; +-- SELECT COUNT(*) FROM education_practice_question; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java new file mode 100644 index 00000000..686f46d1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.education.controller.app.practice; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO; +import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 练习会话 Controller — 学生端已认证接口。 + * + *

所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。

+ *

会话和答题数据写入 MySQL,从不经过 Scalar。

+ * + * @author 恭学教育 + */ +@Tag(name = "用户 APP - 练习会话") +@RestController +@RequestMapping("/education") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class PracticeSessionController { + + @Resource + private PracticeSessionService practiceSessionService; + + // ========== 会话管理 ========== + + @PostMapping("/practice-session/create") + @Operation(summary = "创建练习会话(幂等)", + description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。" + + "题目顺序由服务端固定,选项不含答案标记。") + public CommonResult createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) { + Long userId = getUserId(); + Long tenantId = getTenantId(); + PracticeSessionRespVO resp = practiceSessionService.createPracticeSession(reqVO, userId, tenantId); + return success(resp); + } + + @GetMapping("/practice-session/current") + @Operation(summary = "获取当前进行中的练习会话", + description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。") + public CommonResult currentSession() { + Long userId = getUserId(); + Long tenantId = getTenantId(); + PracticeSessionRespVO resp = practiceSessionService.getCurrentSession(userId, tenantId); + return success(resp); + } + + @GetMapping("/practice-session/get") + @Operation(summary = "获取指定练习会话", + description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。") + public CommonResult getSession( + @Parameter(description = "会话 ID", required = true) @RequestParam Long id) { + Long userId = getUserId(); + Long tenantId = getTenantId(); + PracticeSessionRespVO resp = practiceSessionService.getSession(id, userId, tenantId); + return success(resp); + } + + // ========== security helpers ========== + + private Long getUserId() { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (userId == null) { + throw exception(UNAUTHORIZED); + } + return userId; + } + + private Long getTenantId() { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null || loginUser.getTenantId() == null) { + throw exception(UNAUTHORIZED); + } + return loginUser.getTenantId(); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java new file mode 100644 index 00000000..0b33ed41 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.education.controller.app.practice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 练习会话题目响应 VO — 安全视图,不含答案/解析。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 练习会话题目响应") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PracticeQuestionRespVO { + + @Schema(description = "题目序号(1-based)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sequence; + + @Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001") + private String questionId; + + @Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几?") + private String stem; + + @Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice") + private String type; + + @Schema(description = "难度", example = "easy") + private String difficulty; + + @Schema(description = "选项列表(不含正确性标记)") + private List options; + + @Schema(description = "学生已选答案", example = "A") + private String selectedAnswer; + + @Schema(description = "是否已作答", example = "true") + private Boolean isAnswered; + + @Schema(description = "快照时的内容版本", example = "v2") + private String contentVersion; + + /** + * 选项 VO — 仅 label、content、order,不含 isCorrect。 + */ + @Schema(description = "选项") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class OptionVO { + @Schema(description = "选项标签", example = "A") + private String label; + + @Schema(description = "选项内容", example = "2") + private String content; + + @Schema(description = "排序", example = "1.0") + private Double order; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java new file mode 100644 index 00000000..78a763fb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.education.controller.app.practice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 创建练习会话请求 VO。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 创建练习会话请求") +@Data +public class PracticeSessionCreateReqVO { + + @Schema(description = "客户端生成的会话标识(UUID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000") + @NotBlank(message = "客户端会话标识不能为空") + private String clientSessionId; + + @Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001") + @NotBlank(message = "题集 ID 不能为空") + private String collectionId; + + @Schema(description = "目录节点 ID", example = "node-001") + private String nodeId; + + @Schema(description = "题型", example = "choice") + private String type; + + @Schema(description = "难度", example = "easy") + private String difficulty; + + @Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "题量不能为空") + @Min(value = 1, message = "题量至少为 1") + @Max(value = 1000, message = "题量最多为 1000") + private Integer questionCount = 10; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java new file mode 100644 index 00000000..486a418a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.education.controller.app.practice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 练习会话响应 VO。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 练习会话响应") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PracticeSessionRespVO { + + @Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + private Long sessionId; + + @Schema(description = "会话状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE") + private String status; + + @Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer questionCount; + + @Schema(description = "服务端版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer version; + + @Schema(description = "客户端会话标识", example = "550e8400-e29b-41d4-a716-446655440000") + private String clientSessionId; + + @Schema(description = "会话题目列表(安全视图)") + private List questions; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java new file mode 100644 index 00000000..956b5273 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.education.dal.dataobject; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 练习会话题目快照 DO。 + * + *

会话创建时从题目源获取当前题目内容并快照固化。后续改题不影响本次练习。 + * 选项以 JSON 存储,不含 isCorrect 字段以确保前端安全。

+ * + * @author 恭学教育 + */ +@TableName("education_practice_question") +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class PracticeQuestionDO extends TenantBaseDO { + + /** 主键 */ + @TableId + private Long id; + + /** 会话 ID */ + private Long sessionId; + + /** 题目序号(1-based,服务端固定顺序) */ + private Integer sequence; + + /** 原始题目 ID */ + private String questionId; + + /** 快照时的题目内容版本 */ + private String contentVersion; + + /** 题干快照 */ + private String stem; + + /** 题型快照 */ + private String type; + + /** 难度快照 */ + private String difficulty; + + /** 选项快照 JSON — 仅含 label、content、order,不含 isCorrect */ + private String options; + + /** 学生已选答案 */ + private String selectedAnswer; + + /** 是否已作答 */ + private Boolean isAnswered; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java new file mode 100644 index 00000000..1bbb8cc9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.education.dal.dataobject; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 练习会话 DO。 + * + *

归属于当前租户和 Member 用户。clientSessionId 由客户端生成,服务端保证 per-tenant 唯一。 + * version 字段用于乐观锁并发控制。

+ * + * @author 恭学教育 + */ +@TableName("education_practice_session") +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class PracticeSessionDO extends TenantBaseDO { + + /** 会话主键 */ + @TableId + private Long id; + + /** Member 用户编号(由安全上下文派生,非请求参数) */ + private Long userId; + + /** 客户端生成的会话标识(UUID),用于幂等创建 */ + private String clientSessionId; + + /** + * 会话状态:ACTIVE | SUBMITTED | EXPIRED | CANCELLED + */ + private String status; + + /** 题目总数 */ + private Integer questionCount; + + /** 源题集 ID */ + private String collectionId; + + /** 源目录节点 ID */ + private String nodeId; + + /** 筛选题型 */ + private String type; + + /** 筛选难度 */ + private String difficulty; + + /** 乐观锁版本号 */ + private Integer version; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java new file mode 100644 index 00000000..a0772221 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.education.dal.mysql; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 练习会话题目快照 Mapper。 + * + * @author 恭学教育 + */ +@Mapper +public interface PracticeQuestionMapper extends BaseMapperX { + + /** + * 按会话 ID 和序号查询题目快照列表,按 sequence 升序。 + */ + default List selectBySessionIdOrderBySequence(Long sessionId) { + return selectList(new LambdaQueryWrapperX() + .eq(PracticeQuestionDO::getSessionId, sessionId) + .orderByAsc(PracticeQuestionDO::getSequence)); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java new file mode 100644 index 00000000..278181e6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.education.dal.mysql; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 练习会话 Mapper。 + * + * @author 恭学教育 + */ +@Mapper +public interface PracticeSessionMapper extends BaseMapperX { + + /** + * 根据租户和客户端会话 ID 查找会话(幂等创建检查)。 + */ + default PracticeSessionDO selectByTenantAndClientSessionId(Long tenantId, String clientSessionId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PracticeSessionDO::getTenantId, tenantId) + .eq(PracticeSessionDO::getClientSessionId, clientSessionId)); + } + + /** + * 查找当前租户+用户最近的一条 ACTIVE 会话(恢复用)。 + */ + default PracticeSessionDO selectLatestActiveByTenantAndUser(Long tenantId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PracticeSessionDO::getTenantId, tenantId) + .eq(PracticeSessionDO::getUserId, userId) + .eq(PracticeSessionDO::getStatus, "ACTIVE") + .orderByDesc(PracticeSessionDO::getId) + .last("LIMIT 1")); + } + + /** + * 根据 ID 和租户查找(跨租户隔离)。 + */ + default PracticeSessionDO selectByIdAndTenant(Long id, Long tenantId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PracticeSessionDO::getId, id) + .eq(PracticeSessionDO::getTenantId, tenantId)); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index bc4f7f18..216a9ed6 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -40,4 +40,15 @@ public interface ErrorCodeConstants { ErrorCode UNSAFE_PROVIDER_PAYLOAD = new ErrorCode(1_005_003_004, "题库数据源返回不安全内容,请稍后重试"); ErrorCode QUESTION_NOT_VISIBLE = new ErrorCode(1_005_003_005, "题库数据源返回了不应出现的不可见题目"); + // ========== 练习会话 1-005-003-006 ~ 1-005-003-019 ========== + ErrorCode SESSION_NOT_FOUND = new ErrorCode(1_005_003_006, "练习会话不存在"); + ErrorCode SESSION_NOT_OWN = new ErrorCode(1_005_003_007, "无权访问该练习会话"); + ErrorCode SESSION_EXPIRED = new ErrorCode(1_005_003_008, "练习会话已过期"); + ErrorCode SESSION_ALREADY_SUBMITTED = new ErrorCode(1_005_003_009, "练习会话已提交,无法修改"); + ErrorCode SESSION_CANCELLED = new ErrorCode(1_005_003_010, "练习会话已取消"); + ErrorCode SESSION_DUPLICATE_CLIENT_ID = new ErrorCode(1_005_003_011, "客户端会话标识重复"); + ErrorCode SESSION_IDEMPOTENCY_MISMATCH = new ErrorCode(1_005_003_018, "客户端会话标识已存在但请求参数不一致,请更换 clientSessionId 或使用相同参数重试"); + ErrorCode SESSION_NO_ACTIVE = new ErrorCode(1_005_003_012, "当前没有进行中的练习会话"); + ErrorCode SESSION_QUESTION_MISMATCH = new ErrorCode(1_005_003_013, "会话题目不匹配"); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java new file mode 100644 index 00000000..e249477b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.education.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 练习会话状态枚举。 + * + * @author 恭学教育 + */ +@Getter +@AllArgsConstructor +public enum SessionStatusEnum { + + /** 进行中 — 学生可继续作答 */ + ACTIVE("ACTIVE", "进行中"), + + /** 已提交 — 学生已提交答案,待评分 */ + SUBMITTED("SUBMITTED", "已提交"), + + /** 已过期 — 会话超时失效 */ + EXPIRED("EXPIRED", "已过期"), + + /** 已取消 — 学生主动取消 */ + CANCELLED("CANCELLED", "已取消"); + + /** 状态码 */ + private final String code; + + /** 状态名 */ + private final String name; + + /** + * 判断是否为终态,终态会话不可再被恢复。 + */ + public boolean isTerminal() { + return this == SUBMITTED || this == EXPIRED || this == CANCELLED; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java new file mode 100644 index 00000000..9d24fe98 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.education.service.practice; + +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO; + +/** + * 练习会话服务接口。 + * + * @author 恭学教育 + */ +public interface PracticeSessionService { + + /** + * 幂等创建练习会话。 + * 同一 clientSessionId 重复调用返回已有会话(不创建新会话)。 + * + * @param reqVO 创建请求 + * @param userId 当前用户 ID(由安全上下文派生) + * @param tenantId 当前租户 ID(由安全上下文派生) + * @return 会话响应(含题目快照) + */ + PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId); + + /** + * 获取当前用户最近一条进行中的练习会话。 + * + * @param userId 当前用户 ID + * @param tenantId 当前租户 ID + * @return 会话响应(含题目快照),无进行中会话时返回 null + */ + PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId); + + /** + * 按 ID 获取特定练习会话(必须验证所有权)。 + * + * @param sessionId 会话 ID + * @param userId 当前用户 ID + * @param tenantId 当前租户 ID + * @return 会话响应(含题目快照) + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 会话不存在或无权限 + */ + PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java new file mode 100644 index 00000000..50645775 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -0,0 +1,281 @@ +package cn.iocoder.yudao.module.education.service.practice; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.*; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO; +import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper; +import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; + +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +/** + * 练习会话服务实现。 + * + * @author 恭学教育 + */ +@Service +@Slf4j +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class PracticeSessionServiceImpl implements PracticeSessionService { + + private final PracticeSessionMapper sessionMapper; + private final PracticeQuestionMapper questionMapper; + private final QuestionCatalogProvider questionCatalogProvider; + + public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper, + PracticeQuestionMapper questionMapper, + QuestionCatalogProvider questionCatalogProvider) { + this.sessionMapper = sessionMapper; + this.questionMapper = questionMapper; + this.questionCatalogProvider = questionCatalogProvider; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) { + // 1. Idempotent check: same tenant + clientSessionId exists → verify ownership before returning + PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId()); + if (existing != null) { + if (!Objects.equals(existing.getUserId(), userId)) { + // Same tenant, same clientSessionId, but different user → ownership violation + throw exception(SESSION_NOT_FOUND); + } + if (!isSameFingerprint(existing, reqVO)) { + throw exception(SESSION_IDEMPOTENCY_MISMATCH); + } + return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId())); + } + + // 2. Fetch eligible questions — provider returns visible-only per contract + int requestedCount = reqVO.getQuestionCount(); + List questions = fetchAndOrderQuestions(reqVO.getCollectionId(), reqVO.getNodeId(), + reqVO.getType(), reqVO.getDifficulty(), requestedCount); + + // 2a. Reject underfilled sessions: fewer visible questions than requested + if (questions.size() < requestedCount) { + throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, questions.size(), requestedCount); + } + + // 3. Create session with race-safe insert + PracticeSessionDO session = new PracticeSessionDO(); + session.setTenantId(tenantId); + session.setUserId(userId); + session.setClientSessionId(reqVO.getClientSessionId()); + session.setStatus("ACTIVE"); + session.setQuestionCount(questions.size()); + session.setCollectionId(reqVO.getCollectionId()); + session.setNodeId(reqVO.getNodeId()); + session.setType(reqVO.getType()); + session.setDifficulty(reqVO.getDifficulty()); + session.setVersion(1); + + try { + sessionMapper.insert(session); + } catch (DuplicateKeyException e) { + // Race: another thread inserted the same tenant+clientSessionId between our check and insert. + // Reload and verify ownership + fingerprint match. + log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution", + reqVO.getClientSessionId(), tenantId); + PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId()); + if (winner == null) { + // Defensive: should not happen if UK constraint triggered + throw exception(SESSION_DUPLICATE_CLIENT_ID); + } + if (!Objects.equals(winner.getUserId(), userId)) { + throw exception(SESSION_NOT_FOUND); + } + if (!isSameFingerprint(winner, reqVO)) { + throw exception(SESSION_IDEMPOTENCY_MISMATCH); + } + return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId())); + } + + // 4. Create question snapshots (server-determined order) + List questionDOs = new ArrayList<>(questions.size()); + int seq = 1; + for (CatalogQuestionDTO q : questions) { + PracticeQuestionDO pq = new PracticeQuestionDO(); + pq.setTenantId(tenantId); + pq.setSessionId(session.getId()); + pq.setSequence(seq++); + pq.setQuestionId(q.getId()); + pq.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : ""); + pq.setStem(q.getStem()); + pq.setType(q.getType()); + pq.setDifficulty(q.getDifficulty()); + pq.setOptions(optionsToSafeJson(q.getOptions())); + pq.setIsAnswered(false); + questionDOs.add(pq); + } + questionMapper.insertBatch(questionDOs); + + return buildSessionResp(session, questionDOs); + } + + + @Override + public PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId) { + PracticeSessionDO session = sessionMapper.selectLatestActiveByTenantAndUser(tenantId, userId); + if (session == null) { + return null; + } + List questions = questionMapper.selectBySessionIdOrderBySequence(session.getId()); + return buildSessionResp(session, questions); + } + + @Override + public PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId) { + PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId); + if (session == null) { + throw exception(SESSION_NOT_FOUND); + } + if (!Objects.equals(session.getUserId(), userId)) { + throw exception(SESSION_NOT_OWN); + } + List questions = questionMapper.selectBySessionIdOrderBySequence(session.getId()); + return buildSessionResp(session, questions); + } + + // ========== internal helpers ========== + + /** + * 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。 + */ + /** + * 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。 + * 所有筛选条件(含 nodeId)完整转发到 provider,不做客户端裁剪。 + */ + private List fetchAndOrderQuestions(String collectionId, String nodeId, + String type, String difficulty, int count) { + List allQuestions = new ArrayList<>(); + int pageNo = 1; + int pageSize = Math.min(count, 100); + + while (allQuestions.size() < count) { + CatalogQuestionPageResult page = questionCatalogProvider.listQuestions( + collectionId, nodeId, type, difficulty, pageNo, pageSize); + if (CollUtil.isEmpty(page.getItems())) { + break; + } + allQuestions.addAll(page.getItems()); + if (page.getItems().size() < pageSize) { + break; + } + pageNo++; + } + + if (allQuestions.isEmpty()) { + throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, 0, count); + } + + if (allQuestions.size() > count) { + allQuestions = allQuestions.subList(0, count); + } + + allQuestions.sort(Comparator.comparing(CatalogQuestionDTO::getId, Comparator.nullsLast(String::compareTo)) + .thenComparing(CatalogQuestionDTO::getContentVersion, Comparator.nullsLast(String::compareTo))); + return allQuestions; + } + + /** + * 将选项列表转换为安全 JSON 字符串(不含 isCorrect)。 + */ + private String optionsToSafeJson(List options) { + if (CollUtil.isEmpty(options)) { + return "[]"; + } + List> safeOptions = options.stream() + .map(o -> { + Map m = new LinkedHashMap<>(); + m.put("label", o.getLabel()); + m.put("content", o.getContent()); + m.put("order", o.getOrder()); + return m; + }) + .collect(Collectors.toList()); + return JsonUtils.toJsonString(safeOptions); + } + + /** + * 构建会话响应 VO。 + */ + private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List questions) { + List questionVOs = questions.stream() + .map(this::buildQuestionResp) + .collect(Collectors.toList()); + + return PracticeSessionRespVO.builder() + .sessionId(session.getId()) + .status(session.getStatus()) + .questionCount(session.getQuestionCount()) + .version(session.getVersion()) + .clientSessionId(session.getClientSessionId()) + .questions(questionVOs) + .build(); + } + + /** + * 构建单题响应 VO — 从快照安全还原。 + */ + private PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) { + List options = parseOptions(pq.getOptions()); + + return PracticeQuestionRespVO.builder() + .sequence(pq.getSequence()) + .questionId(pq.getQuestionId()) + .stem(pq.getStem()) + .type(pq.getType()) + .difficulty(pq.getDifficulty()) + .options(options) + .selectedAnswer(pq.getSelectedAnswer()) + .isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered()) + .contentVersion(pq.getContentVersion()) + .build(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private List parseOptions(String optionsJson) { + if (optionsJson == null || optionsJson.isEmpty()) { + return Collections.emptyList(); + } + List> raw = (List) JsonUtils.parseArray(optionsJson, Map.class); + if (raw == null) { + return Collections.emptyList(); + } + return raw.stream() + .map(m -> PracticeQuestionRespVO.OptionVO.builder() + .label((String) m.get("label")) + .content((String) m.get("content")) + .order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null) + .build()) + .collect(Collectors.toList()); + } + + + /** + * Verify that the existing session's immutable selection criteria match the current request. + * Used during race-resolution: if a concurrent insert wins, the calling thread's payload + * must be identical, otherwise it's an idempotency mismatch. + */ + private boolean isSameFingerprint(PracticeSessionDO existing, PracticeSessionCreateReqVO reqVO) { + return Objects.equals(existing.getCollectionId(), reqVO.getCollectionId()) + && Objects.equals(existing.getNodeId(), reqVO.getNodeId()) + && Objects.equals(existing.getType(), reqVO.getType()) + && Objects.equals(existing.getDifficulty(), reqVO.getDifficulty()) + && Objects.equals(existing.getQuestionCount(), reqVO.getQuestionCount()); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionControllerHttpTest.java new file mode 100644 index 00000000..6a0cad20 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionControllerHttpTest.java @@ -0,0 +1,355 @@ +package cn.iocoder.yudao.module.education.controller.app.practice; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO; +import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * PracticeSessionController HTTP seam test — standalone MockMvc. + * + * @author 恭学教育 + */ +class PracticeSessionControllerHttpTest { + + private MockMvc mockMvc; + private PracticeSessionService service; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + service = mock(PracticeSessionService.class); + PracticeSessionController controller = new PracticeSessionController(); + try { + var field = PracticeSessionController.class.getDeclaredField("practiceSessionService"); + field.setAccessible(true); + field.set(controller, service); + } catch (Exception e) { + throw new RuntimeException(e); + } + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setValidator(validator) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ========== POST /create ========== + + @Test + void shouldCreateSessionWhenAuthenticated() throws Exception { + setLoginUser(100L, 1L); + + PracticeSessionRespVO mockResp = buildResp(1001L, "ACTIVE", "uuid-01", List.of( + buildQuestion(1, "q-001", "stem1") + )); + when(service.createPracticeSession(any(), eq(100L), eq(1L))).thenReturn(mockResp); + + PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO(); + req.setClientSessionId("uuid-01"); + req.setCollectionId("col-001"); + req.setQuestionCount(10); + + MvcResult result = mockMvc.perform(post("/education/practice-session/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value(1001)) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.questions[0].questionId").value("q-001")) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + assertFalse(json.contains("correctAnswer"), "create: must not leak correctAnswer"); + assertFalse(json.contains("isCorrect"), "create: must not leak isCorrect"); + } + + @Test + void shouldReturn401ForCreateWhenNotAuthenticated() throws Exception { + PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO(); + req.setClientSessionId("uuid-01"); + req.setCollectionId("col-001"); + + mockMvc.perform(post("/education/practice-session/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturnIdempotentOnDuplicateClientSessionId() throws Exception { + setLoginUser(100L, 1L); + + PracticeSessionRespVO mockResp = buildResp(1001L, "ACTIVE", "uuid-dup", List.of()); + when(service.createPracticeSession(any(), eq(100L), eq(1L))).thenReturn(mockResp); + + PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO(); + req.setClientSessionId("uuid-dup"); + req.setCollectionId("col-001"); + req.setQuestionCount(5); + + // Call twice — service handles idempotency + mockMvc.perform(post("/education/practice-session/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value(1001)); + + mockMvc.perform(post("/education/practice-session/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value(1001)); + + verify(service, times(2)).createPracticeSession(any(), eq(100L), eq(1L)); + } + + @Test + void shouldDeclareNotNullOnQuestionCount() { + // Verify @NotNull is present on the questionCount field — contract: must be validated at HTTP layer + jakarta.validation.constraints.NotNull notNull = null; + try { + var field = PracticeSessionCreateReqVO.class.getDeclaredField("questionCount"); + notNull = field.getAnnotation(jakarta.validation.constraints.NotNull.class); + } catch (NoSuchFieldException e) { + fail("questionCount field must exist on PracticeSessionCreateReqVO"); + } + assertNotNull(notNull, "questionCount must have @NotNull annotation to prevent NPE"); + assertEquals("题量不能为空", notNull.message()); + } + + // ========== GET /current ========== + + @Test + void shouldReturnCurrentSessionWhenActive() throws Exception { + setLoginUser(100L, 1L); + + PracticeSessionRespVO mockResp = buildResp(2001L, "ACTIVE", "uuid-cur", List.of( + buildQuestion(1, "q-001", "current stem") + )); + when(service.getCurrentSession(100L, 1L)).thenReturn(mockResp); + + mockMvc.perform(get("/education/practice-session/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value(2001)) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.questions[0].stem").value("current stem")); + } + + @Test + void shouldReturnNullDataWhenNoCurrentSession() throws Exception { + setLoginUser(100L, 1L); + when(service.getCurrentSession(100L, 1L)).thenReturn(null); + + mockMvc.perform(get("/education/practice-session/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isEmpty()); + } + + @Test + void shouldReturn401ForCurrentWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/practice-session/current")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + // ========== GET /get ========== + + @Test + void shouldReturnSessionByIdWhenOwner() throws Exception { + setLoginUser(100L, 1L); + + PracticeSessionRespVO mockResp = buildResp(3001L, "ACTIVE", "uuid-get", List.of( + buildQuestion(1, "q-001", "get stem") + )); + when(service.getSession(3001L, 100L, 1L)).thenReturn(mockResp); + + mockMvc.perform(get("/education/practice-session/get").param("id", "3001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sessionId").value(3001)); + } + + @Test + void shouldReturn403WhenNotOwner() throws Exception { + setLoginUser(100L, 1L); + when(service.getSession(3001L, 100L, 1L)) + .thenThrow(new ServiceException(SESSION_NOT_OWN.getCode(), SESSION_NOT_OWN.getMsg())); + + mockMvc.perform(get("/education/practice-session/get").param("id", "3001")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(SESSION_NOT_OWN.getCode())); + } + + @Test + void shouldReturn404WhenSessionNotFound() throws Exception { + setLoginUser(100L, 1L); + when(service.getSession(99999L, 100L, 1L)) + .thenThrow(new ServiceException(SESSION_NOT_FOUND.getCode(), SESSION_NOT_FOUND.getMsg())); + + mockMvc.perform(get("/education/practice-session/get").param("id", "99999")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(SESSION_NOT_FOUND.getCode())); + } + + @Test + void shouldReturn401ForGetWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/practice-session/get").param("id", "1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + // ========== Answer field absence ========== + + @Test + void shouldNeverLeakAnswerInSessionResponses() throws Exception { + setLoginUser(100L, 1L); + + PracticeSessionRespVO mockResp = buildResp(4001L, "ACTIVE", "uuid-safe", List.of( + buildQuestion(1, "q-001", "safe stem") + )); + when(service.createPracticeSession(any(), eq(100L), eq(1L))).thenReturn(mockResp); + when(service.getCurrentSession(100L, 1L)).thenReturn(mockResp); + when(service.getSession(4001L, 100L, 1L)).thenReturn(mockResp); + + PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO(); + req.setClientSessionId("uuid-safe"); + req.setCollectionId("col-001"); + req.setQuestionCount(5); + + // Test all endpoints + for (var endpoint : List.of( + new EndpointTest("POST", "/education/practice-session/create", + objectMapper.writeValueAsString(req)), + new EndpointTest("GET", "/education/practice-session/current", null), + new EndpointTest("GET", "/education/practice-session/get?id=4001", null) + )) { + var builder = endpoint.method.equals("POST") + ? post(endpoint.url).contentType(MediaType.APPLICATION_JSON).content(endpoint.body) + : get(endpoint.url); + + MvcResult result = mockMvc.perform(builder) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + assertFalse(json.contains("correctAnswer"), + endpoint.method + " " + endpoint.url + ": must not leak correctAnswer"); + assertFalse(json.contains("isCorrect"), + endpoint.method + " " + endpoint.url + ": must not leak isCorrect"); + assertFalse(json.contains("explanation"), + endpoint.method + " " + endpoint.url + ": must not leak explanation"); + assertFalse(json.contains("analysis"), + endpoint.method + " " + endpoint.url + ": must not leak analysis"); + } + } + + // ========== helpers ========== + + private void setLoginUser(Long userId, Long tenantId) { + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setTenantId(tenantId); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + private PracticeSessionRespVO buildResp(Long sessionId, String status, String clientSessionId, + List questions) { + return PracticeSessionRespVO.builder() + .sessionId(sessionId) + .status(status) + .questionCount(questions.size()) + .version(1) + .clientSessionId(clientSessionId) + .questions(questions) + .build(); + } + + private PracticeQuestionRespVO buildQuestion(int sequence, String questionId, String stem) { + return PracticeQuestionRespVO.builder() + .sequence(sequence) + .questionId(questionId) + .stem(stem) + .type("choice") + .difficulty("easy") + .options(Collections.emptyList()) + .isAnswered(false) + .contentVersion("v1") + .build(); + } + + private record EndpointTest(String method, String url, String body) {} + + /** + * Minimal exception handler for standalone MockMvc. + */ + @RestControllerAdvice + static class TestExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public CommonResult handleValidation(MethodArgumentNotValidException ex, + jakarta.servlet.http.HttpServletResponse response) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + String msg = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .findFirst().orElse("validation error"); + return CommonResult.error(400, msg); + } + + @ExceptionHandler(ServiceException.class) + public CommonResult handleServiceException(ServiceException ex, + jakarta.servlet.http.HttpServletResponse response) { + if (ex.getCode() == UNAUTHORIZED.getCode()) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return CommonResult.error(UNAUTHORIZED); + } + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + return CommonResult.error(ex.getCode(), ex.getMessage()); + } + } + +} \ No newline at end of file diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapperTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapperTest.java new file mode 100644 index 00000000..d4a53379 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapperTest.java @@ -0,0 +1,225 @@ +package cn.iocoder.yudao.module.education.dal.mysql; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DuplicateKeyException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 练习会话与题目 Mapper DB 集成测试。 + * + * @author 恭学教育 + */ +@Import({}) +public class PracticeSessionMapperTest extends BaseDbUnitTest { + + @Resource + private PracticeSessionMapper sessionMapper; + + @Resource + private PracticeQuestionMapper questionMapper; + + // ========== Session CRUD + uniqueness ========== + + @Test + void shouldInsertAndSelectByClientSessionId() { + PracticeSessionDO session = createSession(1L, 100L, "uuid-001"); + sessionMapper.insert(session); + + PracticeSessionDO found = sessionMapper.selectByTenantAndClientSessionId(1L, "uuid-001"); + assertNotNull(found); + assertEquals("uuid-001", found.getClientSessionId()); + assertEquals(100L, found.getUserId()); + assertEquals("ACTIVE", found.getStatus()); + } + + @Test + void shouldRejectDuplicateClientSessionIdSameTenant() { + PracticeSessionDO s1 = createSession(1L, 100L, "uuid-dup"); + sessionMapper.insert(s1); + + PracticeSessionDO s2 = createSession(1L, 100L, "uuid-dup"); + assertThrows(org.springframework.dao.DuplicateKeyException.class, () -> sessionMapper.insert(s2), + "duplicate tenant_id + client_session_id must throw DuplicateKeyException"); + } + + @Test + void shouldAllowSameClientSessionIdDifferentTenant() { + PracticeSessionDO s1 = createSession(1L, 100L, "uuid-cross"); + sessionMapper.insert(s1); + + PracticeSessionDO s2 = createSession(2L, 200L, "uuid-cross"); + assertDoesNotThrow(() -> sessionMapper.insert(s2), + "same clientSessionId across tenants must be allowed"); + + PracticeSessionDO foundT1 = sessionMapper.selectByTenantAndClientSessionId(1L, "uuid-cross"); + assertNotNull(foundT1); + assertEquals(1L, foundT1.getTenantId()); + + PracticeSessionDO foundT2 = sessionMapper.selectByTenantAndClientSessionId(2L, "uuid-cross"); + assertNotNull(foundT2); + assertEquals(2L, foundT2.getTenantId()); + } + + // ========== Latest active session ========== + + @Test + void shouldReturnLatestActiveSession() { + PracticeSessionDO s1 = createSession(1L, 100L, "uuid-a1"); + sessionMapper.insert(s1); + + PracticeSessionDO s2 = createSession(1L, 100L, "uuid-a2"); + sessionMapper.insert(s2); + + PracticeSessionDO latest = sessionMapper.selectLatestActiveByTenantAndUser(1L, 100L); + assertNotNull(latest); + assertEquals("uuid-a2", latest.getClientSessionId()); + } + + @Test + void shouldReturnNullWhenNoActiveSession() { + PracticeSessionDO latest = sessionMapper.selectLatestActiveByTenantAndUser(1L, 999L); + assertNull(latest); + } + + @Test + void shouldIsolateUsersInActiveSessionQuery() { + sessionMapper.insert(createSession(1L, 100L, "uuid-u1")); + sessionMapper.insert(createSession(1L, 200L, "uuid-u2")); + + PracticeSessionDO u1 = sessionMapper.selectLatestActiveByTenantAndUser(1L, 100L); + assertNotNull(u1); + assertEquals(100L, u1.getUserId()); + + PracticeSessionDO u2 = sessionMapper.selectLatestActiveByTenantAndUser(1L, 200L); + assertNotNull(u2); + assertEquals(200L, u2.getUserId()); + } + + // ========== selectByIdAndTenant ========== + + @Test + void shouldRespectTenantWhenSelectById() { + PracticeSessionDO s = createSession(1L, 100L, "uuid-t1"); + sessionMapper.insert(s); + + PracticeSessionDO found = sessionMapper.selectByIdAndTenant(s.getId(), 1L); + assertNotNull(found); + + PracticeSessionDO notFound = sessionMapper.selectByIdAndTenant(s.getId(), 2L); + assertNull(notFound); + } + + // ========== Question snapshot ========== + + @Test + void shouldInsertAndSelectQuestionsBySession() { + PracticeSessionDO session = createSession(1L, 100L, "uuid-snap"); + sessionMapper.insert(session); + + PracticeQuestionDO q1 = createQuestion(session.getId(), 1, "q-001", "1+1=?"); + PracticeQuestionDO q2 = createQuestion(session.getId(), 2, "q-002", "2+2=?"); + questionMapper.insertBatch(List.of(q1, q2)); + + List questions = questionMapper.selectBySessionIdOrderBySequence(session.getId()); + assertEquals(2, questions.size()); + assertEquals(1, questions.get(0).getSequence()); + assertEquals("q-001", questions.get(0).getQuestionId()); + assertEquals(2, questions.get(1).getSequence()); + assertEquals("q-002", questions.get(1).getQuestionId()); + } + + @Test + void shouldPreserveQuestionSnapshotContent() { + PracticeSessionDO session = createSession(1L, 100L, "uuid-cont"); + sessionMapper.insert(session); + + String optionsJson = "[{\"label\":\"A\",\"content\":\"2\",\"order\":1.0}]"; + PracticeQuestionDO q = new PracticeQuestionDO(); + q.setTenantId(1L); + q.setSessionId(session.getId()); + q.setSequence(1); + q.setQuestionId("q-001"); + q.setContentVersion("v1"); + q.setStem("What is 1+1?"); + q.setType("choice"); + q.setDifficulty("easy"); + q.setOptions(optionsJson); + q.setIsAnswered(false); + questionMapper.insert(q); + + List loaded = questionMapper.selectBySessionIdOrderBySequence(session.getId()); + assertEquals(1, loaded.size()); + PracticeQuestionDO loadedQ = loaded.get(0); + assertEquals("What is 1+1?", loadedQ.getStem()); + assertEquals("v1", loadedQ.getContentVersion()); + assertEquals(optionsJson, loadedQ.getOptions()); + assertFalse(loadedQ.getIsAnswered()); + } + + @Test + void shouldRejectDuplicateSequenceInSameSession() { + PracticeSessionDO session = createSession(1L, 100L, "uuid-seq"); + sessionMapper.insert(session); + + questionMapper.insert(createQuestion(session.getId(), 1, "q-001", "stem1")); + assertThrows(Exception.class, () -> + questionMapper.insert(createQuestion(session.getId(), 1, "q-002", "stem2")), + "duplicate session_id + sequence must be rejected"); + } + + @Test + void shouldTrackAnswerProgress() { + PracticeSessionDO session = createSession(1L, 100L, "uuid-prog"); + sessionMapper.insert(session); + + PracticeQuestionDO q = createQuestion(session.getId(), 1, "q-001", "stem"); + questionMapper.insert(q); + + // Update answer + q.setSelectedAnswer("A"); + q.setIsAnswered(true); + questionMapper.updateById(q); + + PracticeQuestionDO updated = questionMapper.selectById(q.getId()); + assertEquals("A", updated.getSelectedAnswer()); + assertTrue(updated.getIsAnswered()); + } + + // ========== helpers ========== + + private PracticeSessionDO createSession(Long tenantId, Long userId, String clientSessionId) { + PracticeSessionDO s = new PracticeSessionDO(); + s.setTenantId(tenantId); + s.setUserId(userId); + s.setClientSessionId(clientSessionId); + s.setStatus("ACTIVE"); + s.setQuestionCount(2); + s.setCollectionId("col-001"); + s.setVersion(1); + return s; + } + + private PracticeQuestionDO createQuestion(Long sessionId, int sequence, String questionId, String stem) { + PracticeQuestionDO q = new PracticeQuestionDO(); + q.setTenantId(1L); + q.setSessionId(sessionId); + q.setSequence(sequence); + q.setQuestionId(questionId); + q.setContentVersion("v1"); + q.setStem(stem); + q.setType("choice"); + q.setDifficulty("easy"); + q.setOptions("[{\"label\":\"A\",\"content\":\"opt\",\"order\":1.0}]"); + q.setIsAnswered(false); + return q; + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java new file mode 100644 index 00000000..d960f26a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java @@ -0,0 +1,466 @@ +package cn.iocoder.yudao.module.education.service.practice; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO; +import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO; +import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper; +import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +/** + * PracticeSessionService test — real Mapper + Mock Provider. + * + * @author 恭学教育 + */ +@Import(PracticeSessionServiceImpl.class) +public class PracticeSessionServiceImplTest extends BaseDbUnitTest { + + @Resource + private PracticeSessionService service; + + @Resource + private PracticeSessionMapper sessionMapper; + + @Resource + private PracticeQuestionMapper questionMapper; + + @MockitoBean + private QuestionCatalogProvider provider; + + // ========== Create: basic ========== + + @Test + void shouldCreateSessionAndReturnQuestions() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(2))) + .thenReturn(pageResult(List.of( + questionDTO("q-002", "v1", "2+2=?"), + questionDTO("q-001", "v1", "1+1=?") + ))); + + PracticeSessionCreateReqVO req = createReq("uuid-create-1", "col-001", 2); + PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); + + assertNotNull(resp.getSessionId()); + assertEquals("ACTIVE", resp.getStatus()); + assertEquals(2, resp.getQuestions().size()); + assertEquals("q-001", resp.getQuestions().get(0).getQuestionId()); + assertEquals("q-002", resp.getQuestions().get(1).getQuestionId()); + assertEquals(1, resp.getQuestions().get(0).getSequence()); + assertEquals(2, resp.getQuestions().get(1).getSequence()); + } + + // ========== Create: idempotent (same user) ========== + + @Test + void shouldReturnExistingSessionOnDuplicateClientSessionId() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-idemp", "col-001", 1); + + PracticeSessionRespVO first = service.createPracticeSession(req, 100L, 1L); + assertNotNull(first.getSessionId()); + + PracticeSessionRespVO second = service.createPracticeSession(req, 100L, 1L); + assertEquals(first.getSessionId(), second.getSessionId()); + assertEquals(first.getQuestions().size(), second.getQuestions().size()); + } + + // ========== Create: cross-user idempotency (HIGH #1) ========== + + @Test + void shouldRejectIdempotentReturnForDifferentUserSameTenant() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-cross-user", "col-001", 1); + + PracticeSessionRespVO created = service.createPracticeSession(req, 100L, 1L); + assertNotNull(created.getSessionId()); + + assertServiceException( + () -> service.createPracticeSession(req, 200L, 1L), + SESSION_NOT_FOUND); + } + + @Test + void shouldAllowIdempotentReturnForSameUserSameTenant() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-same-user", "col-001", 1); + + PracticeSessionRespVO first = service.createPracticeSession(req, 100L, 1L); + PracticeSessionRespVO second = service.createPracticeSession(req, 100L, 1L); + + assertEquals(first.getSessionId(), second.getSessionId()); + } + + @Test + void shouldRejectSameUserReplayWithDifferentFingerprint() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO original = createReq("uuid-fingerprint", "col-001", 1); + service.createPracticeSession(original, 100L, 1L); + + PracticeSessionCreateReqVO changed = createReq("uuid-fingerprint", "col-002", 1); + assertServiceException( + () -> service.createPracticeSession(changed, 100L, 1L), + SESSION_IDEMPOTENCY_MISMATCH); + } + + // ========== Create: underfilled session (MEDIUM #4) ========== + + @Test + void shouldRejectUnderfilledSessionWhenFewerQuestionsThanRequested() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-small"), isNull(), isNull(), isNull(), eq(1), eq(10))) + .thenReturn(pageResult(List.of( + questionDTO("q-001", "v1", "stem1"), + questionDTO("q-002", "v1", "stem2"), + questionDTO("q-003", "v1", "stem3") + ))); + + PracticeSessionCreateReqVO req = createReq("uuid-underfill", "col-small", 10); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.createPracticeSession(req, 100L, 1L)); + assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode()); + + // DB clean — no partial session + List sessions = sessionMapper.selectList(); + assertEquals(0, sessions.size(), "underfilled session must leave no DB trace"); + List questions = questionMapper.selectList(); + assertEquals(0, questions.size(), "underfilled session must leave no questions in DB"); + } + + @Test + void shouldRejectWhenNoQuestionsAvailable() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-empty"), isNull(), isNull(), isNull(), eq(1), eq(10))) + .thenReturn(CatalogQuestionPageResult.builder().items(List.of()).total(0L).build()); + + PracticeSessionCreateReqVO req = createReq("uuid-empty", "col-empty", 10); + ServiceException ex = assertThrows(ServiceException.class, + () -> service.createPracticeSession(req, 100L, 1L)); + assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode()); + } + + // ========== Create: nodeId forwarding (MEDIUM #3) ========== + + @Test + void shouldForwardNodeIdToProvider() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), eq("node-042"), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-node", "col-001", 1); + req.setNodeId("node-042"); + + PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); + assertNotNull(resp.getSessionId()); + } + + @Test + void shouldForwardNullNodeIdToProvider() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-no-node", "col-001", 1); + req.setNodeId(null); + + PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); + assertNotNull(resp.getSessionId()); + } + + // ========== Create: concurrent race (HIGH #2) ========== + + @Test + void shouldResolveDuplicateKeyRaceWithIdenticalFingerprint() throws Exception { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-race"), isNull(), isNull(), isNull(), eq(1), eq(2))) + .thenReturn(pageResult(List.of( + questionDTO("q-a", "v1", "stem-a"), + questionDTO("q-b", "v1", "stem-b") + ))); + + PracticeSessionCreateReqVO req1 = createReq("uuid-race-1", "col-race", 2); + PracticeSessionCreateReqVO req2 = createReq("uuid-race-1", "col-race", 2); + + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch go = new CountDownLatch(1); + AtomicReference r1 = new AtomicReference<>(); + AtomicReference r2 = new AtomicReference<>(); + AtomicReference e1 = new AtomicReference<>(); + AtomicReference e2 = new AtomicReference<>(); + + Thread t1 = new Thread(() -> { + try { + ready.countDown(); + go.await(); + r1.set(service.createPracticeSession(req1, 100L, 1L)); + } catch (Exception e) { + e1.set(e); + } + }); + Thread t2 = new Thread(() -> { + try { + ready.countDown(); + go.await(); + r2.set(service.createPracticeSession(req2, 100L, 1L)); + } catch (Exception e) { + e2.set(e); + } + }); + + t1.start(); + t2.start(); + ready.await(); + go.countDown(); + t1.join(10000); + t2.join(10000); + + assertNull(e1.get(), "thread 1 must not throw: " + (e1.get() != null ? e1.get().getMessage() : "")); + assertNull(e2.get(), "thread 2 must not throw: " + (e2.get() != null ? e2.get().getMessage() : "")); + assertNotNull(r1.get()); + assertNotNull(r2.get()); + assertEquals(r1.get().getSessionId(), r2.get().getSessionId(), + "concurrent identical requests must return same session"); + } + + @Test + void shouldRejectDuplicateKeyRaceWithDifferentFingerprint() throws Exception { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-race-diff"), isNull(), isNull(), eq("easy"), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-easy", "v1", "easy stem")))); + when(provider.listQuestions(eq("col-race-diff"), isNull(), isNull(), eq("hard"), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-hard", "v1", "hard stem")))); + + PracticeSessionCreateReqVO reqA = createReq("uuid-race-diff", "col-race-diff", 1); + reqA.setDifficulty("easy"); + PracticeSessionCreateReqVO reqB = createReq("uuid-race-diff", "col-race-diff", 1); + reqB.setDifficulty("hard"); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch go = new CountDownLatch(1); + AtomicReference rA = new AtomicReference<>(); + AtomicReference eA = new AtomicReference<>(); + AtomicReference rB = new AtomicReference<>(); + AtomicReference eB = new AtomicReference<>(); + + Thread tA = new Thread(() -> { + try { + ready.countDown(); + go.await(); + rA.set(service.createPracticeSession(reqA, 100L, 1L)); + } catch (Exception e) { + eA.set(e); + } + }); + Thread tB = new Thread(() -> { + try { + ready.countDown(); + go.await(); + rB.set(service.createPracticeSession(reqB, 100L, 1L)); + } catch (Exception e) { + eB.set(e); + } + }); + + tA.start(); + tB.start(); + ready.await(); + go.countDown(); + tA.join(10000); + tB.join(10000); + + boolean oneSuccess = rA.get() != null || rB.get() != null; + boolean oneMismatch = (eA.get() != null && eA.get() instanceof ServiceException + && ((ServiceException) eA.get()).getCode() == SESSION_IDEMPOTENCY_MISMATCH.getCode()) + || (eB.get() != null && eB.get() instanceof ServiceException + && ((ServiceException) eB.get()).getCode() == SESSION_IDEMPOTENCY_MISMATCH.getCode()); + + assertTrue(oneSuccess, "at least one thread must succeed"); + assertTrue(oneMismatch, "the other thread must get SESSION_IDEMPOTENCY_MISMATCH"); + + PracticeSessionDO only = sessionMapper.selectByTenantAndClientSessionId(1L, "uuid-race-diff"); + assertNotNull(only, "DB must have exactly one session"); + } + + // ========== Current session ========== + + @Test + void shouldReturnNullWhenNoCurrentSession() { + PracticeSessionRespVO resp = service.getCurrentSession(100L, 1L); + assertNull(resp); + } + + @Test + void shouldReturnCurrentActiveSession() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-current", "col-001", 1); + service.createPracticeSession(req, 100L, 1L); + + PracticeSessionRespVO current = service.getCurrentSession(100L, 1L); + assertNotNull(current); + assertEquals("ACTIVE", current.getStatus()); + assertEquals(1, current.getQuestions().size()); + } + + // ========== Get with ownership ========== + + @Test + void shouldGetSessionByOwner() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-owner", "col-001", 1); + PracticeSessionRespVO created = service.createPracticeSession(req, 100L, 1L); + + PracticeSessionRespVO got = service.getSession(created.getSessionId(), 100L, 1L); + assertNotNull(got); + assertEquals(created.getSessionId(), got.getSessionId()); + } + + @Test + void shouldRejectGetByWrongUser() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-wrong", "col-001", 1); + PracticeSessionRespVO created = service.createPracticeSession(req, 100L, 1L); + + assertServiceException(() -> service.getSession(created.getSessionId(), 200L, 1L), + SESSION_NOT_OWN); + } + + @Test + void shouldRejectGetByWrongTenant() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-tenant", "col-001", 1); + PracticeSessionRespVO created = service.createPracticeSession(req, 100L, 1L); + + assertServiceException(() -> service.getSession(created.getSessionId(), 100L, 2L), + SESSION_NOT_FOUND); + } + + @Test + void shouldReturnNotFoundForNonexistentSession() { + assertServiceException(() -> service.getSession(99999L, 100L, 1L), + SESSION_NOT_FOUND); + } + + // ========== Snapshot stability & safety ========== + + @Test + void shouldPreserveQuestionSnapshotIndependently() { + when(provider.isEnabled()).thenReturn(true); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "Original stem")))); + + PracticeSessionCreateReqVO req = createReq("uuid-snap", "col-001", 1); + PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); + assertEquals("Original stem", resp.getQuestions().get(0).getStem()); + assertEquals("v1", resp.getQuestions().get(0).getContentVersion()); + + PracticeSessionRespVO reloaded = service.getSession(resp.getSessionId(), 100L, 1L); + assertEquals("Original stem", reloaded.getQuestions().get(0).getStem()); + assertEquals("v1", reloaded.getQuestions().get(0).getContentVersion()); + } + + @Test + void shouldNeverStoreCorrectAnswerInOptionsSnapshot() { + when(provider.isEnabled()).thenReturn(true); + CatalogQuestionDTO q = CatalogQuestionDTO.builder() + .id("q-001") + .contentVersion("v1") + .stem("test stem") + .type("choice") + .difficulty("easy") + .isPublished(true) + .options(List.of( + CatalogQuestionDTO.QuestionOptionDTO.builder() + .label("A").content("correct").isCorrect(true).order(1.0).build(), + CatalogQuestionDTO.QuestionOptionDTO.builder() + .label("B").content("wrong").isCorrect(false).order(2.0).build() + )) + .build(); + when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1))) + .thenReturn(pageResult(List.of(q))); + + PracticeSessionCreateReqVO req = createReq("uuid-safe", "col-001", 1); + PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); + assertEquals(1, resp.getQuestions().size()); + + List questions = questionMapper.selectBySessionIdOrderBySequence(resp.getSessionId()); + assertEquals(1, questions.size()); + String optionsJson = questions.get(0).getOptions(); + assertNotNull(optionsJson); + assertTrue(optionsJson.contains("correct"), "option content 'correct' is a label value, not isCorrect"); + assertFalse(optionsJson.contains("isCorrect"), "optionsJson must never contain isCorrect"); + assertFalse(optionsJson.contains("correctAnswer"), "optionsJson must never contain correctAnswer"); + assertFalse(optionsJson.contains("explanation"), "optionsJson must never contain explanation"); + } + + // ========== helpers ========== + + private PracticeSessionCreateReqVO createReq(String clientSessionId, String collectionId, int count) { + PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO(); + req.setClientSessionId(clientSessionId); + req.setCollectionId(collectionId); + req.setQuestionCount(count); + return req; + } + + private CatalogQuestionPageResult pageResult(List items) { + return CatalogQuestionPageResult.builder() + .items(items) + .total((long) items.size()) + .build(); + } + + private CatalogQuestionDTO questionDTO(String id, String version, String stem) { + return CatalogQuestionDTO.builder() + .id(id) + .contentVersion(version) + .stem(stem) + .type("choice") + .difficulty("easy") + .isPublished(true) + .build(); + } +} diff --git a/yudao-module-education/src/test/resources/application-unit-test.yaml b/yudao-module-education/src/test/resources/application-unit-test.yaml index 116b1738..517cad4e 100644 --- a/yudao-module-education/src/test/resources/application-unit-test.yaml +++ b/yudao-module-education/src/test/resources/application-unit-test.yaml @@ -1,35 +1,36 @@ spring: main: - lazy-initialization: true # 开启懒加载,加快速度 - banner-mode: off # 单元测试,禁用 Banner + lazy-initialization: true + banner-mode: off --- #################### 数据库相关配置 #################### spring: - # 数据源配置项 datasource: name: ruoyi-vue-pro - url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; driver-class-name: org.h2.Driver username: sa password: druid: - async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 - initial-size: 1 # 单元测试,配置为 1,提升启动速度 - - # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + async-init: true + initial-size: 1 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql data: redis: - host: 127.0.0.1 # 地址 - port: 16379 # 端口(单元测试,使用 16379 端口) - database: 0 # 数据库索引 + host: 127.0.0.1 + port: 16379 + database: 0 mybatis: - lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + lazy-initialization: true --- #################### 恭学相关配置 #################### -# 恭学配置项,设置当前项目所有自定义的配置 yudao: info: base-package: cn.iocoder.yudao.module + education: + enabled: true diff --git a/yudao-module-education/src/test/resources/sql/clean.sql b/yudao-module-education/src/test/resources/sql/clean.sql index e0ac49d1..aca8d043 100644 --- a/yudao-module-education/src/test/resources/sql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/clean.sql @@ -1 +1,2 @@ -SELECT 1; +DELETE FROM education_practice_question; +DELETE FROM education_practice_session; diff --git a/yudao-module-education/src/test/resources/sql/create_tables.sql b/yudao-module-education/src/test/resources/sql/create_tables.sql index e0ac49d1..9cc023cd 100644 --- a/yudao-module-education/src/test/resources/sql/create_tables.sql +++ b/yudao-module-education/src/test/resources/sql/create_tables.sql @@ -1 +1,46 @@ -SELECT 1; +CREATE TABLE IF NOT EXISTS "education_practice_session" ( + "id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "tenant_id" BIGINT NOT NULL, + "user_id" BIGINT NOT NULL, + "client_session_id" VARCHAR(36) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "question_count" INT NOT NULL DEFAULT 0, + "collection_id" VARCHAR(64) DEFAULT NULL, + "node_id" VARCHAR(64) DEFAULT NULL, + "type" VARCHAR(32) DEFAULT NULL, + "difficulty" VARCHAR(32) DEFAULT NULL, + "version" INT NOT NULL DEFAULT 0, + "creator" VARCHAR(64) DEFAULT '', + "create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" VARCHAR(64) DEFAULT '', + "update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" BIT NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id"), + CONSTRAINT "uk_tenant_client_session" UNIQUE ("tenant_id", "client_session_id") +); + +CREATE INDEX IF NOT EXISTS "idx_tenant_user_status" ON "education_practice_session" ("tenant_id", "user_id", "status"); + +CREATE TABLE IF NOT EXISTS "education_practice_question" ( + "id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "tenant_id" BIGINT NOT NULL, + "session_id" BIGINT NOT NULL, + "sequence" INT NOT NULL, + "question_id" VARCHAR(64) NOT NULL, + "content_version" VARCHAR(64) NOT NULL DEFAULT '', + "stem" CLOB NOT NULL, + "type" VARCHAR(32) NOT NULL, + "difficulty" VARCHAR(32) DEFAULT NULL, + "options" CLOB NOT NULL, + "selected_answer" CLOB DEFAULT NULL, + "is_answered" BIT NOT NULL DEFAULT FALSE, + "creator" VARCHAR(64) DEFAULT '', + "create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" VARCHAR(64) DEFAULT '', + "update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" BIT NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id"), + CONSTRAINT "uk_session_sequence" UNIQUE ("session_id", "sequence") +); + +CREATE INDEX IF NOT EXISTS "idx_session_id" ON "education_practice_question" ("session_id");