feat(education): create resumable practice sessions
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user