feat(education): create resumable practice sessions
This commit is contained in:
@@ -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;
|
||||
101
sql/mysql/education/002-education-practice-session.sql
Normal file
101
sql/mysql/education/002-education-practice-session.sql
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, "会话题目不匹配");
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
SELECT 1;
|
||||
DELETE FROM education_practice_question;
|
||||
DELETE FROM education_practice_session;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user