feat(education): create resumable practice sessions

This commit is contained in:
2026-07-27 21:06:57 +08:00
parent 73b2a8edcc
commit a43a58513a
20 changed files with 2042 additions and 15 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
* <p>会话和答题数据写入 MySQL从不经过 Scalar。</p>
*
* @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<PracticeSessionRespVO> 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<PracticeSessionRespVO> 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<PracticeSessionRespVO> 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();
}
}

View File

@@ -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<OptionVO> 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;
}
}

View File

@@ -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;
}

View File

@@ -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<PracticeQuestionRespVO> questions;
}

View File

@@ -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。
*
* <p>会话创建时从题目源获取当前题目内容并快照固化。后续改题不影响本次练习。
* 选项以 JSON 存储,不含 isCorrect 字段以确保前端安全。</p>
*
* @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;
}

View File

@@ -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。
*
* <p>归属于当前租户和 Member 用户。clientSessionId 由客户端生成,服务端保证 per-tenant 唯一。
* version 字段用于乐观锁并发控制。</p>
*
* @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;
}

View File

@@ -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<PracticeQuestionDO> {
/**
* 按会话 ID 和序号查询题目快照列表,按 sequence 升序。
*/
default List<PracticeQuestionDO> selectBySessionIdOrderBySequence(Long sessionId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.orderByAsc(PracticeQuestionDO::getSequence));
}
}

View File

@@ -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<PracticeSessionDO> {
/**
* 根据租户和客户端会话 ID 查找会话(幂等创建检查)。
*/
default PracticeSessionDO selectByTenantAndClientSessionId(Long tenantId, String clientSessionId) {
return selectOne(new LambdaQueryWrapperX<PracticeSessionDO>()
.eq(PracticeSessionDO::getTenantId, tenantId)
.eq(PracticeSessionDO::getClientSessionId, clientSessionId));
}
/**
* 查找当前租户+用户最近的一条 ACTIVE 会话(恢复用)。
*/
default PracticeSessionDO selectLatestActiveByTenantAndUser(Long tenantId, Long userId) {
return selectOne(new LambdaQueryWrapperX<PracticeSessionDO>()
.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<PracticeSessionDO>()
.eq(PracticeSessionDO::getId, id)
.eq(PracticeSessionDO::getTenantId, tenantId));
}
}

View File

@@ -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, "会话题目不匹配");
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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<CatalogQuestionDTO> 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<PracticeQuestionDO> 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<PracticeQuestionDO> 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<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
return buildSessionResp(session, questions);
}
// ========== internal helpers ==========
/**
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
*/
/**
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
* 所有筛选条件(含 nodeId完整转发到 provider不做客户端裁剪。
*/
private List<CatalogQuestionDTO> fetchAndOrderQuestions(String collectionId, String nodeId,
String type, String difficulty, int count) {
List<CatalogQuestionDTO> 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<CatalogQuestionDTO.QuestionOptionDTO> options) {
if (CollUtil.isEmpty(options)) {
return "[]";
}
List<Map<String, Object>> safeOptions = options.stream()
.map(o -> {
Map<String, Object> 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<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> 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<PracticeQuestionRespVO.OptionVO> 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<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> 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());
}
}

View File

@@ -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<PracticeQuestionRespVO> 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());
}
}
}

View File

@@ -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<PracticeQuestionDO> 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<PracticeQuestionDO> 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;
}
}

View File

@@ -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<PracticeSessionDO> sessions = sessionMapper.selectList();
assertEquals(0, sessions.size(), "underfilled session must leave no DB trace");
List<PracticeQuestionDO> 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<PracticeSessionRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeSessionRespVO> r2 = new AtomicReference<>();
AtomicReference<Exception> e1 = new AtomicReference<>();
AtomicReference<Exception> 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<PracticeSessionRespVO> rA = new AtomicReference<>();
AtomicReference<Exception> eA = new AtomicReference<>();
AtomicReference<PracticeSessionRespVO> rB = new AtomicReference<>();
AtomicReference<Exception> 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<PracticeQuestionDO> 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<CatalogQuestionDTO> 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();
}
}

View File

@@ -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

View File

@@ -1 +1,2 @@
SELECT 1;
DELETE FROM education_practice_question;
DELETE FROM education_practice_session;

View File

@@ -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");