feat(education): save practice answers idempotently
This commit is contained in:
@@ -35,7 +35,7 @@ public class EducationCapabilityController {
|
||||
.module("education")
|
||||
.enabled(educationProperties.isEnabled())
|
||||
.version(educationProperties.getVersion())
|
||||
.capabilities(List.of("shell", "catalog", "questions", "practice-preview"))
|
||||
.capabilities(List.of("shell", "catalog", "questions", "practice-preview", "answer-save"))
|
||||
.build();
|
||||
return success(resp);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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.PracticeAnswerReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
||||
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;
|
||||
@@ -71,6 +73,20 @@ public class PracticeSessionController {
|
||||
return success(resp);
|
||||
}
|
||||
|
||||
// ========== 答案保存 ==========
|
||||
|
||||
@PutMapping("/practice-session/answer")
|
||||
@Operation(summary = "保存答案(幂等,支持安全重试)",
|
||||
description = "保存学生选择题目的答案。同一 idempotencyKey + 相同载荷返回首次成功结果。"
|
||||
+ "旧 clientSequence 或旧 expectedSessionVersion 拒绝覆盖。"
|
||||
+ "客户端根据响应中的 serverVersion 和 acceptedSequence 更新本地状态。")
|
||||
public CommonResult<PracticeAnswerRespVO> submitAnswer(@Valid @RequestBody PracticeAnswerReqVO reqVO) {
|
||||
Long userId = getUserId();
|
||||
Long tenantId = getTenantId();
|
||||
PracticeAnswerRespVO resp = practiceSessionService.submitAnswer(reqVO, userId, tenantId);
|
||||
return success(resp);
|
||||
}
|
||||
|
||||
// ========== security helpers ==========
|
||||
|
||||
private Long getUserId() {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 答案保存请求 VO。
|
||||
*
|
||||
* <p>所有字段由客户端提供。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 答案保存请求")
|
||||
@Data
|
||||
public class PracticeAnswerReqVO {
|
||||
|
||||
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
|
||||
@NotNull(message = "会话 ID 不能为空")
|
||||
private Long sessionId;
|
||||
|
||||
@Schema(description = "题目序号(1-based)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
|
||||
@NotNull(message = "题目序号不能为空")
|
||||
@Min(value = 1, message = "题目序号必须为正整数")
|
||||
private Integer questionSequence;
|
||||
|
||||
@Schema(description = "学生选择的答案(选项标签)", example = "A")
|
||||
@Size(max = 64, message = "答案长度不能超过 64 字符")
|
||||
private String selectedAnswer;
|
||||
|
||||
@Schema(description = "客户端幂等键(UUID)", requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
@NotBlank(message = "幂等键不能为空")
|
||||
@Size(max = 64, message = "幂等键长度不能超过 64 字符")
|
||||
private String idempotencyKey;
|
||||
|
||||
@Schema(description = "客户端命令序号(单调递增)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
|
||||
@NotNull(message = "客户端命令序号不能为空")
|
||||
@Min(value = 0, message = "客户端命令序号不能为负")
|
||||
private Integer clientSequence;
|
||||
|
||||
@Schema(description = "客户端期望的会话版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "期望会话版本号不能为空")
|
||||
@Min(value = 0, message = "期望会话版本号不能为负")
|
||||
private Integer expectedSessionVersion;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 答案保存响应 VO。
|
||||
*
|
||||
* <p>返回服务端确认的版本号和已接受的序号。客户端据此更新本地状态机:SAVED/SAVING/RETRYING/FAILED。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "用户 APP - 答案保存响应")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PracticeAnswerRespVO {
|
||||
|
||||
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
|
||||
private Long sessionId;
|
||||
|
||||
@Schema(description = "题目序号(1-based)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
|
||||
private Integer questionSequence;
|
||||
|
||||
@Schema(description = "服务端已确认的学生答案", example = "A")
|
||||
private String selectedAnswer;
|
||||
|
||||
@Schema(description = "服务端最新会话版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
|
||||
private Integer serverVersion;
|
||||
|
||||
@Schema(description = "服务端已接受的客户端命令序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
|
||||
private Integer acceptedSequence;
|
||||
|
||||
}
|
||||
@@ -35,6 +35,8 @@ public class PracticeSessionRespVO {
|
||||
@Schema(description = "客户端会话标识", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
private String clientSessionId;
|
||||
|
||||
@Schema(description = "会话级最后接受的客户端命令序号", example = "5")
|
||||
private Integer lastClientSequence;
|
||||
@Schema(description = "会话题目列表(安全视图)")
|
||||
private List<PracticeQuestionRespVO> questions;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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>同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
|
||||
* requestHash 用于检测相同键不同载荷的冲突。
|
||||
* responseJson 存储首次成功响应,用于超时重试重放。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_answer_idempotency")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AnswerIdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 答题用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 操作类型:SUBMIT_ANSWER */
|
||||
private String operation;
|
||||
|
||||
/** 客户端幂等键(UUID) */
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 请求载荷 SHA-256 哈希 */
|
||||
private String requestHash;
|
||||
|
||||
/** 会话 ID */
|
||||
private Long sessionId;
|
||||
|
||||
/** 题目 ID */
|
||||
private String questionId;
|
||||
|
||||
/** 学生已选答案 */
|
||||
private String selectedAnswer;
|
||||
|
||||
/** 状态:ACCEPTED / CONFLICT */
|
||||
private String status;
|
||||
|
||||
/** 首次成功响应 JSON(用于重试重放) */
|
||||
private String responseJson;
|
||||
|
||||
}
|
||||
@@ -54,4 +54,7 @@ public class PracticeQuestionDO extends TenantBaseDO {
|
||||
/** 是否已作答 */
|
||||
private Boolean isAnswered;
|
||||
|
||||
|
||||
/** 最后接受的客户端命令序号(单调递增),用于拒绝乱序/过期请求 */
|
||||
private Integer clientSequence;
|
||||
}
|
||||
|
||||
@@ -53,4 +53,7 @@ public class PracticeSessionDO extends TenantBaseDO {
|
||||
/** 乐观锁版本号 */
|
||||
private Integer version;
|
||||
|
||||
/** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */
|
||||
private Integer lastClientSequence;
|
||||
|
||||
}
|
||||
|
||||
@@ -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.AnswerIdempotencyDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 答案命令幂等记录 Mapper。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface AnswerIdempotencyMapper extends BaseMapperX<AnswerIdempotencyDO> {
|
||||
|
||||
/**
|
||||
* 按租户、用户、操作、幂等键查找记录。
|
||||
*/
|
||||
default AnswerIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<AnswerIdempotencyDO>()
|
||||
.eq(AnswerIdempotencyDO::getTenantId, tenantId)
|
||||
.eq(AnswerIdempotencyDO::getUserId, userId)
|
||||
.eq(AnswerIdempotencyDO::getOperation, operation)
|
||||
.eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,4 +25,30 @@ public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO>
|
||||
.orderByAsc(PracticeQuestionDO::getSequence));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按会话 ID 和题目序号查找单题快照。
|
||||
*/
|
||||
default PracticeQuestionDO selectBySessionIdAndSequence(Long sessionId, Integer sequence) {
|
||||
return selectOne(new LambdaQueryWrapperX<PracticeQuestionDO>()
|
||||
.eq(PracticeQuestionDO::getSessionId, sessionId)
|
||||
.eq(PracticeQuestionDO::getSequence, sequence));
|
||||
}
|
||||
|
||||
/**
|
||||
* CAS 条件更新题目答案。仅当 tenant 匹配且当前 clientSequence 小于传入值(或为 NULL)
|
||||
* 时才执行更新。返回受影响行数(1 = 成功,0 = 序列过期或租户不匹配)。
|
||||
*/
|
||||
default int updateAnswerIfNewer(Long questionId, Long tenantId, String selectedAnswer,
|
||||
Boolean isAnswered, Integer clientSequence) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<PracticeQuestionDO>()
|
||||
.eq(PracticeQuestionDO::getId, questionId)
|
||||
.eq(PracticeQuestionDO::getTenantId, tenantId)
|
||||
.and(w -> w.isNull(PracticeQuestionDO::getClientSequence)
|
||||
.or().lt(PracticeQuestionDO::getClientSequence, clientSequence))
|
||||
.set(PracticeQuestionDO::getSelectedAnswer, selectedAnswer)
|
||||
.set(PracticeQuestionDO::getIsAnswered, isAnswered)
|
||||
.set(PracticeQuestionDO::getClientSequence, clientSequence));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@@ -43,4 +44,23 @@ public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
|
||||
.eq(PracticeSessionDO::getTenantId, tenantId));
|
||||
}
|
||||
|
||||
/**
|
||||
* CAS 乐观锁:原子递增版本号 + 更新 lastClientSequence。
|
||||
* 仅当租户、用户、状态、版本全部匹配时才执行。
|
||||
*
|
||||
* @return 受影响行数(1 = 成功,0 = CAS 失败)
|
||||
*/
|
||||
default int casIncrementVersion(Long id, Long tenantId, Long userId,
|
||||
Integer expectedVersion, Integer lastClientSequence) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<PracticeSessionDO>()
|
||||
.eq(PracticeSessionDO::getId, id)
|
||||
.eq(PracticeSessionDO::getTenantId, tenantId)
|
||||
.eq(PracticeSessionDO::getUserId, userId)
|
||||
.eq(PracticeSessionDO::getStatus, "ACTIVE")
|
||||
.eq(PracticeSessionDO::getVersion, expectedVersion)
|
||||
.setSql("version = version + 1")
|
||||
.set(PracticeSessionDO::getLastClientSequence, lastClientSequence));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -51,4 +51,18 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode SESSION_NO_ACTIVE = new ErrorCode(1_005_003_012, "当前没有进行中的练习会话");
|
||||
ErrorCode SESSION_QUESTION_MISMATCH = new ErrorCode(1_005_003_013, "会话题目不匹配");
|
||||
|
||||
|
||||
// ========== 答案保存 1-005-003-014 ~ 1-005-003-019 ==========
|
||||
ErrorCode ANSWER_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_003_014, "幂等键相同但请求内容不一致,请刷新重试");
|
||||
ErrorCode ANSWER_STALE_VERSION = new ErrorCode(1_005_003_015, "会话版本已更新,请刷新后重试(期望版本:{},当前版本:{})");
|
||||
ErrorCode ANSWER_STALE_CLIENT_SEQUENCE = new ErrorCode(1_005_003_016, "客户端命令序号已过期,当前序号 {} 不大于已接受的 {}");
|
||||
ErrorCode ANSWER_OPTION_INVALID = new ErrorCode(1_005_003_017, "无效的选项:{},请刷新题目后重试");
|
||||
ErrorCode ANSWER_QUESTION_NOT_IN_SESSION = new ErrorCode(1_005_003_019, "题目不属于当前会话");
|
||||
|
||||
// ========== 答案保存 1-005-003-020 ~ 1-005-003-029 ==========
|
||||
ErrorCode ANSWER_SESSION_VERSION_NULL = new ErrorCode(1_005_003_020, "会话版本号异常,请联系管理员");
|
||||
ErrorCode ANSWER_SESSION_VERSION_OVERFLOW = new ErrorCode(1_005_003_021, "会话版本号溢出,请新建会话");
|
||||
ErrorCode ANSWER_STALE_CROSS_QUESTION_SEQUENCE = new ErrorCode(1_005_003_022, "客户端命令序号过期(跨题目),当前序号 {} 不大于会话已接受的 {}");
|
||||
ErrorCode CATALOG_UPSTREAM_OPTIONS_MALFORMED = new ErrorCode(1_005_003_023, "题目选项数据格式异常,请稍后重试");
|
||||
ErrorCode ANSWER_FIELD_TOO_LONG = new ErrorCode(1_005_003_024, "请求字段过长:{}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.service.practice;
|
||||
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
|
||||
@@ -41,4 +43,18 @@ public interface PracticeSessionService {
|
||||
*/
|
||||
PracticeSessionRespVO getSession(Long sessionId, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 幂等保存答案命令。
|
||||
*
|
||||
* <p>同一 idempotencyKey + 相同载荷 → 返回首次成功响应(超时重试安全)。
|
||||
* 同一 idempotencyKey + 不同载荷 → 业务冲突。
|
||||
* 旧 clientSequence 或旧 expectedSessionVersion → 拒绝覆盖更新。</p>
|
||||
*
|
||||
* @param reqVO 答案保存请求
|
||||
* @param userId 当前用户 ID(由安全上下文派生)
|
||||
* @param tenantId 当前租户 ID(由安全上下文派生)
|
||||
* @return 答案保存响应(含最新版本号)
|
||||
*/
|
||||
PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package cn.iocoder.yudao.module.education.service.practice;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
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.AnswerIdempotencyDO;
|
||||
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.AnswerIdempotencyMapper;
|
||||
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;
|
||||
@@ -16,12 +19,14 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.*;
|
||||
|
||||
|
||||
/**
|
||||
* 练习会话服务实现。
|
||||
*
|
||||
@@ -34,13 +39,16 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
|
||||
private final PracticeSessionMapper sessionMapper;
|
||||
private final PracticeQuestionMapper questionMapper;
|
||||
private final AnswerIdempotencyMapper idempotencyMapper;
|
||||
private final QuestionCatalogProvider questionCatalogProvider;
|
||||
|
||||
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
|
||||
PracticeQuestionMapper questionMapper,
|
||||
AnswerIdempotencyMapper idempotencyMapper,
|
||||
QuestionCatalogProvider questionCatalogProvider) {
|
||||
this.sessionMapper = sessionMapper;
|
||||
this.questionMapper = questionMapper;
|
||||
this.idempotencyMapper = idempotencyMapper;
|
||||
this.questionCatalogProvider = questionCatalogProvider;
|
||||
}
|
||||
|
||||
@@ -150,6 +158,139 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
return buildSessionResp(session, questions);
|
||||
}
|
||||
|
||||
// ========== answer submission ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId) {
|
||||
// 1. Compute request hash for content-based idempotency (lightweight, no DB)
|
||||
String requestHash = computeRequestHash(reqVO);
|
||||
|
||||
// 2. Check idempotency FIRST — before any business validation (replay safety)
|
||||
AnswerIdempotencyDO existingRecord = idempotencyMapper.selectByKey(
|
||||
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
|
||||
if (existingRecord != null) {
|
||||
// Same key + same hash → replay original committed response
|
||||
if (Objects.equals(existingRecord.getRequestHash(), requestHash)) {
|
||||
return JsonUtils.parseObject(existingRecord.getResponseJson(), PracticeAnswerRespVO.class);
|
||||
}
|
||||
// Same key + different hash → conflict
|
||||
throw exception(ANSWER_IDEMPOTENCY_CONFLICT);
|
||||
}
|
||||
|
||||
// 3. Load and validate session ownership, status, version
|
||||
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(reqVO.getSessionId(), tenantId);
|
||||
if (session == null) {
|
||||
throw exception(SESSION_NOT_FOUND);
|
||||
}
|
||||
if (!Objects.equals(session.getUserId(), userId)) {
|
||||
throw exception(SESSION_NOT_OWN);
|
||||
}
|
||||
if (!"ACTIVE".equals(session.getStatus())) {
|
||||
throw exception(switch (session.getStatus()) {
|
||||
case "SUBMITTED" -> SESSION_ALREADY_SUBMITTED;
|
||||
case "EXPIRED" -> SESSION_EXPIRED;
|
||||
case "CANCELLED" -> SESSION_CANCELLED;
|
||||
default -> SESSION_NOT_FOUND;
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Version guard: null/overflow defensive check (finding #5)
|
||||
if (session.getVersion() == null) {
|
||||
throw exception(ANSWER_SESSION_VERSION_NULL);
|
||||
}
|
||||
if (!Objects.equals(session.getVersion(), reqVO.getExpectedSessionVersion())) {
|
||||
throw exception(ANSWER_STALE_VERSION, reqVO.getExpectedSessionVersion(), session.getVersion());
|
||||
}
|
||||
if (session.getVersion() == Integer.MAX_VALUE) {
|
||||
throw exception(ANSWER_SESSION_VERSION_OVERFLOW);
|
||||
}
|
||||
|
||||
// 5. Session-level clientSequence guard (finding #3)
|
||||
if (session.getLastClientSequence() != null
|
||||
&& reqVO.getClientSequence() <= session.getLastClientSequence()) {
|
||||
throw exception(ANSWER_STALE_CROSS_QUESTION_SEQUENCE,
|
||||
reqVO.getClientSequence(), session.getLastClientSequence());
|
||||
}
|
||||
|
||||
// 6. Load question, validate it belongs to session
|
||||
PracticeQuestionDO question = questionMapper.selectBySessionIdAndSequence(
|
||||
reqVO.getSessionId(), reqVO.getQuestionSequence());
|
||||
if (question == null) {
|
||||
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
|
||||
}
|
||||
|
||||
// 7. Validate selected answer against question options (fail-closed, finding #4)
|
||||
if (reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty()) {
|
||||
validateOption(question, reqVO.getSelectedAnswer());
|
||||
}
|
||||
|
||||
// 8. Compute anticipated response
|
||||
int newVersion = session.getVersion() + 1;
|
||||
PracticeAnswerRespVO response = PracticeAnswerRespVO.builder()
|
||||
.sessionId(session.getId())
|
||||
.questionSequence(reqVO.getQuestionSequence())
|
||||
.selectedAnswer(reqVO.getSelectedAnswer())
|
||||
.serverVersion(newVersion)
|
||||
.acceptedSequence(reqVO.getClientSequence())
|
||||
.build();
|
||||
|
||||
// 9. Insert idempotency record (reserve key — rollback on failure)
|
||||
AnswerIdempotencyDO idempotency = new AnswerIdempotencyDO();
|
||||
idempotency.setTenantId(tenantId);
|
||||
idempotency.setUserId(userId);
|
||||
idempotency.setOperation("SUBMIT_ANSWER");
|
||||
idempotency.setIdempotencyKey(reqVO.getIdempotencyKey());
|
||||
idempotency.setRequestHash(requestHash);
|
||||
idempotency.setSessionId(session.getId());
|
||||
idempotency.setQuestionId(question.getQuestionId());
|
||||
idempotency.setSelectedAnswer(reqVO.getSelectedAnswer());
|
||||
idempotency.setStatus("ACCEPTED");
|
||||
idempotency.setResponseJson(JsonUtils.toJsonString(response));
|
||||
|
||||
try {
|
||||
idempotencyMapper.insert(idempotency);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// Race: another request with same idempotency key arrived between select and insert
|
||||
log.warn("DuplicateKeyException on idempotencyKey={} for user={} tenant={}",
|
||||
reqVO.getIdempotencyKey(), userId, tenantId);
|
||||
AnswerIdempotencyDO winner = idempotencyMapper.selectByKey(
|
||||
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
|
||||
if (winner != null && Objects.equals(winner.getRequestHash(), requestHash)) {
|
||||
return JsonUtils.parseObject(winner.getResponseJson(), PracticeAnswerRespVO.class);
|
||||
}
|
||||
throw exception(ANSWER_IDEMPOTENCY_CONFLICT);
|
||||
}
|
||||
|
||||
// 10. CAS session version + lastClientSequence atomically (finding #2)
|
||||
int casResult = sessionMapper.casIncrementVersion(session.getId(), tenantId, userId,
|
||||
session.getVersion(), reqVO.getClientSequence());
|
||||
if (casResult == 0) {
|
||||
// Version changed or session no longer ACTIVE — rollback + throw
|
||||
throw exception(ANSWER_STALE_VERSION, session.getVersion(),
|
||||
"已被其他请求更新,请刷新重试");
|
||||
}
|
||||
|
||||
// 11. CAS update question answer (finding #1: conditional, affected-row result)
|
||||
int updated = questionMapper.updateAnswerIfNewer(question.getId(), tenantId,
|
||||
reqVO.getSelectedAnswer(),
|
||||
reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty(),
|
||||
reqVO.getClientSequence());
|
||||
if (updated == 0) {
|
||||
// Reload and classify staleness
|
||||
PracticeQuestionDO reloaded = questionMapper.selectById(question.getId());
|
||||
if (reloaded != null && reloaded.getClientSequence() != null
|
||||
&& reqVO.getClientSequence() <= reloaded.getClientSequence()) {
|
||||
throw exception(ANSWER_STALE_CLIENT_SEQUENCE,
|
||||
reqVO.getClientSequence(), reloaded.getClientSequence());
|
||||
}
|
||||
// tenant guard failed or question deleted — treat as not-in-session
|
||||
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ========== internal helpers ==========
|
||||
|
||||
/**
|
||||
@@ -224,6 +365,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
.questionCount(session.getQuestionCount())
|
||||
.version(session.getVersion())
|
||||
.clientSessionId(session.getClientSessionId())
|
||||
.lastClientSequence(session.getLastClientSequence())
|
||||
.questions(questionVOs)
|
||||
.build();
|
||||
}
|
||||
@@ -278,4 +420,60 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
&& Objects.equals(existing.getDifficulty(), reqVO.getDifficulty())
|
||||
&& Objects.equals(existing.getQuestionCount(), reqVO.getQuestionCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of canonical request payload for idempotency content checking.
|
||||
* Fields are ordered deterministically; same payload always produces same hash.
|
||||
*/
|
||||
private String computeRequestHash(PracticeAnswerReqVO reqVO) {
|
||||
Map<String, Object> canonical = new TreeMap<>();
|
||||
canonical.put("clientSequence", reqVO.getClientSequence());
|
||||
canonical.put("expectedSessionVersion", reqVO.getExpectedSessionVersion());
|
||||
canonical.put("questionSequence", reqVO.getQuestionSequence());
|
||||
canonical.put("selectedAnswer", reqVO.getSelectedAnswer());
|
||||
canonical.put("sessionId", reqVO.getSessionId());
|
||||
return DigestUtil.sha256Hex(JsonUtils.toJsonString(canonical));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the selected answer exists among the question's options.
|
||||
* Fail-closed: malformed options data → immediate rejection.
|
||||
*/
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private void validateOption(PracticeQuestionDO question, String selectedAnswer) {
|
||||
// null or blank options → upstream malformed
|
||||
if (question.getOptions() == null || question.getOptions().isBlank()) {
|
||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> options;
|
||||
try {
|
||||
options = (List) JsonUtils.parseArray(question.getOptions(), Map.class);
|
||||
} catch (Exception e) {
|
||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||
}
|
||||
|
||||
// non-array or empty → malformed
|
||||
if (options == null || options.isEmpty()) {
|
||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||
}
|
||||
|
||||
// validate every element: non-null, has non-blank label
|
||||
for (Map<String, Object> o : options) {
|
||||
if (o == null) {
|
||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||
}
|
||||
Object label = o.get("label");
|
||||
if (label == null || (label instanceof String s && s.isBlank())) {
|
||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||
}
|
||||
}
|
||||
|
||||
// validate selectedAnswer exists in options
|
||||
boolean valid = options.stream()
|
||||
.anyMatch(o -> Objects.equals(o.get("label"), selectedAnswer));
|
||||
if (!valid) {
|
||||
throw exception(ANSWER_OPTION_INVALID, selectedAnswer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user