feat(education): create resumable practice sessions

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

View File

@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 练习会话 Controller — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
* <p>会话和答题数据写入 MySQL从不经过 Scalar。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 练习会话")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class PracticeSessionController {
@Resource
private PracticeSessionService practiceSessionService;
// ========== 会话管理 ==========
@PostMapping("/practice-session/create")
@Operation(summary = "创建练习会话(幂等)",
description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
+ "题目顺序由服务端固定,选项不含答案标记。")
public CommonResult<PracticeSessionRespVO> createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
Long userId = getUserId();
Long tenantId = getTenantId();
PracticeSessionRespVO resp = practiceSessionService.createPracticeSession(reqVO, userId, tenantId);
return success(resp);
}
@GetMapping("/practice-session/current")
@Operation(summary = "获取当前进行中的练习会话",
description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。")
public CommonResult<PracticeSessionRespVO> currentSession() {
Long userId = getUserId();
Long tenantId = getTenantId();
PracticeSessionRespVO resp = practiceSessionService.getCurrentSession(userId, tenantId);
return success(resp);
}
@GetMapping("/practice-session/get")
@Operation(summary = "获取指定练习会话",
description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。")
public CommonResult<PracticeSessionRespVO> getSession(
@Parameter(description = "会话 ID", required = true) @RequestParam Long id) {
Long userId = getUserId();
Long tenantId = getTenantId();
PracticeSessionRespVO resp = practiceSessionService.getSession(id, userId, tenantId);
return success(resp);
}
// ========== security helpers ==========
private Long getUserId() {
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
throw exception(UNAUTHORIZED);
}
return userId;
}
private Long getTenantId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.getTenantId();
}
}

View File

@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 练习会话题目响应 VO — 安全视图,不含答案/解析。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习会话题目响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeQuestionRespVO {
@Schema(description = "题目序号1-based", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer sequence;
@Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
private String questionId;
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几")
private String stem;
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项列表(不含正确性标记)")
private List<OptionVO> options;
@Schema(description = "学生已选答案", example = "A")
private String selectedAnswer;
@Schema(description = "是否已作答", example = "true")
private Boolean isAnswered;
@Schema(description = "快照时的内容版本", example = "v2")
private String contentVersion;
/**
* 选项 VO — 仅 label、content、order不含 isCorrect。
*/
@Schema(description = "选项")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class OptionVO {
@Schema(description = "选项标签", example = "A")
private String label;
@Schema(description = "选项内容", example = "2")
private String content;
@Schema(description = "排序", example = "1.0")
private Double order;
}
}

View File

@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 创建练习会话请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 创建练习会话请求")
@Data
public class PracticeSessionCreateReqVO {
@Schema(description = "客户端生成的会话标识UUID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "客户端会话标识不能为空")
private String clientSessionId;
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001")
@NotBlank(message = "题集 ID 不能为空")
private String collectionId;
@Schema(description = "目录节点 ID", example = "node-001")
private String nodeId;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
@NotNull(message = "题量不能为空")
@Min(value = 1, message = "题量至少为 1")
@Max(value = 1000, message = "题量最多为 1000")
private Integer questionCount = 10;
}

View File

@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 练习会话响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习会话响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeSessionRespVO {
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
private Long sessionId;
@Schema(description = "会话状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE")
private String status;
@Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer questionCount;
@Schema(description = "服务端版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer version;
@Schema(description = "客户端会话标识", example = "550e8400-e29b-41d4-a716-446655440000")
private String clientSessionId;
@Schema(description = "会话题目列表(安全视图)")
private List<PracticeQuestionRespVO> questions;
}

View File

@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 练习会话题目快照 DO。
*
* <p>会话创建时从题目源获取当前题目内容并快照固化。后续改题不影响本次练习。
* 选项以 JSON 存储,不含 isCorrect 字段以确保前端安全。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_question")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeQuestionDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 会话 ID */
private Long sessionId;
/** 题目序号1-based服务端固定顺序 */
private Integer sequence;
/** 原始题目 ID */
private String questionId;
/** 快照时的题目内容版本 */
private String contentVersion;
/** 题干快照 */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 选项快照 JSON — 仅含 label、content、order不含 isCorrect */
private String options;
/** 学生已选答案 */
private String selectedAnswer;
/** 是否已作答 */
private Boolean isAnswered;
}

View File

@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 练习会话 DO。
*
* <p>归属于当前租户和 Member 用户。clientSessionId 由客户端生成,服务端保证 per-tenant 唯一。
* version 字段用于乐观锁并发控制。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_session")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeSessionDO extends TenantBaseDO {
/** 会话主键 */
@TableId
private Long id;
/** Member 用户编号(由安全上下文派生,非请求参数) */
private Long userId;
/** 客户端生成的会话标识UUID用于幂等创建 */
private String clientSessionId;
/**
* 会话状态ACTIVE | SUBMITTED | EXPIRED | CANCELLED
*/
private String status;
/** 题目总数 */
private Integer questionCount;
/** 源题集 ID */
private String collectionId;
/** 源目录节点 ID */
private String nodeId;
/** 筛选题型 */
private String type;
/** 筛选难度 */
private String difficulty;
/** 乐观锁版本号 */
private Integer version;
}

View File

@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 练习会话题目快照 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO> {
/**
* 按会话 ID 和序号查询题目快照列表,按 sequence 升序。
*/
default List<PracticeQuestionDO> selectBySessionIdOrderBySequence(Long sessionId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.orderByAsc(PracticeQuestionDO::getSequence));
}
}

View File

@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import org.apache.ibatis.annotations.Mapper;
/**
* 练习会话 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
/**
* 根据租户和客户端会话 ID 查找会话(幂等创建检查)。
*/
default PracticeSessionDO selectByTenantAndClientSessionId(Long tenantId, String clientSessionId) {
return selectOne(new LambdaQueryWrapperX<PracticeSessionDO>()
.eq(PracticeSessionDO::getTenantId, tenantId)
.eq(PracticeSessionDO::getClientSessionId, clientSessionId));
}
/**
* 查找当前租户+用户最近的一条 ACTIVE 会话(恢复用)。
*/
default PracticeSessionDO selectLatestActiveByTenantAndUser(Long tenantId, Long userId) {
return selectOne(new LambdaQueryWrapperX<PracticeSessionDO>()
.eq(PracticeSessionDO::getTenantId, tenantId)
.eq(PracticeSessionDO::getUserId, userId)
.eq(PracticeSessionDO::getStatus, "ACTIVE")
.orderByDesc(PracticeSessionDO::getId)
.last("LIMIT 1"));
}
/**
* 根据 ID 和租户查找(跨租户隔离)。
*/
default PracticeSessionDO selectByIdAndTenant(Long id, Long tenantId) {
return selectOne(new LambdaQueryWrapperX<PracticeSessionDO>()
.eq(PracticeSessionDO::getId, id)
.eq(PracticeSessionDO::getTenantId, tenantId));
}
}

View File

@@ -40,4 +40,15 @@ public interface ErrorCodeConstants {
ErrorCode UNSAFE_PROVIDER_PAYLOAD = new ErrorCode(1_005_003_004, "题库数据源返回不安全内容,请稍后重试");
ErrorCode QUESTION_NOT_VISIBLE = new ErrorCode(1_005_003_005, "题库数据源返回了不应出现的不可见题目");
// ========== 练习会话 1-005-003-006 ~ 1-005-003-019 ==========
ErrorCode SESSION_NOT_FOUND = new ErrorCode(1_005_003_006, "练习会话不存在");
ErrorCode SESSION_NOT_OWN = new ErrorCode(1_005_003_007, "无权访问该练习会话");
ErrorCode SESSION_EXPIRED = new ErrorCode(1_005_003_008, "练习会话已过期");
ErrorCode SESSION_ALREADY_SUBMITTED = new ErrorCode(1_005_003_009, "练习会话已提交,无法修改");
ErrorCode SESSION_CANCELLED = new ErrorCode(1_005_003_010, "练习会话已取消");
ErrorCode SESSION_DUPLICATE_CLIENT_ID = new ErrorCode(1_005_003_011, "客户端会话标识重复");
ErrorCode SESSION_IDEMPOTENCY_MISMATCH = new ErrorCode(1_005_003_018, "客户端会话标识已存在但请求参数不一致,请更换 clientSessionId 或使用相同参数重试");
ErrorCode SESSION_NO_ACTIVE = new ErrorCode(1_005_003_012, "当前没有进行中的练习会话");
ErrorCode SESSION_QUESTION_MISMATCH = new ErrorCode(1_005_003_013, "会话题目不匹配");
}

View File

@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.education.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 练习会话状态枚举。
*
* @author 恭学教育
*/
@Getter
@AllArgsConstructor
public enum SessionStatusEnum {
/** 进行中 — 学生可继续作答 */
ACTIVE("ACTIVE", "进行中"),
/** 已提交 — 学生已提交答案,待评分 */
SUBMITTED("SUBMITTED", "已提交"),
/** 已过期 — 会话超时失效 */
EXPIRED("EXPIRED", "已过期"),
/** 已取消 — 学生主动取消 */
CANCELLED("CANCELLED", "已取消");
/** 状态码 */
private final String code;
/** 状态名 */
private final String name;
/**
* 判断是否为终态,终态会话不可再被恢复。
*/
public boolean isTerminal() {
return this == SUBMITTED || this == EXPIRED || this == CANCELLED;
}
}

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
/**
* 练习会话服务接口。
*
* @author 恭学教育
*/
public interface PracticeSessionService {
/**
* 幂等创建练习会话。
* 同一 clientSessionId 重复调用返回已有会话(不创建新会话)。
*
* @param reqVO 创建请求
* @param userId 当前用户 ID由安全上下文派生
* @param tenantId 当前租户 ID由安全上下文派生
* @return 会话响应(含题目快照)
*/
PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId);
/**
* 获取当前用户最近一条进行中的练习会话。
*
* @param userId 当前用户 ID
* @param tenantId 当前租户 ID
* @return 会话响应(含题目快照),无进行中会话时返回 null
*/
PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId);
/**
* 按 ID 获取特定练习会话(必须验证所有权)。
*
* @param sessionId 会话 ID
* @param userId 当前用户 ID
* @param tenantId 当前租户 ID
* @return 会话响应(含题目快照)
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 会话不存在或无权限
*/
PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId);
}

View File

@@ -0,0 +1,281 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
/**
* 练习会话服务实现。
*
* @author 恭学教育
*/
@Service
@Slf4j
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class PracticeSessionServiceImpl implements PracticeSessionService {
private final PracticeSessionMapper sessionMapper;
private final PracticeQuestionMapper questionMapper;
private final QuestionCatalogProvider questionCatalogProvider;
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper,
QuestionCatalogProvider questionCatalogProvider) {
this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper;
this.questionCatalogProvider = questionCatalogProvider;
}
@Override
@Transactional(rollbackFor = Exception.class)
public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) {
// 1. Idempotent check: same tenant + clientSessionId exists → verify ownership before returning
PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
if (existing != null) {
if (!Objects.equals(existing.getUserId(), userId)) {
// Same tenant, same clientSessionId, but different user → ownership violation
throw exception(SESSION_NOT_FOUND);
}
if (!isSameFingerprint(existing, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
}
// 2. Fetch eligible questions — provider returns visible-only per contract
int requestedCount = reqVO.getQuestionCount();
List<CatalogQuestionDTO> questions = fetchAndOrderQuestions(reqVO.getCollectionId(), reqVO.getNodeId(),
reqVO.getType(), reqVO.getDifficulty(), requestedCount);
// 2a. Reject underfilled sessions: fewer visible questions than requested
if (questions.size() < requestedCount) {
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, questions.size(), requestedCount);
}
// 3. Create session with race-safe insert
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId(reqVO.getClientSessionId());
session.setStatus("ACTIVE");
session.setQuestionCount(questions.size());
session.setCollectionId(reqVO.getCollectionId());
session.setNodeId(reqVO.getNodeId());
session.setType(reqVO.getType());
session.setDifficulty(reqVO.getDifficulty());
session.setVersion(1);
try {
sessionMapper.insert(session);
} catch (DuplicateKeyException e) {
// Race: another thread inserted the same tenant+clientSessionId between our check and insert.
// Reload and verify ownership + fingerprint match.
log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution",
reqVO.getClientSessionId(), tenantId);
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
if (winner == null) {
// Defensive: should not happen if UK constraint triggered
throw exception(SESSION_DUPLICATE_CLIENT_ID);
}
if (!Objects.equals(winner.getUserId(), userId)) {
throw exception(SESSION_NOT_FOUND);
}
if (!isSameFingerprint(winner, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
}
// 4. Create question snapshots (server-determined order)
List<PracticeQuestionDO> questionDOs = new ArrayList<>(questions.size());
int seq = 1;
for (CatalogQuestionDTO q : questions) {
PracticeQuestionDO pq = new PracticeQuestionDO();
pq.setTenantId(tenantId);
pq.setSessionId(session.getId());
pq.setSequence(seq++);
pq.setQuestionId(q.getId());
pq.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
pq.setStem(q.getStem());
pq.setType(q.getType());
pq.setDifficulty(q.getDifficulty());
pq.setOptions(optionsToSafeJson(q.getOptions()));
pq.setIsAnswered(false);
questionDOs.add(pq);
}
questionMapper.insertBatch(questionDOs);
return buildSessionResp(session, questionDOs);
}
@Override
public PracticeSessionRespVO getCurrentSession(Long userId, Long tenantId) {
PracticeSessionDO session = sessionMapper.selectLatestActiveByTenantAndUser(tenantId, userId);
if (session == null) {
return null;
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
return buildSessionResp(session, questions);
}
@Override
public PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId) {
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);
if (session == null) {
throw exception(SESSION_NOT_FOUND);
}
if (!Objects.equals(session.getUserId(), userId)) {
throw exception(SESSION_NOT_OWN);
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
return buildSessionResp(session, questions);
}
// ========== internal helpers ==========
/**
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
*/
/**
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
* 所有筛选条件(含 nodeId完整转发到 provider不做客户端裁剪。
*/
private List<CatalogQuestionDTO> fetchAndOrderQuestions(String collectionId, String nodeId,
String type, String difficulty, int count) {
List<CatalogQuestionDTO> allQuestions = new ArrayList<>();
int pageNo = 1;
int pageSize = Math.min(count, 100);
while (allQuestions.size() < count) {
CatalogQuestionPageResult page = questionCatalogProvider.listQuestions(
collectionId, nodeId, type, difficulty, pageNo, pageSize);
if (CollUtil.isEmpty(page.getItems())) {
break;
}
allQuestions.addAll(page.getItems());
if (page.getItems().size() < pageSize) {
break;
}
pageNo++;
}
if (allQuestions.isEmpty()) {
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, 0, count);
}
if (allQuestions.size() > count) {
allQuestions = allQuestions.subList(0, count);
}
allQuestions.sort(Comparator.comparing(CatalogQuestionDTO::getId, Comparator.nullsLast(String::compareTo))
.thenComparing(CatalogQuestionDTO::getContentVersion, Comparator.nullsLast(String::compareTo)));
return allQuestions;
}
/**
* 将选项列表转换为安全 JSON 字符串(不含 isCorrect
*/
private String optionsToSafeJson(List<CatalogQuestionDTO.QuestionOptionDTO> options) {
if (CollUtil.isEmpty(options)) {
return "[]";
}
List<Map<String, Object>> safeOptions = options.stream()
.map(o -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("label", o.getLabel());
m.put("content", o.getContent());
m.put("order", o.getOrder());
return m;
})
.collect(Collectors.toList());
return JsonUtils.toJsonString(safeOptions);
}
/**
* 构建会话响应 VO。
*/
private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> questionVOs = questions.stream()
.map(this::buildQuestionResp)
.collect(Collectors.toList());
return PracticeSessionRespVO.builder()
.sessionId(session.getId())
.status(session.getStatus())
.questionCount(session.getQuestionCount())
.version(session.getVersion())
.clientSessionId(session.getClientSessionId())
.questions(questionVOs)
.build();
}
/**
* 构建单题响应 VO — 从快照安全还原。
*/
private PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) {
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
return PracticeQuestionRespVO.builder()
.sequence(pq.getSequence())
.questionId(pq.getQuestionId())
.stem(pq.getStem())
.type(pq.getType())
.difficulty(pq.getDifficulty())
.options(options)
.selectedAnswer(pq.getSelectedAnswer())
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
.contentVersion(pq.getContentVersion())
.build();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> raw = (List) JsonUtils.parseArray(optionsJson, Map.class);
if (raw == null) {
return Collections.emptyList();
}
return raw.stream()
.map(m -> PracticeQuestionRespVO.OptionVO.builder()
.label((String) m.get("label"))
.content((String) m.get("content"))
.order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null)
.build())
.collect(Collectors.toList());
}
/**
* Verify that the existing session's immutable selection criteria match the current request.
* Used during race-resolution: if a concurrent insert wins, the calling thread's payload
* must be identical, otherwise it's an idempotency mismatch.
*/
private boolean isSameFingerprint(PracticeSessionDO existing, PracticeSessionCreateReqVO reqVO) {
return Objects.equals(existing.getCollectionId(), reqVO.getCollectionId())
&& Objects.equals(existing.getNodeId(), reqVO.getNodeId())
&& Objects.equals(existing.getType(), reqVO.getType())
&& Objects.equals(existing.getDifficulty(), reqVO.getDifficulty())
&& Objects.equals(existing.getQuestionCount(), reqVO.getQuestionCount());
}
}