feat(education): save practice answers idempotently

This commit is contained in:
2026-07-27 21:49:02 +08:00
parent a43a58513a
commit 046bb4efee
21 changed files with 1923 additions and 2 deletions

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -54,4 +54,7 @@ public class PracticeQuestionDO extends TenantBaseDO {
/** 是否已作答 */
private Boolean isAnswered;
/** 最后接受的客户端命令序号(单调递增),用于拒绝乱序/过期请求 */
private Integer clientSequence;
}

View File

@@ -53,4 +53,7 @@ public class PracticeSessionDO extends TenantBaseDO {
/** 乐观锁版本号 */
private Integer version;
/** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */
private Integer lastClientSequence;
}

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.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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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, "请求字段过长:{}");
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}