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