From 046bb4efee06cf1c5805a9ac6a429da97182581b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 27 Jul 2026 21:49:02 +0800 Subject: [PATCH] feat(education): save practice answers idempotently --- ...-education-answer-idempotency-rollback.sql | 30 + .../003-education-answer-idempotency.sql | 97 ++ yudao-module-education/README.md | 54 ++ .../admin/EducationCapabilityController.java | 2 +- .../practice/PracticeSessionController.java | 16 + .../app/practice/vo/PracticeAnswerReqVO.java | 50 ++ .../app/practice/vo/PracticeAnswerRespVO.java | 38 + .../practice/vo/PracticeSessionRespVO.java | 2 + .../dal/dataobject/AnswerIdempotencyDO.java | 55 ++ .../dal/dataobject/PracticeQuestionDO.java | 3 + .../dal/dataobject/PracticeSessionDO.java | 3 + .../dal/mysql/AnswerIdempotencyMapper.java | 27 + .../dal/mysql/PracticeQuestionMapper.java | 27 + .../dal/mysql/PracticeSessionMapper.java | 20 + .../education/enums/ErrorCodeConstants.java | 14 + .../practice/PracticeSessionService.java | 16 + .../practice/PracticeSessionServiceImpl.java | 198 +++++ .../PracticeAnswerControllerHttpTest.java | 407 +++++++++ .../PracticeAnswerServiceImplTest.java | 838 ++++++++++++++++++ .../src/test/resources/sql/clean.sql | 1 + .../src/test/resources/sql/create_tables.sql | 27 +- 21 files changed, 1923 insertions(+), 2 deletions(-) create mode 100644 sql/mysql/education/003-education-answer-idempotency-rollback.sql create mode 100644 sql/mysql/education/003-education-answer-idempotency.sql create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/AnswerIdempotencyDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/AnswerIdempotencyMapper.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeAnswerControllerHttpTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java diff --git a/sql/mysql/education/003-education-answer-idempotency-rollback.sql b/sql/mysql/education/003-education-answer-idempotency-rollback.sql new file mode 100644 index 00000000..a8934e9c --- /dev/null +++ b/sql/mysql/education/003-education-answer-idempotency-rollback.sql @@ -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; diff --git a/sql/mysql/education/003-education-answer-idempotency.sql b/sql/mysql/education/003-education-answer-idempotency.sql new file mode 100644 index 00000000..f68d3bb7 --- /dev/null +++ b/sql/mysql/education/003-education-answer-idempotency.sql @@ -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; diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index a4381729..4a5b8b65 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -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 | 题目不属于当前会话 | diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java index 8d724e86..bf6be73d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java @@ -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); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java index 686f46d1..764cf2c8 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeSessionController.java @@ -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 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() { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerReqVO.java new file mode 100644 index 00000000..aef4d045 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerReqVO.java @@ -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。 + * + *

所有字段由客户端提供。userId/tenantId 由安全上下文派生,不接受请求参数。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerRespVO.java new file mode 100644 index 00000000..f59ab585 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeAnswerRespVO.java @@ -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。 + * + *

返回服务端确认的版本号和已接受的序号。客户端据此更新本地状态机:SAVED/SAVING/RETRYING/FAILED。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java index 486a418a..95b8598a 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/practice/vo/PracticeSessionRespVO.java @@ -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 questions; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/AnswerIdempotencyDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/AnswerIdempotencyDO.java new file mode 100644 index 00000000..95c57e89 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/AnswerIdempotencyDO.java @@ -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。 + * + *

同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。 + * requestHash 用于检测相同键不同载荷的冲突。 + * responseJson 存储首次成功响应,用于超时重试重放。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java index 956b5273..7e16f9cb 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeQuestionDO.java @@ -54,4 +54,7 @@ public class PracticeQuestionDO extends TenantBaseDO { /** 是否已作答 */ private Boolean isAnswered; + + /** 最后接受的客户端命令序号(单调递增),用于拒绝乱序/过期请求 */ + private Integer clientSequence; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java index 1bbb8cc9..7199c4b1 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java @@ -53,4 +53,7 @@ public class PracticeSessionDO extends TenantBaseDO { /** 乐观锁版本号 */ private Integer version; + /** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */ + private Integer lastClientSequence; + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/AnswerIdempotencyMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/AnswerIdempotencyMapper.java new file mode 100644 index 00000000..86676ae1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/AnswerIdempotencyMapper.java @@ -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 { + + /** + * 按租户、用户、操作、幂等键查找记录。 + */ + default AnswerIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) { + return selectOne(new LambdaQueryWrapperX() + .eq(AnswerIdempotencyDO::getTenantId, tenantId) + .eq(AnswerIdempotencyDO::getUserId, userId) + .eq(AnswerIdempotencyDO::getOperation, operation) + .eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey)); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java index a0772221..03d9b72b 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeQuestionMapper.java @@ -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 .orderByAsc(PracticeQuestionDO::getSequence)); } + /** + * 按会话 ID 和题目序号查找单题快照。 + */ + default PracticeQuestionDO selectBySessionIdAndSequence(Long sessionId, Integer sequence) { + return selectOne(new LambdaQueryWrapperX() + .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() + .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)); + } + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java index 278181e6..5057d87d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java @@ -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 { .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() + .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)); + } + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 216a9ed6..85a856c6 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "请求字段过长:{}"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java index 9d24fe98..05905e3e 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionService.java @@ -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); + /** + * 幂等保存答案命令。 + * + *

同一 idempotencyKey + 相同载荷 → 返回首次成功响应(超时重试安全)。 + * 同一 idempotencyKey + 不同载荷 → 业务冲突。 + * 旧 clientSequence 或旧 expectedSessionVersion → 拒绝覆盖更新。

+ * + * @param reqVO 答案保存请求 + * @param userId 当前用户 ID(由安全上下文派生) + * @param tenantId 当前租户 ID(由安全上下文派生) + * @return 答案保存响应(含最新版本号) + */ + PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java index 50645775..8e56e1b1 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -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 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> 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 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); + } + } } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeAnswerControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeAnswerControllerHttpTest.java new file mode 100644 index 00000000..30c75d23 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/practice/PracticeAnswerControllerHttpTest.java @@ -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()); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java new file mode 100644 index 00000000..9dc5bc1f --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java @@ -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 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() + .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 r1 = new AtomicReference<>(); + AtomicReference r2 = new AtomicReference<>(); + AtomicReference e1 = new AtomicReference<>(); + AtomicReference 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 r1 = new AtomicReference<>(); + AtomicReference r2 = new AtomicReference<>(); + AtomicReference e1 = new AtomicReference<>(); + AtomicReference 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; + } +} diff --git a/yudao-module-education/src/test/resources/sql/clean.sql b/yudao-module-education/src/test/resources/sql/clean.sql index aca8d043..2c729fc8 100644 --- a/yudao-module-education/src/test/resources/sql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/clean.sql @@ -1,2 +1,3 @@ DELETE FROM education_practice_question; DELETE FROM education_practice_session; +DELETE FROM education_answer_idempotency; diff --git a/yudao-module-education/src/test/resources/sql/create_tables.sql b/yudao-module-education/src/test/resources/sql/create_tables.sql index 9cc023cd..390b39de 100644 --- a/yudao-module-education/src/test/resources/sql/create_tables.sql +++ b/yudao-module-education/src/test/resources/sql/create_tables.sql @@ -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");