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

@@ -0,0 +1,30 @@
-- =============================================
-- Education 模块 — 答案保存幂等性回滚
-- Ticket #7 / Migration 003
-- =============================================
--
-- WARNING: This file contains NO executable SQL.
-- Destructive rollback requires manual operator verification.
--
-- Manual rollback procedure (operator must execute):
-- 1. Verify no other tables depend on education_answer_idempotency:
-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
-- FROM information_schema.KEY_COLUMN_USAGE
-- WHERE REFERENCED_TABLE_NAME = 'education_answer_idempotency'
-- AND TABLE_SCHEMA = DATABASE();
-- Result MUST be empty before proceeding.
--
-- 2. Verify the table contains only data from this migration:
-- SELECT COUNT(*) AS idempotency_count FROM education_answer_idempotency;
-- Operator must confirm this count is acceptable to destroy.
--
-- 3. Verify no application code depends on client_sequence column:
-- Search codebase for 'clientSequence' / 'client_sequence' references.
--
-- 4. After verification, execute:
-- DROP TABLE IF EXISTS education_answer_idempotency;
-- ALTER TABLE education_practice_question DROP COLUMN client_sequence;
--
-- DO NOT uncomment or execute the lines below without operator verification.
-- -- DROP TABLE IF EXISTS education_answer_idempotency;
-- -- ALTER TABLE education_practice_question DROP COLUMN client_sequence;

View File

@@ -0,0 +1,97 @@
-- =============================================
-- Education 模块 — 答案保存幂等性 DDL
-- Ticket #7: 答案命令幂等、乐观锁并发控制、答案恢复
-- Migration: 003
-- Prerequisites: 002-education-practice-session.sql (session + question snapshots)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name = 'education_answer_idempotency';
-- Result MUST be 0 before executing this migration.
--
-- Verify prerequisite tables exist:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_session', 'education_practice_question');
-- Result MUST be 2.
-- =============================================
-- 答案命令幂等表
-- =============================================
-- Purpose: Provide durable idempotency for answer save commands.
-- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original response.
-- Same key + different request_hash → conflict.
-- Concurrent same-key inserts are resolved by unique constraint race handling.
--
-- Indexes:
-- uk_answer_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key.
-- INSERT during answer save. DuplicateKeyException catch for concurrent-create race resolution.
-- idx_tenant_session — covers lookup by session for audit/debug.
--
-- response_json: Stores the serialized answer response for replay after network timeout/retry.
-- request_hash: SHA-256 of canonical payload (sorted JSON fields) for content-based dedup.
CREATE TABLE `education_answer_idempotency` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '答题用户编号',
`operation` VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER'
COMMENT '操作类型SUBMIT_ANSWER',
`idempotency_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键UUID',
`request_hash` VARCHAR(64) NOT NULL COMMENT '请求载荷 SHA-256 哈希',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`question_id` VARCHAR(64) NOT NULL COMMENT '题目 ID',
`selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED'
COMMENT '状态ACCEPTED-已接受, CONFLICT-冲突',
`response_json` TEXT NOT NULL COMMENT '首次成功响应 JSON用于重试重放',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_answer_idempotency` (`tenant_id`, `user_id`, `operation`, `idempotency_key`),
KEY `idx_tenant_session` (`tenant_id`, `session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-答案命令幂等记录';
-- =============================================
-- PracticeQuestionDO: add client_sequence column
-- =============================================
-- Purpose: Track the last accepted client command sequence per question.
-- Rejects stale clientSequence: only sequences strictly greater than the
-- stored value are accepted (monotonic forward progression).
-- NULL means no answer has been accepted yet.
ALTER TABLE `education_practice_question`
ADD COLUMN `client_sequence` INT DEFAULT NULL COMMENT '最后接受的客户端命令序号',
ADD INDEX `idx_client_sequence` (`client_sequence`);
-- =============================================
-- PracticeSessionDO: add last_client_sequence column
-- =============================================
-- Purpose: Session-wide monotonic counter for client commands.
-- Rejects stale clientSequence across questions (not just per-question).
-- CAS incrementVersion now updates this column alongside version.
-- NULL means no answer has been accepted yet for this session.
ALTER TABLE `education_practice_session`
ADD COLUMN `last_client_sequence` INT DEFAULT NULL COMMENT '会话级最后接受的客户端命令序号(跨题目)';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new table exists:
-- SHOW CREATE TABLE education_answer_idempotency;
-- Verify unique key is enforced:
-- SHOW INDEX FROM education_answer_idempotency WHERE Key_name = 'uk_answer_idempotency';
-- Verify column added to question table:
-- SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND TABLE_NAME = 'education_practice_question'
-- AND COLUMN_NAME = 'client_sequence';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_answer_idempotency;

View File

@@ -14,6 +14,7 @@
- 题库目录端点 (见下方 Catalog API) — 学生端已认证
- 题目浏览与筛选端点 (见下方 Questions API) — 学生端已认证
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端)
- 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
@@ -406,3 +407,56 @@ Browser -> Controller(/education/questions/*) -> QuestionCatalogService -> Quest
| 1_005_003_002 | 无效的练习配置 |
| 1_005_003_003 | 符合条件的题目数量不足 |
| 1_005_003_004 | 题库数据源返回不安全内容 |
### 用户 APP - 答案保存Answer
需要学生登录态。`userId`/`tenantId` 由安全上下文派生。
答案保存具有幂等性:同一 `idempotencyKey` + 相同载荷返回首次结果,相同 key + 不同载荷返回冲突。
服务端乐观锁防止旧版本/旧序号覆盖更新答案。
```
PUT /app-api/education/practice-session/answer
```
**请求体:**
```json
{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","idempotencyKey":"uuid","clientSequence":5,"expectedSessionVersion":1}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| sessionId | Long | 是 | 练习会话 ID |
| questionSequence | Integer | 是 | 题目序号1-based |
| selectedAnswer | String | 否 | 学生选择的答案null 表示清除 |
| idempotencyKey | String | 是 | 客户端幂等键UUID |
| clientSequence | Integer | 是 | 客户端命令序号(单调递增) |
| expectedSessionVersion | Integer | 是 | 客户端期望的会话版本号 |
**成功响应:** `{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","serverVersion":2,"acceptedSequence":5}`
**前端保存状态契约**(客户端根据 API 响应派生,后端不提供状态枚举):
| 状态 | 条件 | 说明 |
|------|------|------|
| SAVING | 请求发送中 | 显示保存中指示器 |
| SAVED | code=0 | 更新本地版本号和序号 |
| RETRYING | 网络超时/5xx | 相同 idempotencyKey 安全重试 |
| FAILED | 1\_005\_003\_014/015/016 | 刷新页面获取最新状态后重试 |
刷新页面通过 `GET /practice-session/current` 恢复服务端最后确认的答案。
### 答案保存错误码
| 错误码 | 说明 |
|--------|------|
| 1\_005\_003\_006 | 练习会话不存在 |
| 1\_005\_003\_007 | 无权访问该练习会话 |
| 1\_005\_003\_008 | 练习会话已过期 |
| 1\_005\_003\_009 | 练习会话已提交 |
| 1\_005\_003\_010 | 练习会话已取消 |
| 1\_005\_003\_014 | 幂等键相同但请求内容不一致 |
| 1\_005\_003\_015 | 会话版本已更新,请刷新后重试 |
| 1\_005\_003\_016 | 客户端命令序号已过期 |
| 1\_005\_003\_017 | 无效的选项 |
| 1\_005\_003\_019 | 题目不属于当前会话 |

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

View File

@@ -0,0 +1,407 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
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.service.practice.PracticeSessionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* PracticeAnswerController HTTP seam test — standalone MockMvc.
*
* @author 恭学教育
*/
class PracticeAnswerControllerHttpTest {
private MockMvc mockMvc;
private PracticeSessionService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void setUp() {
service = mock(PracticeSessionService.class);
PracticeSessionController controller = new PracticeSessionController();
try {
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
field.setAccessible(true);
field.set(controller, service);
} catch (Exception e) {
throw new RuntimeException(e);
}
var validator = new LocalValidatorFactoryBean();
validator.afterPropertiesSet();
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setValidator(validator)
.setControllerAdvice(new TestExceptionHandler())
.build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ========== PUT /answer success ==========
@Test
void shouldSaveAnswerWhenAuthenticated() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerRespVO mockResp = PracticeAnswerRespVO.builder()
.sessionId(1001L)
.questionSequence(1)
.selectedAnswer("A")
.serverVersion(2)
.acceptedSequence(1)
.build();
when(service.submitAnswer(any(), eq(100L), eq(1L))).thenReturn(mockResp);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
MvcResult result = mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").value(1001))
.andExpect(jsonPath("$.data.questionSequence").value(1))
.andExpect(jsonPath("$.data.selectedAnswer").value("A"))
.andExpect(jsonPath("$.data.serverVersion").value(2))
.andExpect(jsonPath("$.data.acceptedSequence").value(1))
.andReturn();
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "answer response must not leak correctAnswer");
assertFalse(json.contains("isCorrect"), "answer response must not leak isCorrect");
}
// ========== Auth validation ==========
@Test
void shouldReturn401ForAnswerWhenNotAuthenticated() throws Exception {
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
// ========== Validation error envelope ==========
@Test
void shouldReturn400ForMissingSessionId() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
// sessionId is null
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400ForMissingIdempotencyKey() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
// idempotencyKey is blank
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400ForMissingQuestionSequence() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
// questionSequence is null
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400ForMissingClientSequence() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setExpectedSessionVersion(1);
// clientSequence is null
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400ForMissingExpectedSessionVersion() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
// expectedSessionVersion is null
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
// ========== Error code propagation ==========
@Test
void shouldPropagateStaleVersionError() throws Exception {
setLoginUser(100L, 1L);
when(service.submitAnswer(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(ANSWER_STALE_VERSION.getCode(), ANSWER_STALE_VERSION.getMsg()));
PracticeAnswerReqVO req = validReq();
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(ANSWER_STALE_VERSION.getCode()));
}
@Test
void shouldPropagateIdempotencyConflictError() throws Exception {
setLoginUser(100L, 1L);
when(service.submitAnswer(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(ANSWER_IDEMPOTENCY_CONFLICT.getCode(),
ANSWER_IDEMPOTENCY_CONFLICT.getMsg()));
PracticeAnswerReqVO req = validReq();
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(ANSWER_IDEMPOTENCY_CONFLICT.getCode()));
}
@Test
void shouldPropagateSessionNotFoundError() throws Exception {
setLoginUser(100L, 1L);
when(service.submitAnswer(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(SESSION_NOT_FOUND.getCode(), SESSION_NOT_FOUND.getMsg()));
PracticeAnswerReqVO req = validReq();
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(SESSION_NOT_FOUND.getCode()));
}
@Test
void shouldPropagateInvalidOptionError() throws Exception {
setLoginUser(100L, 1L);
when(service.submitAnswer(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(ANSWER_OPTION_INVALID.getCode(), ANSWER_OPTION_INVALID.getMsg()));
PracticeAnswerReqVO req = validReq();
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(ANSWER_OPTION_INVALID.getCode()));
}
// ========== Idempotent replay via HTTP ==========
@Test
void shouldReplaySameResponseOnDuplicateIdempotencyKey() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerRespVO mockResp = PracticeAnswerRespVO.builder()
.sessionId(1001L)
.questionSequence(1)
.selectedAnswer("A")
.serverVersion(2)
.acceptedSequence(1)
.build();
when(service.submitAnswer(any(), eq(100L), eq(1L))).thenReturn(mockResp);
PracticeAnswerReqVO req = validReq();
// Call twice — service handles idempotency
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.serverVersion").value(2));
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.serverVersion").value(2));
verify(service, times(2)).submitAnswer(any(), eq(100L), eq(1L));
}
// ========== #4: Malformed options error propagation ==========
@Test
void shouldPropagateMalformedOptionsError() throws Exception {
setLoginUser(100L, 1L);
when(service.submitAnswer(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(),
CATALOG_UPSTREAM_OPTIONS_MALFORMED.getMsg()));
PracticeAnswerReqVO req = validReq();
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode()));
}
// ========== #4: Oversized selectedAnswer ==========
@Test
void shouldReturn400ForOversizedSelectedAnswer() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = validReq();
req.setSelectedAnswer("X".repeat(65)); // > 64 chars
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
// ========== #4: Oversized idempotencyKey ==========
@Test
void shouldReturn400ForOversizedIdempotencyKey() throws Exception {
setLoginUser(100L, 1L);
PracticeAnswerReqVO req = validReq();
req.setIdempotencyKey("x".repeat(65)); // > 64 chars
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
// ========== helpers ==========
private void setLoginUser(Long userId, Long tenantId) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
private PracticeAnswerReqVO validReq() {
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(1001L);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey("uuid-answer");
req.setClientSequence(1);
req.setExpectedSessionVersion(1);
return req;
}
/**
* Minimal exception handler for standalone MockMvc.
*/
@RestControllerAdvice
static class TestExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<?> handleValidation(MethodArgumentNotValidException ex,
jakarta.servlet.http.HttpServletResponse response) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.findFirst().orElse("validation error");
return CommonResult.error(400, msg);
}
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex,
jakarta.servlet.http.HttpServletResponse response) {
if (ex.getCode() == UNAUTHORIZED.getCode()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return CommonResult.error(UNAUTHORIZED);
}
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
return CommonResult.error(ex.getCode(), ex.getMessage());
}
}
}

View File

@@ -0,0 +1,838 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
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;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* PracticeAnswerService test — real DB (H2) backing all persistence assertions.
* Covers findings #1-#6: conditional update, atomic CAS, session-wide sequence,
* fail-closed options, version null/overflow, idempotency replay, concurrency.
*
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionService service;
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
private PracticeQuestionMapper questionMapper;
@Resource
private AnswerIdempotencyMapper idempotencyMapper;
@MockitoBean
private QuestionCatalogProvider provider;
private final Long userId = 100L;
private final Long tenantId = 1L;
// ========== helpers ==========
private record SessionFixture(Long sessionId, Long questionId, Integer version) {}
private SessionFixture createSession(String clientSessionId) {
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId(clientSessionId);
session.setStatus("ACTIVE");
session.setQuestionCount(1);
session.setCollectionId("col-001");
session.setVersion(1);
sessionMapper.insert(session);
PracticeQuestionDO question = new PracticeQuestionDO();
question.setTenantId(tenantId);
question.setSessionId(session.getId());
question.setSequence(1);
question.setQuestionId("q-001");
question.setContentVersion("v1");
question.setStem("test stem");
question.setType("choice");
question.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0}]");
question.setIsAnswered(false);
questionMapper.insert(question);
return new SessionFixture(session.getId(), question.getId(), session.getVersion());
}
private PracticeAnswerReqVO createAnswerReq(Long sessionId, String idempotencyKey,
int clientSeq, int expectedVersion) {
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(sessionId);
req.setQuestionSequence(1);
req.setSelectedAnswer("A");
req.setIdempotencyKey(idempotencyKey);
req.setClientSequence(clientSeq);
req.setExpectedSessionVersion(expectedVersion);
return req;
}
// ========== #6: Successful persistence + resume ==========
@Test
void shouldPersistAnswerAndReflectInSessionResume() {
SessionFixture f = createSession("uuid-persist");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-persist", 1, 1);
PracticeAnswerRespVO resp = service.submitAnswer(req, userId, tenantId);
assertNotNull(resp);
assertEquals(f.sessionId, resp.getSessionId());
assertEquals("A", resp.getSelectedAnswer());
assertEquals(2, resp.getServerVersion());
assertEquals(1, resp.getAcceptedSequence());
// Session version incremented, lastClientSequence set
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
assertEquals(2, session.getVersion());
assertEquals(1, session.getLastClientSequence());
// Question updated
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals("A", question.getSelectedAnswer());
assertTrue(question.getIsAnswered());
assertEquals(1, question.getClientSequence());
// Resume via getSession reflects answer
PracticeSessionRespVO resumed = service.getSession(f.sessionId, userId, tenantId);
assertEquals("A", resumed.getQuestions().get(0).getSelectedAnswer());
assertTrue(resumed.getQuestions().get(0).getIsAnswered());
assertEquals(1, resumed.getLastClientSequence());
// Idempotency record committed
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-persist");
assertNotNull(idem);
assertEquals("ACCEPTED", idem.getStatus());
}
// ========== #6: Replay after response loss (same key + same payload) ==========
@Test
void shouldReplayOriginalResponseOnDuplicateIdempotencyKey() {
SessionFixture f = createSession("uuid-replay");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-replay", 1, 1);
PracticeAnswerRespVO first = service.submitAnswer(req, userId, tenantId);
PracticeAnswerRespVO second = service.submitAnswer(req, userId, tenantId);
assertEquals(first.getServerVersion(), second.getServerVersion());
assertEquals(first.getAcceptedSequence(), second.getAcceptedSequence());
assertEquals(first.getSelectedAnswer(), second.getSelectedAnswer());
// Exactly one idempotency record
List<AnswerIdempotencyDO> records = idempotencyMapper.selectList();
assertEquals(1, records.stream()
.filter(r -> "idem-replay".equals(r.getIdempotencyKey())).count());
}
// ========== #6: Replay after session status changes ==========
@Test
void shouldReplayOriginalResponseAfterSessionStatusChange() {
SessionFixture f = createSession("uuid-status-replay");
// Submit answer → committed
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-status", 1, 1);
PracticeAnswerRespVO original = service.submitAnswer(req, userId, tenantId);
assertEquals(2, original.getServerVersion());
// Change session status to SUBMITTED
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setStatus("SUBMITTED");
sessionMapper.updateById(session);
// Replay: same key → must return original committed response BEFORE status check
PracticeAnswerRespVO replayed = service.submitAnswer(req, userId, tenantId);
assertEquals(original.getServerVersion(), replayed.getServerVersion());
assertEquals(original.getAcceptedSequence(), replayed.getAcceptedSequence());
assertEquals(original.getSelectedAnswer(), replayed.getSelectedAnswer());
}
// ========== #6: Same key different payload → conflict (even after status change) ==========
@Test
void shouldConflictOnSameKeyDifferentPayloadAfterStatusChange() {
SessionFixture f = createSession("uuid-status-conflict");
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-sc", 1, 1);
req1.setSelectedAnswer("A");
service.submitAnswer(req1, userId, tenantId);
// Change status
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setStatus("EXPIRED");
sessionMapper.updateById(session);
// Different payload (different sequence → different hash)
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-sc", 2, 2);
req2.setSelectedAnswer("A");
assertServiceException(
() -> service.submitAnswer(req2, userId, tenantId),
ANSWER_IDEMPOTENCY_CONFLICT);
}
// ========== #1/#2: Basic answer save ==========
@Test
void shouldSaveAnswerAndReturnNewVersion() {
SessionFixture f = createSession("uuid-answer-basic");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-1", 1, 1);
PracticeAnswerRespVO resp = service.submitAnswer(req, userId, tenantId);
assertNotNull(resp);
assertEquals(f.sessionId, resp.getSessionId());
assertEquals(1, resp.getQuestionSequence());
assertEquals("A", resp.getSelectedAnswer());
assertEquals(2, resp.getServerVersion());
assertEquals(1, resp.getAcceptedSequence());
}
// ========== #3: Session-wide clientSequence rejection ==========
@Test
void shouldRejectStaleSessionWideClientSequence() {
SessionFixture f = createSession("uuid-stale-seq");
// Accept sequence 5
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-seq-1", 5, 1);
service.submitAnswer(req1, userId, tenantId);
assertEquals(Integer.valueOf(5),
sessionMapper.selectById(f.sessionId).getLastClientSequence());
// Reject sequence 3 (older than session's lastClientSequence=5)
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-seq-2", 3, 2);
assertServiceException(
() -> service.submitAnswer(req2, userId, tenantId),
ANSWER_STALE_CROSS_QUESTION_SEQUENCE, 3, 5);
// Reject sequence 5 (equal to session's lastClientSequence=5)
PracticeAnswerReqVO req3 = createAnswerReq(f.sessionId, "idem-seq-3", 5, 2);
assertServiceException(
() -> service.submitAnswer(req3, userId, tenantId),
ANSWER_STALE_CROSS_QUESTION_SEQUENCE, 5, 5);
}
// ========== #3: Cross-question stale sequence ==========
@Test
void shouldRejectStaleSequenceForDifferentQuestion() {
// Create session with 2 questions
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId("uuid-cross-q-seq");
session.setStatus("ACTIVE");
session.setQuestionCount(2);
session.setCollectionId("col-001");
session.setVersion(1);
sessionMapper.insert(session);
PracticeQuestionDO q1 = createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
PracticeQuestionDO q2 = createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
// Answer Q1 with seq=10
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 10, 1);
service.submitAnswer(req1, userId, tenantId);
assertEquals(Integer.valueOf(10),
sessionMapper.selectById(session.getId()).getLastClientSequence());
// Try answering Q2 with seq=5 (rejected: session.lastClientSequence=10)
PracticeAnswerReqVO req2 = makeReq(session.getId(), 2, "B", "idem-q2", 5, 2);
assertServiceException(
() -> service.submitAnswer(req2, userId, tenantId),
ANSWER_STALE_CROSS_QUESTION_SEQUENCE, 5, 10);
// Answer Q2 with seq=15 (accepted)
PracticeAnswerReqVO req3 = makeReq(session.getId(), 2, "B", "idem-q3", 15, 2);
PracticeAnswerRespVO resp3 = service.submitAnswer(req3, userId, tenantId);
assertEquals(15, resp3.getAcceptedSequence());
assertEquals(3, resp3.getServerVersion());
}
// ========== #3: Sequential answers for different questions ==========
@Test
void shouldAllowSequentialAnswersForDifferentQuestionsWithDistinctSequences() {
// Create session with 2 questions
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId("uuid-multi-q");
session.setStatus("ACTIVE");
session.setQuestionCount(2);
session.setCollectionId("col-001");
session.setVersion(1);
sessionMapper.insert(session);
createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
// Answer Q1 with seq=5
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 5, 1);
PracticeAnswerRespVO resp1 = service.submitAnswer(req1, userId, tenantId);
assertEquals(2, resp1.getServerVersion());
// Answer Q2 with seq=10 (higher, not equal)
PracticeAnswerReqVO req2 = makeReq(session.getId(), 2, "B", "idem-q2", 10, 2);
PracticeAnswerRespVO resp2 = service.submitAnswer(req2, userId, tenantId);
assertEquals(3, resp2.getServerVersion());
// Verify both answers reflected
PracticeSessionRespVO full = service.getSession(session.getId(), userId, tenantId);
assertEquals(2, full.getQuestions().size());
assertEquals("A", full.getQuestions().get(0).getSelectedAnswer());
assertEquals("B", full.getQuestions().get(1).getSelectedAnswer());
}
// ========== #1: updateAnswerIfNewer — per-question stale sequence ==========
@Test
void shouldRejectPerQuestionStaleSequenceViaUpdateAnswerIfNewer() {
SessionFixture f = createSession("uuid-pq-stale");
// Accept sequence 10
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-pq-1", 10, 1);
service.submitAnswer(req1, userId, tenantId);
// Accept sequence 15 for same question (session version now 3)
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-pq-2", 15, 2);
service.submitAnswer(req2, userId, tenantId);
// Reject sequence 12 for same question: session-level check catches 12 <= 15 first
PracticeAnswerReqVO req3 = createAnswerReq(f.sessionId, "idem-pq-3", 12, 3);
assertServiceException(
() -> service.submitAnswer(req3, userId, tenantId),
ANSWER_STALE_CROSS_QUESTION_SEQUENCE, 12, 15);
}
// ========== #2: Atomic session version CAS ==========
@Test
void shouldRejectStaleExpectedSessionVersion() {
SessionFixture f = createSession("uuid-stale-ver");
// First answer increments version to 2
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-ver-1", 1, 1);
PracticeAnswerRespVO resp1 = service.submitAnswer(req1, userId, tenantId);
assertEquals(2, resp1.getServerVersion());
// Second request with expectedVersion=1 (stale) — both pre-check and CAS reject
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-ver-2", 2, 1);
assertServiceException(
() -> service.submitAnswer(req2, userId, tenantId),
ANSWER_STALE_VERSION, 1, 2);
}
// ========== #2: Forced version conflict → CAS rollback ==========
@Test
void shouldNotPersistWhenCasVersionFails() {
SessionFixture f = createSession("uuid-cas-rollback");
// Pre-check passes (version=1 matches expectedVersion=1)
// But we'll force version change between load and CAS via concurrent call
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-cas", 1, 1);
// Concurrently change version to simulate race
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setVersion(99);
sessionMapper.updateById(session);
// Now CAS should fail because version != 1
// Pre-check catches version mismatch (1 != 99) before CAS
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
ANSWER_STALE_VERSION, 1, 99);
// Idempotency record must NOT exist (transaction rolled back)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-cas");
assertNull(idem, "idempotency insert must roll back when CAS fails");
// Question answer must NOT be changed
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertNull(question.getSelectedAnswer());
assertFalse(question.getIsAnswered());
}
// ========== #1: updateAnswerIfNewer no-op → answer not persisted ==========
@Test
void shouldNotPersistAnswerWhenUpdateAnswerIfNewerReturnsZero() {
SessionFixture f = createSession("uuid-uan-noop");
// Force question's clientSequence to 99 (make it look like newer answer exists)
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setClientSequence(99);
questionMapper.updateById(question);
// Submit with seq=5 (5 <= 99 → CAS update returns 0)
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-uan", 5, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
ANSWER_STALE_CLIENT_SEQUENCE, 5, 99);
// Answer must NOT be changed
PracticeQuestionDO reloaded = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(99), reloaded.getClientSequence());
// Idempotency must NOT be committed (rollback)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-uan");
assertNull(idem, "idempotency must roll back when question CAS fails");
}
// ========== #1: Question update CAS with tenant guard ==========
@Test
void shouldRejectAnswerForWrongTenantOnQuestionUpdate() {
SessionFixture f = createSession("uuid-q-tenant");
// Submit with a different tenant — session load fails first
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-wt", 1, 1), userId, 999L),
SESSION_NOT_FOUND);
}
// ========== #5: Session version null ==========
// Note: DB column has NOT NULL DEFAULT 1 constraint, so version null is only
// reachable via external data corruption. The service defensive check is verified
// by code review; the error code declaration validates the enum exists.
@Test
void shouldHaveVersionNullErrorCodeDeclared() {
assertNotNull(ANSWER_SESSION_VERSION_NULL);
assertEquals(1_005_003_020, ANSWER_SESSION_VERSION_NULL.getCode());
}
// ========== #5: Session version overflow ==========
@Test
void shouldRejectWhenSessionVersionIsMaxValue() {
SessionFixture f = createSession("uuid-ver-max");
// Set version to MAX_VALUE
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setVersion(Integer.MAX_VALUE);
sessionMapper.updateById(session);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-vmax", 1, Integer.MAX_VALUE);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
ANSWER_SESSION_VERSION_OVERFLOW);
}
// ========== #4: Fail-closed options validation ==========
@Test
void shouldRejectMalformedOptionsEmptyArray() {
SessionFixture f = createSession("uuid-opt-empty");
// Empty array is valid JSON but has no valid option labels
questionMapper.update(null,
new LambdaUpdateWrapper<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getId, f.questionId)
.set(PracticeQuestionDO::getOptions, "[]"));
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-oea", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@Test
void shouldRejectMalformedOptionsBlank() {
SessionFixture f = createSession("uuid-opt-blank");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions(" ");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-ob", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@Test
void shouldRejectMalformedOptionsNonArray() {
SessionFixture f = createSession("uuid-opt-na");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions("{\"not\":\"an array\"}");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-ona", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@Test
void shouldRejectMalformedOptionsNullElement() {
SessionFixture f = createSession("uuid-opt-ne");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions("[{\"label\":\"A\"}, null]");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-one", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@Test
void shouldRejectMalformedOptionsMissingLabel() {
SessionFixture f = createSession("uuid-opt-ml");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions("[{\"content\":\"no label here\"}]");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-oml", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
// ========== #4: Invalid answer option ==========
@Test
void shouldRejectInvalidSelectedOption() {
SessionFixture f = createSession("uuid-bad-option");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-opt", 1, 1);
req.setSelectedAnswer("Z"); // Not a valid option label
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
ANSWER_OPTION_INVALID, "Z");
}
// ========== Cross-user ==========
@Test
void shouldRejectAnswerForDifferentUserSameTenant() {
SessionFixture f = createSession("uuid-cross-answer");
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-cross", 1, 1), 200L, tenantId),
SESSION_NOT_OWN);
}
// ========== Cross-tenant ==========
@Test
void shouldRejectAnswerForDifferentTenant() {
SessionFixture f = createSession("uuid-cross-tenant-answer");
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-tenant", 1, 1), userId, 999L),
SESSION_NOT_FOUND);
}
// ========== Non-ACTIVE session rejection ==========
@Test
void shouldRejectAnswerForSubmittedSession() {
SessionFixture f = createSession("uuid-submitted");
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setStatus("SUBMITTED");
sessionMapper.updateById(session);
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-sub", 1, 1), userId, tenantId),
SESSION_ALREADY_SUBMITTED);
}
@Test
void shouldRejectAnswerForExpiredSession() {
SessionFixture f = createSession("uuid-expired");
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setStatus("EXPIRED");
sessionMapper.updateById(session);
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-exp", 1, 1), userId, tenantId),
SESSION_EXPIRED);
}
@Test
void shouldRejectAnswerForCancelledSession() {
SessionFixture f = createSession("uuid-cancelled");
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
session.setStatus("CANCELLED");
sessionMapper.updateById(session);
assertServiceException(
() -> service.submitAnswer(
createAnswerReq(f.sessionId, "idem-ccl", 1, 1), userId, tenantId),
SESSION_CANCELLED);
}
// ========== Unknown question ==========
@Test
void shouldRejectAnswerForQuestionNotInSession() {
SessionFixture f = createSession("uuid-no-question");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-nq", 1, 1);
req.setQuestionSequence(99);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
ANSWER_QUESTION_NOT_IN_SESSION);
}
// ========== Concurrent same-key race (idempotency) ==========
@Test
void shouldResolveConcurrentSameKeyRace() throws Exception {
SessionFixture f = createSession("uuid-race-answer");
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-race", 1, 1);
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-race", 1, 1);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeAnswerRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeAnswerRespVO> r2 = new AtomicReference<>();
AtomicReference<Exception> e1 = new AtomicReference<>();
AtomicReference<Exception> e2 = new AtomicReference<>();
Thread t1 = new Thread(() -> {
try {
ready.countDown();
go.await();
r1.set(service.submitAnswer(req1, userId, tenantId));
} catch (Exception e) {
e1.set(e);
}
});
Thread t2 = new Thread(() -> {
try {
ready.countDown();
go.await();
r2.set(service.submitAnswer(req2, userId, tenantId));
} catch (Exception e) {
e2.set(e);
}
});
t1.start();
t2.start();
ready.await();
go.countDown();
t1.join(10000);
t2.join(10000);
assertNull(e1.get(), "thread 1 must not throw: " + (e1.get() != null ? e1.get().getMessage() : ""));
assertNull(e2.get(), "thread 2 must not throw: " + (e2.get() != null ? e2.get().getMessage() : ""));
assertNotNull(r1.get());
assertNotNull(r2.get());
assertEquals(r1.get().getServerVersion(), r2.get().getServerVersion(),
"concurrent identical requests must return same response");
}
// ========== Concurrent different-key ==========
@Test
void shouldAllowConcurrentDifferentKeysWithDistinctSequences() throws Exception {
SessionFixture f = createSession("uuid-race-diff-key");
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-dk1", 1, 1);
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-dk2", 2, 1);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeAnswerRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeAnswerRespVO> r2 = new AtomicReference<>();
AtomicReference<Exception> e1 = new AtomicReference<>();
AtomicReference<Exception> e2 = new AtomicReference<>();
Thread t1 = new Thread(() -> {
try {
ready.countDown();
go.await();
r1.set(service.submitAnswer(req1, userId, tenantId));
} catch (Exception e) {
e1.set(e);
}
});
Thread t2 = new Thread(() -> {
try {
ready.countDown();
go.await();
r2.set(service.submitAnswer(req2, userId, tenantId));
} catch (Exception e) {
e2.set(e);
}
});
t1.start();
t2.start();
ready.await();
go.countDown();
t1.join(10000);
t2.join(10000);
// One should succeed, the other should get stale version (CAS failed)
int successCount = (e1.get() == null ? 1 : 0) + (e2.get() == null ? 1 : 0);
assertTrue(successCount >= 1, "at least one must succeed");
// Version must be exactly 2 (only one CAS succeeded)
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
assertEquals(2, session.getVersion());
}
// ========== No correctness leak ==========
@Test
void shouldNeverLeakCorrectnessInSessionResponse() {
SessionFixture f = createSession("uuid-no-leak");
service.submitAnswer(createAnswerReq(f.sessionId, "idem-leak", 1, 1), userId, tenantId);
PracticeSessionRespVO resp = service.getSession(f.sessionId, userId, tenantId);
assertNotNull(resp);
String optionsJson = questionMapper.selectById(f.questionId).getOptions();
assertNotNull(optionsJson);
assertFalse(optionsJson.contains("isCorrect"));
assertFalse(optionsJson.contains("correctAnswer"));
assertFalse(optionsJson.contains("explanation"));
}
// ========== Refresh recovery ==========
@Test
void shouldReflectSavedAnswerInSessionResponse() {
SessionFixture f = createSession("uuid-refresh");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-refresh", 1, 1);
service.submitAnswer(req, userId, tenantId);
PracticeSessionRespVO sessionResp = service.getSession(f.sessionId, userId, tenantId);
assertNotNull(sessionResp);
assertEquals("A", sessionResp.getQuestions().get(0).getSelectedAnswer());
assertTrue(sessionResp.getQuestions().get(0).getIsAnswered());
PracticeSessionRespVO currentResp = service.getCurrentSession(userId, tenantId);
assertNotNull(currentResp);
assertEquals("A", currentResp.getQuestions().get(0).getSelectedAnswer());
}
// ========== Idempotency: different payload conflict ==========
@Test
void shouldRejectDifferentPayloadWithSameIdempotencyKey() {
SessionFixture f = createSession("uuid-idemp-conflict");
PracticeAnswerReqVO req1 = createAnswerReq(f.sessionId, "idem-conflict", 1, 1);
req1.setSelectedAnswer("A");
service.submitAnswer(req1, userId, tenantId);
PracticeAnswerReqVO req2 = createAnswerReq(f.sessionId, "idem-conflict", 2, 2);
req2.setSelectedAnswer("A");
assertServiceException(
() -> service.submitAnswer(req2, userId, tenantId),
ANSWER_IDEMPOTENCY_CONFLICT);
}
@Test
void shouldRejectDelimiterAmbiguityWithSameIdempotencyKey() {
SessionFixture f = createSession("uuid-idemp-delimiter");
PracticeAnswerReqVO first = createAnswerReq(f.sessionId, "idem-delimiter", 1, 1);
first.setSelectedAnswer("A|2");
ServiceException invalid = assertThrows(ServiceException.class,
() -> service.submitAnswer(first, userId, tenantId));
assertEquals(ANSWER_OPTION_INVALID.getCode(), invalid.getCode());
PracticeAnswerReqVO accepted = createAnswerReq(f.sessionId, "idem-delimiter", 1, 1);
accepted.setSelectedAnswer("A");
service.submitAnswer(accepted, userId, tenantId);
PracticeAnswerReqVO changed = createAnswerReq(f.sessionId, "idem-delimiter", 2, 2);
changed.setSelectedAnswer("A");
assertServiceException(() -> service.submitAnswer(changed, userId, tenantId), ANSWER_IDEMPOTENCY_CONFLICT);
}
// ========== Two delayed requests: newer arrives first ==========
@Test
void shouldAcceptNewerSequenceAndRejectOlderWhenReversed() {
SessionFixture f = createSession("uuid-reversed");
// Later command arrives first (seq=10)
PracticeAnswerReqVO reqLate = createAnswerReq(f.sessionId, "idem-late", 10, 1);
PracticeAnswerRespVO respLate = service.submitAnswer(reqLate, userId, tenantId);
assertEquals(10, respLate.getAcceptedSequence());
assertEquals(2, respLate.getServerVersion());
// Earlier command arrives second (seq=5) — must be rejected by session-level check
PracticeAnswerReqVO reqEarly = createAnswerReq(f.sessionId, "idem-early", 5, 2);
assertServiceException(
() -> service.submitAnswer(reqEarly, userId, tenantId),
ANSWER_STALE_CROSS_QUESTION_SEQUENCE, 5, 10);
}
// ========== private helpers ==========
private PracticeQuestionDO createQuestion(Long sessionId, int sequence, String questionId,
String options) {
PracticeQuestionDO q = new PracticeQuestionDO();
q.setTenantId(tenantId);
q.setSessionId(sessionId);
q.setSequence(sequence);
q.setQuestionId(questionId);
q.setContentVersion("v1");
q.setStem("stem-" + questionId);
q.setType("choice");
q.setOptions(options);
q.setIsAnswered(false);
questionMapper.insert(q);
return q;
}
private PracticeAnswerReqVO makeReq(Long sessionId, int questionSequence,
String selectedAnswer, String idempotencyKey,
int clientSequence, int expectedVersion) {
PracticeAnswerReqVO req = new PracticeAnswerReqVO();
req.setSessionId(sessionId);
req.setQuestionSequence(questionSequence);
req.setSelectedAnswer(selectedAnswer);
req.setIdempotencyKey(idempotencyKey);
req.setClientSequence(clientSequence);
req.setExpectedSessionVersion(expectedVersion);
return req;
}
}

View File

@@ -1,2 +1,3 @@
DELETE FROM education_practice_question;
DELETE FROM education_practice_session;
DELETE FROM education_answer_idempotency;

View File

@@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS "education_practice_session" (
"node_id" VARCHAR(64) DEFAULT NULL,
"type" VARCHAR(32) DEFAULT NULL,
"difficulty" VARCHAR(32) DEFAULT NULL,
"version" INT NOT NULL DEFAULT 0,
"version" INT NOT NULL DEFAULT 1,
"last_client_sequence" INT DEFAULT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
@@ -34,6 +35,7 @@ CREATE TABLE IF NOT EXISTS "education_practice_question" (
"options" CLOB NOT NULL,
"selected_answer" CLOB DEFAULT NULL,
"is_answered" BIT NOT NULL DEFAULT FALSE,
"client_sequence" INT DEFAULT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
@@ -44,3 +46,26 @@ CREATE TABLE IF NOT EXISTS "education_practice_question" (
);
CREATE INDEX IF NOT EXISTS "idx_session_id" ON "education_practice_question" ("session_id");
CREATE TABLE IF NOT EXISTS "education_answer_idempotency" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"operation" VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER',
"idempotency_key" VARCHAR(64) NOT NULL,
"request_hash" VARCHAR(64) NOT NULL,
"session_id" BIGINT NOT NULL,
"question_id" VARCHAR(64) NOT NULL,
"selected_answer" CLOB DEFAULT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED',
"response_json" CLOB NOT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BIT NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_answer_idempotency" UNIQUE ("tenant_id", "user_id", "operation", "idempotency_key")
);
CREATE INDEX IF NOT EXISTS "idx_tenant_session" ON "education_answer_idempotency" ("tenant_id", "session_id");