diff --git a/sql/mysql/education/002-education-practice-session-rollback.sql b/sql/mysql/education/002-education-practice-session-rollback.sql
new file mode 100644
index 00000000..01233e9d
--- /dev/null
+++ b/sql/mysql/education/002-education-practice-session-rollback.sql
@@ -0,0 +1,28 @@
+-- =============================================
+-- Education 模块 — 练习会话与题目快照回滚
+-- Ticket #6 / Migration 002
+-- =============================================
+--
+-- WARNING: This file contains NO executable SQL.
+-- Destructive rollback (DROP TABLE) requires manual operator verification.
+--
+-- Manual rollback procedure (operator must execute):
+-- 1. Verify no other tables depend on these tables:
+-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
+-- FROM information_schema.KEY_COLUMN_USAGE
+-- WHERE REFERENCED_TABLE_NAME IN ('education_practice_session', 'education_practice_question')
+-- AND TABLE_SCHEMA = DATABASE();
+-- Result MUST be empty before proceeding.
+--
+-- 2. Verify the tables contain only data from this migration:
+-- SELECT COUNT(*) AS session_count FROM education_practice_session;
+-- SELECT COUNT(*) AS question_count FROM education_practice_question;
+-- Operator must confirm these counts are acceptable to destroy.
+--
+-- 3. After verification, execute:
+-- DROP TABLE IF EXISTS education_practice_question;
+-- DROP TABLE IF EXISTS education_practice_session;
+--
+-- DO NOT uncomment or execute the lines below without operator verification.
+-- -- DROP TABLE IF EXISTS education_practice_question;
+-- -- DROP TABLE IF EXISTS education_practice_session;
diff --git a/sql/mysql/education/002-education-practice-session.sql b/sql/mysql/education/002-education-practice-session.sql
new file mode 100644
index 00000000..5ef104ba
--- /dev/null
+++ b/sql/mysql/education/002-education-practice-session.sql
@@ -0,0 +1,101 @@
+-- =============================================
+-- Education 模块 — 练习会话与题目快照 DDL
+-- Ticket #6: 练习会话创建、题目快照、恢复与状态机
+-- Migration: 002
+-- Prerequisites: 000-education-schema.sql (database creation)
+-- 001-education-tenant-seed.sql (tenant seed data)
+-- =============================================
+
+-- =============================================
+-- Preconditions
+-- =============================================
+-- This migration MUST fail if either table already exists (no IF NOT EXISTS).
+-- Operator is expected to verify:
+-- SELECT COUNT(*) FROM information_schema.tables
+-- WHERE table_schema = DATABASE()
+-- AND table_name IN ('education_practice_session', 'education_practice_question');
+-- Result MUST be 0 before executing this migration.
+
+-- =============================================
+-- 练习会话表
+-- =============================================
+-- Indexes:
+-- uk_tenant_client_session — per-tenant uniqueness for clientSessionId idempotency.
+-- Used by: selectByTenantAndClientSessionId (idempotent create check),
+-- DuplicateKeyException catch for concurrent-create race resolution.
+-- idx_tenant_user_status — covers getCurrentSession (latest ACTIVE by tenant+user)
+-- and ownership queries. Column order: (tenant_id, user_id, status) so the
+-- index supports both filtering by tenant+user and tenant+user+status.
+-- Lock impact: INSERT acquires next-key lock on uk_tenant_client_session unique key;
+-- concurrent inserts with same (tenant_id, client_session_id) serialize naturally.
+-- No additional table-level locks required.
+CREATE TABLE `education_practice_session` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话主键',
+ `tenant_id` BIGINT NOT NULL COMMENT '租户编号',
+ `user_id` BIGINT NOT NULL COMMENT 'Member 用户编号',
+ `client_session_id` VARCHAR(36) NOT NULL COMMENT '客户端生成的会话标识(UUID),用于幂等创建',
+ `status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
+ COMMENT '会话状态:ACTIVE-进行中, SUBMITTED-已提交, EXPIRED-已过期, CANCELLED-已取消',
+ `question_count` INT NOT NULL DEFAULT 0 COMMENT '题目总数',
+ `collection_id` VARCHAR(64) DEFAULT NULL COMMENT '源题集 ID',
+ `node_id` VARCHAR(64) DEFAULT NULL COMMENT '源目录节点 ID',
+ `type` VARCHAR(32) DEFAULT NULL COMMENT '筛选题型',
+ `difficulty` VARCHAR(32) DEFAULT NULL COMMENT '筛选难度',
+ `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
+ `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_tenant_client_session` (`tenant_id`, `client_session_id`),
+ KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话';
+
+-- =============================================
+-- 练习会话题目快照表
+-- =============================================
+-- Indexes:
+-- uk_session_sequence — per-session uniqueness for question sequence numbers.
+-- Used by: insertBatch to ensure no duplicate sequences within a session.
+-- idx_session_id — covers selectBySessionIdOrderBySequence (load all questions
+-- for a session, ordered by sequence). Also used by cascade delete lookups.
+-- Lock impact: INSERT acquires gap locks within session_id range on uk_session_sequence;
+-- concurrent inserts into different sessions are independent.
+-- Options column: JSON data type stores only label, content, order — never isCorrect.
+-- Application layer (optionsToSafeJson) strips correctness before storage.
+CREATE TABLE `education_practice_question` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT NOT NULL COMMENT '租户编号',
+ `session_id` BIGINT NOT NULL COMMENT '会话 ID',
+ `sequence` INT NOT NULL COMMENT '题目序号(1-based,服务端固定)',
+ `question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
+ `content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '快照时的题目内容版本',
+ `stem` TEXT NOT NULL COMMENT '题干快照',
+ `type` VARCHAR(32) NOT NULL COMMENT '题型快照',
+ `difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
+ `options` JSON NOT NULL COMMENT '选项快照 JSON(不含 isCorrect)',
+ `selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
+ `is_answered` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否已作答',
+ `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_session_sequence` (`session_id`, `sequence`),
+ KEY `idx_session_id` (`session_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话题目快照';
+
+-- =============================================
+-- Post-migration verification queries
+-- =============================================
+-- Verify tables exist with correct structure:
+-- SHOW CREATE TABLE education_practice_session;
+-- SHOW CREATE TABLE education_practice_question;
+-- Verify unique keys are enforced:
+-- SHOW INDEX FROM education_practice_session WHERE Key_name = 'uk_tenant_client_session';
+-- SHOW INDEX FROM education_practice_question WHERE Key_name = 'uk_session_sequence';
+-- Verify no orphan data (should be 0 after fresh migration):
+-- SELECT COUNT(*) FROM education_practice_session;
+-- SELECT COUNT(*) FROM education_practice_question;
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java
new file mode 100644
index 00000000..686f46d1
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java
@@ -0,0 +1,91 @@
+package cn.iocoder.yudao.module.education.controller.app.practice;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
+import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
+import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 练习会话 Controller — 学生端已认证接口。
+ *
+ *
所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。
+ * 会话和答题数据写入 MySQL,从不经过 Scalar。
+ *
+ * @author 恭学教育
+ */
+@Tag(name = "用户 APP - 练习会话")
+@RestController
+@RequestMapping("/education")
+@Validated
+@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
+public class PracticeSessionController {
+
+ @Resource
+ private PracticeSessionService practiceSessionService;
+
+ // ========== 会话管理 ==========
+
+ @PostMapping("/practice-session/create")
+ @Operation(summary = "创建练习会话(幂等)",
+ description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
+ + "题目顺序由服务端固定,选项不含答案标记。")
+ public CommonResult createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
+ Long userId = getUserId();
+ Long tenantId = getTenantId();
+ PracticeSessionRespVO resp = practiceSessionService.createPracticeSession(reqVO, userId, tenantId);
+ return success(resp);
+ }
+
+ @GetMapping("/practice-session/current")
+ @Operation(summary = "获取当前进行中的练习会话",
+ description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。")
+ public CommonResult currentSession() {
+ Long userId = getUserId();
+ Long tenantId = getTenantId();
+ PracticeSessionRespVO resp = practiceSessionService.getCurrentSession(userId, tenantId);
+ return success(resp);
+ }
+
+ @GetMapping("/practice-session/get")
+ @Operation(summary = "获取指定练习会话",
+ description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。")
+ public CommonResult getSession(
+ @Parameter(description = "会话 ID", required = true) @RequestParam Long id) {
+ Long userId = getUserId();
+ Long tenantId = getTenantId();
+ PracticeSessionRespVO resp = practiceSessionService.getSession(id, userId, tenantId);
+ return success(resp);
+ }
+
+ // ========== security helpers ==========
+
+ private Long getUserId() {
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ if (userId == null) {
+ throw exception(UNAUTHORIZED);
+ }
+ return userId;
+ }
+
+ private Long getTenantId() {
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ if (loginUser == null || loginUser.getTenantId() == null) {
+ throw exception(UNAUTHORIZED);
+ }
+ return loginUser.getTenantId();
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java
new file mode 100644
index 00000000..0b33ed41
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeQuestionRespVO.java
@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.education.controller.app.practice.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 练习会话题目响应 VO — 安全视图,不含答案/解析。
+ *
+ * @author 恭学教育
+ */
+@Schema(description = "用户 APP - 练习会话题目响应")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class PracticeQuestionRespVO {
+
+ @Schema(description = "题目序号(1-based)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer sequence;
+
+ @Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
+ private String questionId;
+
+ @Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几?")
+ private String stem;
+
+ @Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
+ private String type;
+
+ @Schema(description = "难度", example = "easy")
+ private String difficulty;
+
+ @Schema(description = "选项列表(不含正确性标记)")
+ private List options;
+
+ @Schema(description = "学生已选答案", example = "A")
+ private String selectedAnswer;
+
+ @Schema(description = "是否已作答", example = "true")
+ private Boolean isAnswered;
+
+ @Schema(description = "快照时的内容版本", example = "v2")
+ private String contentVersion;
+
+ /**
+ * 选项 VO — 仅 label、content、order,不含 isCorrect。
+ */
+ @Schema(description = "选项")
+ @Data
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Builder
+ public static class OptionVO {
+ @Schema(description = "选项标签", example = "A")
+ private String label;
+
+ @Schema(description = "选项内容", example = "2")
+ private String content;
+
+ @Schema(description = "排序", example = "1.0")
+ private Double order;
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java
new file mode 100644
index 00000000..78a763fb
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionCreateReqVO.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.education.controller.app.practice.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 创建练习会话请求 VO。
+ *
+ * @author 恭学教育
+ */
+@Schema(description = "用户 APP - 创建练习会话请求")
+@Data
+public class PracticeSessionCreateReqVO {
+
+ @Schema(description = "客户端生成的会话标识(UUID)", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
+ @NotBlank(message = "客户端会话标识不能为空")
+ private String clientSessionId;
+
+ @Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001")
+ @NotBlank(message = "题集 ID 不能为空")
+ private String collectionId;
+
+ @Schema(description = "目录节点 ID", example = "node-001")
+ private String nodeId;
+
+ @Schema(description = "题型", example = "choice")
+ private String type;
+
+ @Schema(description = "难度", example = "easy")
+ private String difficulty;
+
+ @Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ @NotNull(message = "题量不能为空")
+ @Min(value = 1, message = "题量至少为 1")
+ @Max(value = 1000, message = "题量最多为 1000")
+ private Integer questionCount = 10;
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java
new file mode 100644
index 00000000..486a418a
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java
@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.module.education.controller.app.practice.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 练习会话响应 VO。
+ *
+ * @author 恭学教育
+ */
+@Schema(description = "用户 APP - 练习会话响应")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class PracticeSessionRespVO {
+
+ @Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
+ private Long sessionId;
+
+ @Schema(description = "会话状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE")
+ private String status;
+
+ @Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ private Integer questionCount;
+
+ @Schema(description = "服务端版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer version;
+
+ @Schema(description = "客户端会话标识", example = "550e8400-e29b-41d4-a716-446655440000")
+ private String clientSessionId;
+
+ @Schema(description = "会话题目列表(安全视图)")
+ private List questions;
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java
new file mode 100644
index 00000000..956b5273
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java
@@ -0,0 +1,57 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 练习会话题目快照 DO。
+ *
+ * 会话创建时从题目源获取当前题目内容并快照固化。后续改题不影响本次练习。
+ * 选项以 JSON 存储,不含 isCorrect 字段以确保前端安全。
+ *
+ * @author 恭学教育
+ */
+@TableName("education_practice_question")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class PracticeQuestionDO extends TenantBaseDO {
+
+ /** 主键 */
+ @TableId
+ private Long id;
+
+ /** 会话 ID */
+ private Long sessionId;
+
+ /** 题目序号(1-based,服务端固定顺序) */
+ private Integer sequence;
+
+ /** 原始题目 ID */
+ private String questionId;
+
+ /** 快照时的题目内容版本 */
+ private String contentVersion;
+
+ /** 题干快照 */
+ private String stem;
+
+ /** 题型快照 */
+ private String type;
+
+ /** 难度快照 */
+ private String difficulty;
+
+ /** 选项快照 JSON — 仅含 label、content、order,不含 isCorrect */
+ private String options;
+
+ /** 学生已选答案 */
+ private String selectedAnswer;
+
+ /** 是否已作答 */
+ private Boolean isAnswered;
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java
new file mode 100644
index 00000000..1bbb8cc9
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java
@@ -0,0 +1,56 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 练习会话 DO。
+ *
+ * 归属于当前租户和 Member 用户。clientSessionId 由客户端生成,服务端保证 per-tenant 唯一。
+ * version 字段用于乐观锁并发控制。
+ *
+ * @author 恭学教育
+ */
+@TableName("education_practice_session")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class PracticeSessionDO extends TenantBaseDO {
+
+ /** 会话主键 */
+ @TableId
+ private Long id;
+
+ /** Member 用户编号(由安全上下文派生,非请求参数) */
+ private Long userId;
+
+ /** 客户端生成的会话标识(UUID),用于幂等创建 */
+ private String clientSessionId;
+
+ /**
+ * 会话状态:ACTIVE | SUBMITTED | EXPIRED | CANCELLED
+ */
+ private String status;
+
+ /** 题目总数 */
+ private Integer questionCount;
+
+ /** 源题集 ID */
+ private String collectionId;
+
+ /** 源目录节点 ID */
+ private String nodeId;
+
+ /** 筛选题型 */
+ private String type;
+
+ /** 筛选难度 */
+ private String difficulty;
+
+ /** 乐观锁版本号 */
+ private Integer version;
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java
new file mode 100644
index 00000000..a0772221
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 练习会话题目快照 Mapper。
+ *
+ * @author 恭学教育
+ */
+@Mapper
+public interface PracticeQuestionMapper extends BaseMapperX {
+
+ /**
+ * 按会话 ID 和序号查询题目快照列表,按 sequence 升序。
+ */
+ default List selectBySessionIdOrderBySequence(Long sessionId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(PracticeQuestionDO::getSessionId, sessionId)
+ .orderByAsc(PracticeQuestionDO::getSequence));
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java
new file mode 100644
index 00000000..278181e6
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java
@@ -0,0 +1,46 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 练习会话 Mapper。
+ *
+ * @author 恭学教育
+ */
+@Mapper
+public interface PracticeSessionMapper extends BaseMapperX {
+
+ /**
+ * 根据租户和客户端会话 ID 查找会话(幂等创建检查)。
+ */
+ default PracticeSessionDO selectByTenantAndClientSessionId(Long tenantId, String clientSessionId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(PracticeSessionDO::getTenantId, tenantId)
+ .eq(PracticeSessionDO::getClientSessionId, clientSessionId));
+ }
+
+ /**
+ * 查找当前租户+用户最近的一条 ACTIVE 会话(恢复用)。
+ */
+ default PracticeSessionDO selectLatestActiveByTenantAndUser(Long tenantId, Long userId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(PracticeSessionDO::getTenantId, tenantId)
+ .eq(PracticeSessionDO::getUserId, userId)
+ .eq(PracticeSessionDO::getStatus, "ACTIVE")
+ .orderByDesc(PracticeSessionDO::getId)
+ .last("LIMIT 1"));
+ }
+
+ /**
+ * 根据 ID 和租户查找(跨租户隔离)。
+ */
+ default PracticeSessionDO selectByIdAndTenant(Long id, Long tenantId) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(PracticeSessionDO::getId, id)
+ .eq(PracticeSessionDO::getTenantId, tenantId));
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
index bc4f7f18..216a9ed6 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
@@ -40,4 +40,15 @@ public interface ErrorCodeConstants {
ErrorCode UNSAFE_PROVIDER_PAYLOAD = new ErrorCode(1_005_003_004, "题库数据源返回不安全内容,请稍后重试");
ErrorCode QUESTION_NOT_VISIBLE = new ErrorCode(1_005_003_005, "题库数据源返回了不应出现的不可见题目");
+ // ========== 练习会话 1-005-003-006 ~ 1-005-003-019 ==========
+ ErrorCode SESSION_NOT_FOUND = new ErrorCode(1_005_003_006, "练习会话不存在");
+ ErrorCode SESSION_NOT_OWN = new ErrorCode(1_005_003_007, "无权访问该练习会话");
+ ErrorCode SESSION_EXPIRED = new ErrorCode(1_005_003_008, "练习会话已过期");
+ ErrorCode SESSION_ALREADY_SUBMITTED = new ErrorCode(1_005_003_009, "练习会话已提交,无法修改");
+ ErrorCode SESSION_CANCELLED = new ErrorCode(1_005_003_010, "练习会话已取消");
+ ErrorCode SESSION_DUPLICATE_CLIENT_ID = new ErrorCode(1_005_003_011, "客户端会话标识重复");
+ ErrorCode SESSION_IDEMPOTENCY_MISMATCH = new ErrorCode(1_005_003_018, "客户端会话标识已存在但请求参数不一致,请更换 clientSessionId 或使用相同参数重试");
+ ErrorCode SESSION_NO_ACTIVE = new ErrorCode(1_005_003_012, "当前没有进行中的练习会话");
+ ErrorCode SESSION_QUESTION_MISMATCH = new ErrorCode(1_005_003_013, "会话题目不匹配");
+
}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java
new file mode 100644
index 00000000..e249477b
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/SessionStatusEnum.java
@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.education.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 练习会话状态枚举。
+ *
+ * @author 恭学教育
+ */
+@Getter
+@AllArgsConstructor
+public enum SessionStatusEnum {
+
+ /** 进行中 — 学生可继续作答 */
+ ACTIVE("ACTIVE", "进行中"),
+
+ /** 已提交 — 学生已提交答案,待评分 */
+ SUBMITTED("SUBMITTED", "已提交"),
+
+ /** 已过期 — 会话超时失效 */
+ EXPIRED("EXPIRED", "已过期"),
+
+ /** 已取消 — 学生主动取消 */
+ CANCELLED("CANCELLED", "已取消");
+
+ /** 状态码 */
+ private final String code;
+
+ /** 状态名 */
+ private final String name;
+
+ /**
+ * 判断是否为终态,终态会话不可再被恢复。
+ */
+ public boolean isTerminal() {
+ return this == SUBMITTED || this == EXPIRED || this == CANCELLED;
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java
new file mode 100644
index 00000000..9d24fe98
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java
@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.module.education.service.practice;
+
+import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
+import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
+
+/**
+ * 练习会话服务接口。
+ *
+ * @author 恭学教育
+ */
+public interface PracticeSessionService {
+
+ /**
+ * 幂等创建练习会话。
+ * 同一 clientSessionId 重复调用返回已有会话(不创建新会话)。
+ *
+ * @param reqVO 创建请求
+ * @param userId 当前用户 ID(由安全上下文派生)
+ * @param tenantId 当前租户 ID(由安全上下文派生)
+ * @return 会话响应(含题目快照)
+ */
+ PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId);
+
+ /**
+ * 获取当前用户最近一条进行中的练习会话。
+ *
+ * @param userId 当前用户 ID
+ * @param tenantId 当前租户 ID
+ * @return 会话响应(含题目快照),无进行中会话时返回 null
+ */
+ PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId);
+
+ /**
+ * 按 ID 获取特定练习会话(必须验证所有权)。
+ *
+ * @param sessionId 会话 ID
+ * @param userId 当前用户 ID
+ * @param tenantId 当前租户 ID
+ * @return 会话响应(含题目快照)
+ * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 会话不存在或无权限
+ */
+ PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId);
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java
new file mode 100644
index 00000000..50645775
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java
@@ -0,0 +1,281 @@
+package cn.iocoder.yudao.module.education.service.practice;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
+import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
+import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
+import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
+import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
+import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
+import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
+
+/**
+ * 练习会话服务实现。
+ *
+ * @author 恭学教育
+ */
+@Service
+@Slf4j
+@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
+public class PracticeSessionServiceImpl implements PracticeSessionService {
+
+ private final PracticeSessionMapper sessionMapper;
+ private final PracticeQuestionMapper questionMapper;
+ private final QuestionCatalogProvider questionCatalogProvider;
+
+ public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
+ PracticeQuestionMapper questionMapper,
+ QuestionCatalogProvider questionCatalogProvider) {
+ this.sessionMapper = sessionMapper;
+ this.questionMapper = questionMapper;
+ this.questionCatalogProvider = questionCatalogProvider;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) {
+ // 1. Idempotent check: same tenant + clientSessionId exists → verify ownership before returning
+ PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
+ if (existing != null) {
+ if (!Objects.equals(existing.getUserId(), userId)) {
+ // Same tenant, same clientSessionId, but different user → ownership violation
+ throw exception(SESSION_NOT_FOUND);
+ }
+ if (!isSameFingerprint(existing, reqVO)) {
+ throw exception(SESSION_IDEMPOTENCY_MISMATCH);
+ }
+ return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
+ }
+
+ // 2. Fetch eligible questions — provider returns visible-only per contract
+ int requestedCount = reqVO.getQuestionCount();
+ List questions = fetchAndOrderQuestions(reqVO.getCollectionId(), reqVO.getNodeId(),
+ reqVO.getType(), reqVO.getDifficulty(), requestedCount);
+
+ // 2a. Reject underfilled sessions: fewer visible questions than requested
+ if (questions.size() < requestedCount) {
+ throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, questions.size(), requestedCount);
+ }
+
+ // 3. Create session with race-safe insert
+ PracticeSessionDO session = new PracticeSessionDO();
+ session.setTenantId(tenantId);
+ session.setUserId(userId);
+ session.setClientSessionId(reqVO.getClientSessionId());
+ session.setStatus("ACTIVE");
+ session.setQuestionCount(questions.size());
+ session.setCollectionId(reqVO.getCollectionId());
+ session.setNodeId(reqVO.getNodeId());
+ session.setType(reqVO.getType());
+ session.setDifficulty(reqVO.getDifficulty());
+ session.setVersion(1);
+
+ try {
+ sessionMapper.insert(session);
+ } catch (DuplicateKeyException e) {
+ // Race: another thread inserted the same tenant+clientSessionId between our check and insert.
+ // Reload and verify ownership + fingerprint match.
+ log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution",
+ reqVO.getClientSessionId(), tenantId);
+ PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
+ if (winner == null) {
+ // Defensive: should not happen if UK constraint triggered
+ throw exception(SESSION_DUPLICATE_CLIENT_ID);
+ }
+ if (!Objects.equals(winner.getUserId(), userId)) {
+ throw exception(SESSION_NOT_FOUND);
+ }
+ if (!isSameFingerprint(winner, reqVO)) {
+ throw exception(SESSION_IDEMPOTENCY_MISMATCH);
+ }
+ return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
+ }
+
+ // 4. Create question snapshots (server-determined order)
+ List questionDOs = new ArrayList<>(questions.size());
+ int seq = 1;
+ for (CatalogQuestionDTO q : questions) {
+ PracticeQuestionDO pq = new PracticeQuestionDO();
+ pq.setTenantId(tenantId);
+ pq.setSessionId(session.getId());
+ pq.setSequence(seq++);
+ pq.setQuestionId(q.getId());
+ pq.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
+ pq.setStem(q.getStem());
+ pq.setType(q.getType());
+ pq.setDifficulty(q.getDifficulty());
+ pq.setOptions(optionsToSafeJson(q.getOptions()));
+ pq.setIsAnswered(false);
+ questionDOs.add(pq);
+ }
+ questionMapper.insertBatch(questionDOs);
+
+ return buildSessionResp(session, questionDOs);
+ }
+
+
+ @Override
+ public PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId) {
+ PracticeSessionDO session = sessionMapper.selectLatestActiveByTenantAndUser(tenantId, userId);
+ if (session == null) {
+ return null;
+ }
+ List questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
+ return buildSessionResp(session, questions);
+ }
+
+ @Override
+ public PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId) {
+ PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);
+ if (session == null) {
+ throw exception(SESSION_NOT_FOUND);
+ }
+ if (!Objects.equals(session.getUserId(), userId)) {
+ throw exception(SESSION_NOT_OWN);
+ }
+ List questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
+ return buildSessionResp(session, questions);
+ }
+
+ // ========== internal helpers ==========
+
+ /**
+ * 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
+ */
+ /**
+ * 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
+ * 所有筛选条件(含 nodeId)完整转发到 provider,不做客户端裁剪。
+ */
+ private List fetchAndOrderQuestions(String collectionId, String nodeId,
+ String type, String difficulty, int count) {
+ List allQuestions = new ArrayList<>();
+ int pageNo = 1;
+ int pageSize = Math.min(count, 100);
+
+ while (allQuestions.size() < count) {
+ CatalogQuestionPageResult page = questionCatalogProvider.listQuestions(
+ collectionId, nodeId, type, difficulty, pageNo, pageSize);
+ if (CollUtil.isEmpty(page.getItems())) {
+ break;
+ }
+ allQuestions.addAll(page.getItems());
+ if (page.getItems().size() < pageSize) {
+ break;
+ }
+ pageNo++;
+ }
+
+ if (allQuestions.isEmpty()) {
+ throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, 0, count);
+ }
+
+ if (allQuestions.size() > count) {
+ allQuestions = allQuestions.subList(0, count);
+ }
+
+ allQuestions.sort(Comparator.comparing(CatalogQuestionDTO::getId, Comparator.nullsLast(String::compareTo))
+ .thenComparing(CatalogQuestionDTO::getContentVersion, Comparator.nullsLast(String::compareTo)));
+ return allQuestions;
+ }
+
+ /**
+ * 将选项列表转换为安全 JSON 字符串(不含 isCorrect)。
+ */
+ private String optionsToSafeJson(List options) {
+ if (CollUtil.isEmpty(options)) {
+ return "[]";
+ }
+ List