feat(education): submit sessions and persist reports
This commit is contained in:
42
sql/mysql/education/004-education-submit-report-rollback.sql
Normal file
42
sql/mysql/education/004-education-submit-report-rollback.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Education 模块 — Ticket #8 迁移回滚
|
||||||
|
-- 004-education-submit-report-rollback.sql
|
||||||
|
-- =============================================
|
||||||
|
--
|
||||||
|
-- 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 these tables:
|
||||||
|
-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
|
||||||
|
-- FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
-- WHERE REFERENCED_TABLE_NAME IN ('education_submit_idempotency',
|
||||||
|
-- 'education_practice_report', 'education_practice_report_detail')
|
||||||
|
-- AND TABLE_SCHEMA = DATABASE();
|
||||||
|
-- Result MUST be empty before proceeding.
|
||||||
|
--
|
||||||
|
-- 2. Verify columns are not referenced by application code:
|
||||||
|
-- Search codebase for 'correct_answer' / 'explanation' references in
|
||||||
|
-- education_practice_question to confirm no other consumers.
|
||||||
|
--
|
||||||
|
-- 3. Verify the tables contain only data from this migration:
|
||||||
|
-- SELECT COUNT(*) AS idempotency_count FROM education_submit_idempotency;
|
||||||
|
-- SELECT COUNT(*) AS report_count FROM education_practice_report;
|
||||||
|
-- SELECT COUNT(*) AS detail_count FROM education_practice_report_detail;
|
||||||
|
-- Operator must confirm these counts are acceptable to destroy.
|
||||||
|
--
|
||||||
|
-- 4. After verification, execute:
|
||||||
|
-- DROP TABLE IF EXISTS education_practice_report_detail;
|
||||||
|
-- DROP TABLE IF EXISTS education_practice_report;
|
||||||
|
-- DROP TABLE IF EXISTS education_submit_idempotency;
|
||||||
|
-- ALTER TABLE education_practice_question
|
||||||
|
-- DROP COLUMN correct_answer,
|
||||||
|
-- DROP COLUMN explanation;
|
||||||
|
--
|
||||||
|
-- DO NOT uncomment or execute the lines below without operator verification.
|
||||||
|
-- -- DROP TABLE IF EXISTS education_practice_report_detail;
|
||||||
|
-- -- DROP TABLE IF EXISTS education_practice_report;
|
||||||
|
-- -- DROP TABLE IF EXISTS education_submit_idempotency;
|
||||||
|
-- -- ALTER TABLE education_practice_question
|
||||||
|
-- -- DROP COLUMN correct_answer,
|
||||||
|
-- -- DROP COLUMN explanation;
|
||||||
164
sql/mysql/education/004-education-submit-report.sql
Normal file
164
sql/mysql/education/004-education-submit-report.sql
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Education 模块 — 交卷提交与成绩报告 DDL
|
||||||
|
-- Ticket #8: 交卷 CAS、保护性答案快照、评分与报告
|
||||||
|
-- Migration: 004
|
||||||
|
-- Prerequisites: 003-education-answer-idempotency.sql
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- Preconditions
|
||||||
|
-- =============================================
|
||||||
|
-- Operator is expected to verify:
|
||||||
|
-- SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
-- WHERE table_schema = DATABASE()
|
||||||
|
-- AND table_name IN ('education_submit_idempotency',
|
||||||
|
-- 'education_practice_report',
|
||||||
|
-- 'education_practice_report_detail');
|
||||||
|
-- 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',
|
||||||
|
-- 'education_answer_idempotency');
|
||||||
|
-- Result MUST be 3.
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- PracticeQuestionDO: add protected answer snapshot columns
|
||||||
|
-- =============================================
|
||||||
|
-- Purpose: At session creation, snapshot correct_answer and explanation
|
||||||
|
-- from the full CatalogQuestionDTO. These fields are NEVER exposed
|
||||||
|
-- before submission (enforced by SafeQuestionRespVO allow-list and
|
||||||
|
-- PracticeQuestionRespVO which does not include them).
|
||||||
|
ALTER TABLE `education_practice_question`
|
||||||
|
ADD COLUMN `correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照(不可在交卷前暴露)',
|
||||||
|
ADD COLUMN `explanation` TEXT DEFAULT NULL COMMENT '解析快照(不可在交卷前暴露)';
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 交卷幂等表
|
||||||
|
-- =============================================
|
||||||
|
-- Purpose: Provide durable idempotency for submit-session commands.
|
||||||
|
-- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original report.
|
||||||
|
-- Same key + different request_hash → conflict.
|
||||||
|
-- Concurrent same-key inserts resolved by unique constraint race handling.
|
||||||
|
--
|
||||||
|
-- Indexes:
|
||||||
|
-- uk_submit_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key.
|
||||||
|
-- INSERT during submit. DuplicateKeyException catch for concurrent-create race resolution.
|
||||||
|
-- idx_submit_session — covers lookup by session for audit/debug.
|
||||||
|
CREATE TABLE `education_submit_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_SESSION'
|
||||||
|
COMMENT '操作类型:SUBMIT_SESSION',
|
||||||
|
`idempotency_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键(UUID)',
|
||||||
|
`request_hash` VARCHAR(64) NOT NULL COMMENT '请求载荷 SHA-256 哈希',
|
||||||
|
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
|
||||||
|
`report_id` BIGINT DEFAULT NULL COMMENT '关联的报告 ID(成功时有值)',
|
||||||
|
`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_submit_idempotency` (`tenant_id`, `user_id`, `operation`, `idempotency_key`),
|
||||||
|
KEY `idx_submit_session` (`tenant_id`, `session_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-交卷幂等记录';
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 练习报告表(会话级)
|
||||||
|
-- =============================================
|
||||||
|
-- Purpose: Store the computed scoring result for a submitted session.
|
||||||
|
-- One report per session. Immutable after creation.
|
||||||
|
-- Question snapshots (stem, selectedAnswer, correctAnswer, explanation)
|
||||||
|
-- are stored in report_details so source question edits don't affect history.
|
||||||
|
--
|
||||||
|
-- Indexes:
|
||||||
|
-- uk_report_session — one report per session (unique).
|
||||||
|
-- idx_report_tenant_user — covers paginated history queries for current tenant+user.
|
||||||
|
-- idx_report_create_time — covers time-sorted listing.
|
||||||
|
CREATE TABLE `education_practice_report` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '用户编号',
|
||||||
|
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
|
||||||
|
`question_count` INT NOT NULL COMMENT '题目总数',
|
||||||
|
`answered_count` INT NOT NULL DEFAULT 0 COMMENT '已答题数',
|
||||||
|
`unanswered_count` INT NOT NULL DEFAULT 0 COMMENT '未答题数',
|
||||||
|
`correct_count` INT NOT NULL DEFAULT 0 COMMENT '正确题数',
|
||||||
|
`incorrect_count` INT NOT NULL DEFAULT 0 COMMENT '错误题数',
|
||||||
|
`score` INT NOT NULL DEFAULT 0 COMMENT '得分(整数,满分 100 为基准)',
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED'
|
||||||
|
COMMENT '报告状态:SUBMITTED',
|
||||||
|
`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_report_session` (`session_id`),
|
||||||
|
KEY `idx_report_tenant_user` (`tenant_id`, `user_id`),
|
||||||
|
KEY `idx_report_create_time` (`create_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习报告';
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 练习报告明细表(逐题结果)
|
||||||
|
-- =============================================
|
||||||
|
-- Purpose: Store per-question scoring results at submission time.
|
||||||
|
-- Includes snapshot of stem, selectedAnswer, correctAnswer, and explanation
|
||||||
|
-- so that history is stable even if source questions are later edited.
|
||||||
|
--
|
||||||
|
-- Indexes:
|
||||||
|
-- uk_report_sequence — per-report uniqueness for question sequence.
|
||||||
|
-- idx_detail_session — covers lookup by session for report assembly.
|
||||||
|
CREATE TABLE `education_practice_report_detail` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '用户编号',
|
||||||
|
`report_id` BIGINT NOT NULL COMMENT '报告 ID',
|
||||||
|
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
|
||||||
|
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
|
||||||
|
`sequence` INT NOT NULL COMMENT '题目序号(1-based)',
|
||||||
|
`stem` TEXT NOT NULL COMMENT '题干快照',
|
||||||
|
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
|
||||||
|
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
|
||||||
|
`selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
|
||||||
|
`correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照',
|
||||||
|
`is_correct` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否正确',
|
||||||
|
`explanation` TEXT DEFAULT NULL COMMENT '解析快照',
|
||||||
|
`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_report_sequence` (`report_id`, `sequence`),
|
||||||
|
KEY `idx_detail_session` (`session_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习报告明细';
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- Post-migration verification queries
|
||||||
|
-- =============================================
|
||||||
|
-- Verify new tables exist:
|
||||||
|
-- SHOW CREATE TABLE education_submit_idempotency;
|
||||||
|
-- SHOW CREATE TABLE education_practice_report;
|
||||||
|
-- SHOW CREATE TABLE education_practice_report_detail;
|
||||||
|
-- Verify columns 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 IN ('correct_answer', 'explanation');
|
||||||
|
-- Verify unique keys are enforced:
|
||||||
|
-- SHOW INDEX FROM education_submit_idempotency WHERE Key_name = 'uk_submit_idempotency';
|
||||||
|
-- SHOW INDEX FROM education_practice_report WHERE Key_name = 'uk_report_session';
|
||||||
|
-- SHOW INDEX FROM education_practice_report_detail WHERE Key_name = 'uk_report_sequence';
|
||||||
|
-- Verify no orphan data (should be 0 after fresh migration):
|
||||||
|
-- SELECT COUNT(*) FROM education_submit_idempotency;
|
||||||
|
-- SELECT COUNT(*) FROM education_practice_report;
|
||||||
|
-- SELECT COUNT(*) FROM education_practice_report_detail;
|
||||||
@@ -35,7 +35,8 @@ public class EducationCapabilityController {
|
|||||||
.module("education")
|
.module("education")
|
||||||
.enabled(educationProperties.isEnabled())
|
.enabled(educationProperties.isEnabled())
|
||||||
.version(educationProperties.getVersion())
|
.version(educationProperties.getVersion())
|
||||||
.capabilities(List.of("shell", "catalog", "questions", "practice-preview", "answer-save"))
|
.capabilities(List.of("shell", "catalog", "questions", "practice-preview",
|
||||||
|
"answer-save", "session-submit", "practice-report"))
|
||||||
.build();
|
.build();
|
||||||
return success(resp);
|
return success(resp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package cn.iocoder.yudao.module.education.controller.app.practice;
|
package cn.iocoder.yudao.module.education.controller.app.practice;
|
||||||
|
|
||||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
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.PracticeAnswerReqVO;
|
||||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
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.PracticeSessionCreateReqVO;
|
||||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||||
|
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||||
|
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -46,20 +51,14 @@ public class PracticeSessionController {
|
|||||||
description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
|
description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
|
||||||
+ "题目顺序由服务端固定,选项不含答案标记。")
|
+ "题目顺序由服务端固定,选项不含答案标记。")
|
||||||
public CommonResult<PracticeSessionRespVO> createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
|
public CommonResult<PracticeSessionRespVO> createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
|
||||||
Long userId = getUserId();
|
return success(practiceSessionService.createPracticeSession(reqVO, getUserId(), getTenantId()));
|
||||||
Long tenantId = getTenantId();
|
|
||||||
PracticeSessionRespVO resp = practiceSessionService.createPracticeSession(reqVO, userId, tenantId);
|
|
||||||
return success(resp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/practice-session/current")
|
@GetMapping("/practice-session/current")
|
||||||
@Operation(summary = "获取当前进行中的练习会话",
|
@Operation(summary = "获取当前进行中的练习会话",
|
||||||
description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。")
|
description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。")
|
||||||
public CommonResult<PracticeSessionRespVO> currentSession() {
|
public CommonResult<PracticeSessionRespVO> currentSession() {
|
||||||
Long userId = getUserId();
|
return success(practiceSessionService.getCurrentSession(getUserId(), getTenantId()));
|
||||||
Long tenantId = getTenantId();
|
|
||||||
PracticeSessionRespVO resp = practiceSessionService.getCurrentSession(userId, tenantId);
|
|
||||||
return success(resp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/practice-session/get")
|
@GetMapping("/practice-session/get")
|
||||||
@@ -67,10 +66,7 @@ public class PracticeSessionController {
|
|||||||
description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。")
|
description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。")
|
||||||
public CommonResult<PracticeSessionRespVO> getSession(
|
public CommonResult<PracticeSessionRespVO> getSession(
|
||||||
@Parameter(description = "会话 ID", required = true) @RequestParam Long id) {
|
@Parameter(description = "会话 ID", required = true) @RequestParam Long id) {
|
||||||
Long userId = getUserId();
|
return success(practiceSessionService.getSession(id, getUserId(), getTenantId()));
|
||||||
Long tenantId = getTenantId();
|
|
||||||
PracticeSessionRespVO resp = practiceSessionService.getSession(id, userId, tenantId);
|
|
||||||
return success(resp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 答案保存 ==========
|
// ========== 答案保存 ==========
|
||||||
@@ -81,27 +77,55 @@ public class PracticeSessionController {
|
|||||||
+ "旧 clientSequence 或旧 expectedSessionVersion 拒绝覆盖。"
|
+ "旧 clientSequence 或旧 expectedSessionVersion 拒绝覆盖。"
|
||||||
+ "客户端根据响应中的 serverVersion 和 acceptedSequence 更新本地状态。")
|
+ "客户端根据响应中的 serverVersion 和 acceptedSequence 更新本地状态。")
|
||||||
public CommonResult<PracticeAnswerRespVO> submitAnswer(@Valid @RequestBody PracticeAnswerReqVO reqVO) {
|
public CommonResult<PracticeAnswerRespVO> submitAnswer(@Valid @RequestBody PracticeAnswerReqVO reqVO) {
|
||||||
Long userId = getUserId();
|
return success(practiceSessionService.submitAnswer(reqVO, getUserId(), getTenantId()));
|
||||||
Long tenantId = getTenantId();
|
}
|
||||||
PracticeAnswerRespVO resp = practiceSessionService.submitAnswer(reqVO, userId, tenantId);
|
|
||||||
return success(resp);
|
// ========== 交卷提交 ==========
|
||||||
|
|
||||||
|
@PostMapping("/practice-session/submit")
|
||||||
|
@Operation(summary = "交卷提交(幂等,原子单向状态转换)",
|
||||||
|
description = "提交练习会话完成评分。ACTIVE→SUBMITTED 原子单向转换。"
|
||||||
|
+ "同一 idempotencyKey + 相同载荷返回首次评分报告(超时重试安全)。"
|
||||||
|
+ "交卷后不可再修改答案。")
|
||||||
|
public CommonResult<PracticeSubmitRespVO> submitSession(@Valid @RequestBody PracticeSubmitReqVO reqVO) {
|
||||||
|
return success(practiceSessionService.submitSession(reqVO, getUserId(), getTenantId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 报告查看 ==========
|
||||||
|
|
||||||
|
@GetMapping("/practice-session/report")
|
||||||
|
@Operation(summary = "获取会话评分报告",
|
||||||
|
description = "按会话 ID 获取评分报告,含逐题结果、正确答案和解析。仅已提交会话可用。")
|
||||||
|
public CommonResult<PracticeSubmitRespVO> getReport(
|
||||||
|
@Parameter(description = "会话 ID", required = true) @RequestParam Long sessionId) {
|
||||||
|
return success(practiceSessionService.getReport(sessionId, getUserId(), getTenantId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/practice-session/reports")
|
||||||
|
@Operation(summary = "分页查询练习历史报告",
|
||||||
|
description = "分页查询当前用户的历史练习报告列表,按创建时间降序。仅返回本人记录。")
|
||||||
|
public CommonResult<PageResult<PracticeSubmitRespVO>> getReportHistory(
|
||||||
|
@Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") @Min(1) int pageNo,
|
||||||
|
@Parameter(description = "每页条数", example = "10") @RequestParam(defaultValue = "10") @Min(1) @Max(100) int pageSize) {
|
||||||
|
return success(practiceSessionService.getReportHistory(getUserId(), getTenantId(), pageNo, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== security helpers ==========
|
// ========== security helpers ==========
|
||||||
|
|
||||||
private Long getUserId() {
|
private Long getUserId() {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||||
if (userId == null) {
|
if (loginUser == null) {
|
||||||
throw exception(UNAUTHORIZED);
|
throw exception(UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
return userId;
|
return loginUser.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long getTenantId() {
|
private Long getTenantId() {
|
||||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
if (loginUser == null) {
|
||||||
throw exception(UNAUTHORIZED);
|
throw exception(UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
return loginUser.getTenantId();
|
return loginUser.getTenantId();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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>仅在报告查看接口中暴露,创建/获取会话接口中绝不出现在响应中。</p>
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Schema(description = "用户 APP - 报告逐题明细响应")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class PracticeReportDetailRespVO {
|
||||||
|
|
||||||
|
@Schema(description = "题目序号(1-based)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||||
|
private Integer sequence;
|
||||||
|
|
||||||
|
@Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
|
||||||
|
private String questionId;
|
||||||
|
|
||||||
|
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几?")
|
||||||
|
private String stem;
|
||||||
|
|
||||||
|
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Schema(description = "难度", example = "easy")
|
||||||
|
private String difficulty;
|
||||||
|
|
||||||
|
@Schema(description = "学生已选答案", example = "A")
|
||||||
|
private String selectedAnswer;
|
||||||
|
|
||||||
|
@Schema(description = "正确答案", example = "B")
|
||||||
|
private String correctAnswer;
|
||||||
|
|
||||||
|
@Schema(description = "是否正确", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
|
||||||
|
private Boolean isCorrect;
|
||||||
|
|
||||||
|
@Schema(description = "解析", example = "1+1=2,因此正确答案为 B")
|
||||||
|
private String explanation;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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。
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Schema(description = "用户 APP - 交卷提交请求")
|
||||||
|
@Data
|
||||||
|
public class PracticeSubmitReqVO {
|
||||||
|
|
||||||
|
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
|
||||||
|
@NotNull(message = "会话 ID 不能为空")
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
@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 = 1, message = "期望会话版本号必须为正整数")
|
||||||
|
private Integer expectedSessionVersion;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交卷提交响应 VO — 评分总览 + 逐题结果。
|
||||||
|
*
|
||||||
|
* <p>包含报告 ID、得分统计和逐题明细。明细中才暴露正确答案和解析。</p>
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Schema(description = "用户 APP - 交卷提交响应")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class PracticeSubmitRespVO {
|
||||||
|
|
||||||
|
@Schema(description = "报告 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
@Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||||
|
private Integer questionCount;
|
||||||
|
|
||||||
|
@Schema(description = "已答题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8")
|
||||||
|
private Integer answeredCount;
|
||||||
|
|
||||||
|
@Schema(description = "未答题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
|
||||||
|
private Integer unansweredCount;
|
||||||
|
|
||||||
|
@Schema(description = "正确题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6")
|
||||||
|
private Integer correctCount;
|
||||||
|
|
||||||
|
@Schema(description = "错误题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
|
||||||
|
private Integer incorrectCount;
|
||||||
|
|
||||||
|
@Schema(description = "得分(整数,满分 100 为基准)", requiredMode = Schema.RequiredMode.REQUIRED, example = "75")
|
||||||
|
private Integer score;
|
||||||
|
|
||||||
|
@Schema(description = "逐题明细(含正确答案和解析)")
|
||||||
|
private List<PracticeReportDetailRespVO> details;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ public class PracticeQuestionDO extends TenantBaseDO {
|
|||||||
/** 选项快照 JSON — 仅含 label、content、order,不含 isCorrect */
|
/** 选项快照 JSON — 仅含 label、content、order,不含 isCorrect */
|
||||||
private String options;
|
private String options;
|
||||||
|
|
||||||
|
/** 正确答案快照 — 不可在交卷前暴露,JSON 格式 */
|
||||||
|
private String correctAnswer;
|
||||||
|
|
||||||
|
/** 解析快照 — 不可在交卷前暴露 */
|
||||||
|
private String explanation;
|
||||||
|
|
||||||
/** 学生已选答案 */
|
/** 学生已选答案 */
|
||||||
private String selectedAnswer;
|
private String selectedAnswer;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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>一个会话最多一份报告(uk_report_session)。创建后不可变。
|
||||||
|
* 题目级结果存储在 PracticeReportDetailDO 中。</p>
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@TableName("education_practice_report")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PracticeReportDO extends TenantBaseDO {
|
||||||
|
|
||||||
|
/** 主键 */
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 用户编号 */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 会话 ID */
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
/** 题目总数 */
|
||||||
|
private Integer questionCount;
|
||||||
|
|
||||||
|
/** 已答题数 */
|
||||||
|
private Integer answeredCount;
|
||||||
|
|
||||||
|
/** 未答题数 */
|
||||||
|
private Integer unansweredCount;
|
||||||
|
|
||||||
|
/** 正确题数 */
|
||||||
|
private Integer correctCount;
|
||||||
|
|
||||||
|
/** 错误题数 */
|
||||||
|
private Integer incorrectCount;
|
||||||
|
|
||||||
|
/** 得分(整数,满分 100 为基准) */
|
||||||
|
private Integer score;
|
||||||
|
|
||||||
|
/** 报告状态 */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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>交卷时从会话题目快照计算,含题干、学生答案、正确答案和解析的快照。
|
||||||
|
* 创建后不可变,确保历史报告不受后续题目修改影响。</p>
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@TableName("education_practice_report_detail")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PracticeReportDetailDO extends TenantBaseDO {
|
||||||
|
|
||||||
|
/** 主键 */
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 用户编号 */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 报告 ID */
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
/** 会话 ID */
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
/** 原始题目 ID */
|
||||||
|
private String questionId;
|
||||||
|
|
||||||
|
/** 题目序号(1-based) */
|
||||||
|
private Integer sequence;
|
||||||
|
|
||||||
|
/** 题干快照 */
|
||||||
|
private String stem;
|
||||||
|
|
||||||
|
/** 题型快照 */
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
/** 难度快照 */
|
||||||
|
private String difficulty;
|
||||||
|
|
||||||
|
/** 学生已选答案 */
|
||||||
|
private String selectedAnswer;
|
||||||
|
|
||||||
|
/** 正确答案快照 */
|
||||||
|
private String correctAnswer;
|
||||||
|
|
||||||
|
/** 是否正确 */
|
||||||
|
private Boolean isCorrect;
|
||||||
|
|
||||||
|
/** 解析快照 */
|
||||||
|
private String explanation;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 存储首次成功的完整报告 JSON,用于超时重试重放。</p>
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@TableName("education_submit_idempotency")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SubmitIdempotencyDO extends TenantBaseDO {
|
||||||
|
|
||||||
|
/** 主键 */
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 交卷用户编号 */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 操作类型:SUBMIT_SESSION */
|
||||||
|
private String operation;
|
||||||
|
|
||||||
|
/** 客户端幂等键(UUID) */
|
||||||
|
private String idempotencyKey;
|
||||||
|
|
||||||
|
/** 请求载荷 SHA-256 哈希 */
|
||||||
|
private String requestHash;
|
||||||
|
|
||||||
|
/** 会话 ID */
|
||||||
|
private Long sessionId;
|
||||||
|
|
||||||
|
/** 关联的报告 ID(成功时有值) */
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
/** 状态:ACCEPTED / CONFLICT */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 首次成功响应 JSON(用于重试重放) */
|
||||||
|
private String responseJson;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.dal.mysql;
|
|||||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
|
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 答案命令幂等记录 Mapper。
|
* 答案命令幂等记录 Mapper。
|
||||||
@@ -24,4 +26,18 @@ public interface AnswerIdempotencyMapper extends BaseMapperX<AnswerIdempotencyDO
|
|||||||
.eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
.eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
|
||||||
|
* Safe for concurrent same-key resolution without DuplicateKeyException.
|
||||||
|
*/
|
||||||
|
@Insert("INSERT IGNORE INTO education_answer_idempotency " +
|
||||||
|
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
|
||||||
|
"question_id, selected_answer, status, response_json, " +
|
||||||
|
"creator, create_time, updater, update_time, deleted) " +
|
||||||
|
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
|
||||||
|
"#{sessionId}, #{questionId}, #{selectedAnswer}, #{status}, #{responseJson}, " +
|
||||||
|
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||||
|
int insertIgnore(AnswerIdempotencyDO record);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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.PracticeReportDetailDO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 练习报告明细 Mapper。
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PracticeReportDetailMapper extends BaseMapperX<PracticeReportDetailDO> {
|
||||||
|
/**
|
||||||
|
* 按报告 ID、租户、用户查询所有明细,按 sequence 升序。
|
||||||
|
* Ownership check: tenant + user must match the report owner.
|
||||||
|
*/
|
||||||
|
default List<PracticeReportDetailDO> selectByReportIdAndTenantAndUser(Long reportId, Long tenantId, Long userId) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<PracticeReportDetailDO>()
|
||||||
|
.eq(PracticeReportDetailDO::getReportId, reportId)
|
||||||
|
.eq(PracticeReportDetailDO::getTenantId, tenantId)
|
||||||
|
.eq(PracticeReportDetailDO::getUserId, userId)
|
||||||
|
.orderByAsc(PracticeReportDetailDO::getSequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按报告 ID 查询所有明细,按 sequence 升序。
|
||||||
|
* Prefer {@link #selectByReportIdAndTenantAndUser} when tenant/user context is available.
|
||||||
|
*/
|
||||||
|
default List<PracticeReportDetailDO> selectByReportIdOrderBySequence(Long reportId) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<PracticeReportDetailDO>()
|
||||||
|
.eq(PracticeReportDetailDO::getReportId, reportId)
|
||||||
|
.orderByAsc(PracticeReportDetailDO::getSequence));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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.PracticeReportDO;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 练习报告 Mapper。
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按会话 ID 和租户查找报告。
|
||||||
|
*/
|
||||||
|
default PracticeReportDO selectBySessionIdAndTenant(Long sessionId, Long tenantId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<PracticeReportDO>()
|
||||||
|
.eq(PracticeReportDO::getSessionId, sessionId)
|
||||||
|
.eq(PracticeReportDO::getTenantId, tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户和用户分页查询报告,按创建时间降序。
|
||||||
|
*/
|
||||||
|
default IPage<PracticeReportDO> selectPageByTenantAndUser(IPage<PracticeReportDO> page, Long tenantId, Long userId) {
|
||||||
|
return selectPage(page, new LambdaQueryWrapperX<PracticeReportDO>()
|
||||||
|
.eq(PracticeReportDO::getTenantId, tenantId)
|
||||||
|
.eq(PracticeReportDO::getUserId, userId)
|
||||||
|
.orderByDesc(PracticeReportDO::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate (uk_report_session) was silently ignored.
|
||||||
|
* Safe for concurrent submit race resolution without DuplicateKeyException.
|
||||||
|
*/
|
||||||
|
@Insert("INSERT IGNORE INTO education_practice_report " +
|
||||||
|
"(tenant_id, user_id, session_id, question_count, answered_count, unanswered_count, " +
|
||||||
|
"correct_count, incorrect_count, score, status, " +
|
||||||
|
"creator, create_time, updater, update_time, deleted) " +
|
||||||
|
"VALUES (#{tenantId}, #{userId}, #{sessionId}, #{questionCount}, #{answeredCount}, #{unansweredCount}, " +
|
||||||
|
"#{correctCount}, #{incorrectCount}, #{score}, #{status}, " +
|
||||||
|
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||||
|
int insertIgnore(PracticeReportDO record);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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.SubmitIdempotencyDO;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交卷幂等记录 Mapper。
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SubmitIdempotencyMapper extends BaseMapperX<SubmitIdempotencyDO> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户、用户、操作、幂等键查找记录。
|
||||||
|
*/
|
||||||
|
default SubmitIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<SubmitIdempotencyDO>()
|
||||||
|
.eq(SubmitIdempotencyDO::getTenantId, tenantId)
|
||||||
|
.eq(SubmitIdempotencyDO::getUserId, userId)
|
||||||
|
.eq(SubmitIdempotencyDO::getOperation, operation)
|
||||||
|
.eq(SubmitIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
|
||||||
|
* Safe for concurrent same-key resolution without DuplicateKeyException.
|
||||||
|
*/
|
||||||
|
@Insert("INSERT IGNORE INTO education_submit_idempotency " +
|
||||||
|
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
|
||||||
|
"report_id, status, response_json, " +
|
||||||
|
"creator, create_time, updater, update_time, deleted) " +
|
||||||
|
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
|
||||||
|
"#{sessionId}, #{reportId}, #{status}, #{responseJson}, " +
|
||||||
|
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||||
|
int insertIgnore(SubmitIdempotencyDO record);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -65,4 +65,13 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode ANSWER_STALE_CROSS_QUESTION_SEQUENCE = new ErrorCode(1_005_003_022, "客户端命令序号过期(跨题目),当前序号 {} 不大于会话已接受的 {}");
|
ErrorCode ANSWER_STALE_CROSS_QUESTION_SEQUENCE = new ErrorCode(1_005_003_022, "客户端命令序号过期(跨题目),当前序号 {} 不大于会话已接受的 {}");
|
||||||
ErrorCode CATALOG_UPSTREAM_OPTIONS_MALFORMED = new ErrorCode(1_005_003_023, "题目选项数据格式异常,请稍后重试");
|
ErrorCode CATALOG_UPSTREAM_OPTIONS_MALFORMED = new ErrorCode(1_005_003_023, "题目选项数据格式异常,请稍后重试");
|
||||||
ErrorCode ANSWER_FIELD_TOO_LONG = new ErrorCode(1_005_003_024, "请求字段过长:{}");
|
ErrorCode ANSWER_FIELD_TOO_LONG = new ErrorCode(1_005_003_024, "请求字段过长:{}");
|
||||||
|
|
||||||
|
// ========== 交卷提交 1-005-003-030 ~ 1-005-003-039 ==========
|
||||||
|
ErrorCode SUBMIT_SESSION_NOT_ACTIVE = new ErrorCode(1_005_003_030, "会话不是进行中状态,无法交卷");
|
||||||
|
ErrorCode SUBMIT_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_003_031, "交卷幂等键相同但请求内容不一致,请刷新重试");
|
||||||
|
ErrorCode SUBMIT_STALE_VERSION = new ErrorCode(1_005_003_032, "会话版本已更新,请刷新后重新交卷(期望版本:{},当前版本:{})");
|
||||||
|
ErrorCode SUBMIT_CONCURRENT_CONFLICT = new ErrorCode(1_005_003_033, "交卷失败:会话状态已被其他请求变更,请重试");
|
||||||
|
ErrorCode REPORT_NOT_FOUND = new ErrorCode(1_005_003_034, "报告不存在");
|
||||||
|
ErrorCode REPORT_NOT_OWN = new ErrorCode(1_005_003_035, "无权访问该报告");
|
||||||
|
ErrorCode REPORT_SESSION_NOT_SUBMITTED = new ErrorCode(1_005_003_036, "会话尚未提交,报告不可用");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package cn.iocoder.yudao.module.education.service.practice;
|
package cn.iocoder.yudao.module.education.service.practice;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
|
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.PracticeAnswerRespVO;
|
||||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
|
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.controller.app.practice.vo.PracticeSessionRespVO;
|
||||||
|
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||||
|
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 练习会话服务接口。
|
* 练习会话服务接口。
|
||||||
@@ -57,4 +60,41 @@ public interface PracticeSessionService {
|
|||||||
*/
|
*/
|
||||||
PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId);
|
PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等交卷提交 — ACTIVE→SUBMITTED 原子单向状态转换。
|
||||||
|
*
|
||||||
|
* <p>同一 idempotencyKey + 相同载荷 → 返回首次评分报告(超时重试安全)。
|
||||||
|
* 同一 idempotencyKey + 不同载荷 → 业务冲突。
|
||||||
|
* 并发提交仅第一个完成评分和持久化,其余返回首次结果。</p>
|
||||||
|
*
|
||||||
|
* <p>评分使用会话固定的题目快照及答案版本,交卷后不可再修改答案。</p>
|
||||||
|
*
|
||||||
|
* @param reqVO 交卷请求
|
||||||
|
* @param userId 当前用户 ID(由安全上下文派生)
|
||||||
|
* @param tenantId 当前租户 ID(由安全上下文派生)
|
||||||
|
* @return 评分报告
|
||||||
|
*/
|
||||||
|
PracticeSubmitRespVO submitSession(PracticeSubmitReqVO reqVO, Long userId, Long tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话评分报告(仅已提交会话)。
|
||||||
|
*
|
||||||
|
* @param sessionId 会话 ID
|
||||||
|
* @param userId 当前用户 ID
|
||||||
|
* @param tenantId 当前租户 ID
|
||||||
|
* @return 评分报告(含逐题明细和解析)
|
||||||
|
*/
|
||||||
|
PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询当前用户的历史练习报告。
|
||||||
|
*
|
||||||
|
* @param userId 当前用户 ID
|
||||||
|
* @param tenantId 当前租户 ID
|
||||||
|
* @param pageNo 页码(从 1 开始)
|
||||||
|
* @param pageSize 每页条数
|
||||||
|
* @return 分页报告列表
|
||||||
|
*/
|
||||||
|
PageResult<PracticeSubmitRespVO> getReportHistory(Long userId, Long tenantId, int pageNo, int pageSize);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,27 @@ package cn.iocoder.yudao.module.education.service.practice;
|
|||||||
|
|
||||||
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.crypto.digest.DigestUtil;
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
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.controller.app.practice.vo.*;
|
||||||
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
|
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||||
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
|
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||||
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 cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
||||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 练习会话服务实现。
|
* 练习会话服务实现。
|
||||||
*
|
*
|
||||||
@@ -40,15 +36,24 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
private final PracticeSessionMapper sessionMapper;
|
private final PracticeSessionMapper sessionMapper;
|
||||||
private final PracticeQuestionMapper questionMapper;
|
private final PracticeQuestionMapper questionMapper;
|
||||||
private final AnswerIdempotencyMapper idempotencyMapper;
|
private final AnswerIdempotencyMapper idempotencyMapper;
|
||||||
|
private final SubmitIdempotencyMapper submitIdempotencyMapper;
|
||||||
|
private final PracticeReportMapper reportMapper;
|
||||||
|
private final PracticeReportDetailMapper reportDetailMapper;
|
||||||
private final QuestionCatalogProvider questionCatalogProvider;
|
private final QuestionCatalogProvider questionCatalogProvider;
|
||||||
|
|
||||||
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
|
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
|
||||||
PracticeQuestionMapper questionMapper,
|
PracticeQuestionMapper questionMapper,
|
||||||
AnswerIdempotencyMapper idempotencyMapper,
|
AnswerIdempotencyMapper idempotencyMapper,
|
||||||
|
SubmitIdempotencyMapper submitIdempotencyMapper,
|
||||||
|
PracticeReportMapper reportMapper,
|
||||||
|
PracticeReportDetailMapper reportDetailMapper,
|
||||||
QuestionCatalogProvider questionCatalogProvider) {
|
QuestionCatalogProvider questionCatalogProvider) {
|
||||||
this.sessionMapper = sessionMapper;
|
this.sessionMapper = sessionMapper;
|
||||||
this.questionMapper = questionMapper;
|
this.questionMapper = questionMapper;
|
||||||
this.idempotencyMapper = idempotencyMapper;
|
this.idempotencyMapper = idempotencyMapper;
|
||||||
|
this.submitIdempotencyMapper = submitIdempotencyMapper;
|
||||||
|
this.reportMapper = reportMapper;
|
||||||
|
this.reportDetailMapper = reportDetailMapper;
|
||||||
this.questionCatalogProvider = questionCatalogProvider;
|
this.questionCatalogProvider = questionCatalogProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,6 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
|
PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (!Objects.equals(existing.getUserId(), userId)) {
|
if (!Objects.equals(existing.getUserId(), userId)) {
|
||||||
// Same tenant, same clientSessionId, but different user → ownership violation
|
|
||||||
throw exception(SESSION_NOT_FOUND);
|
throw exception(SESSION_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (!isSameFingerprint(existing, reqVO)) {
|
if (!isSameFingerprint(existing, reqVO)) {
|
||||||
@@ -73,7 +77,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
List<CatalogQuestionDTO> questions = fetchAndOrderQuestions(reqVO.getCollectionId(), reqVO.getNodeId(),
|
List<CatalogQuestionDTO> questions = fetchAndOrderQuestions(reqVO.getCollectionId(), reqVO.getNodeId(),
|
||||||
reqVO.getType(), reqVO.getDifficulty(), requestedCount);
|
reqVO.getType(), reqVO.getDifficulty(), requestedCount);
|
||||||
|
|
||||||
// 2a. Reject underfilled sessions: fewer visible questions than requested
|
// 2a. Reject underfilled sessions
|
||||||
if (questions.size() < requestedCount) {
|
if (questions.size() < requestedCount) {
|
||||||
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, questions.size(), requestedCount);
|
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, questions.size(), requestedCount);
|
||||||
}
|
}
|
||||||
@@ -94,13 +98,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
try {
|
try {
|
||||||
sessionMapper.insert(session);
|
sessionMapper.insert(session);
|
||||||
} catch (DuplicateKeyException e) {
|
} catch (DuplicateKeyException e) {
|
||||||
// Race: another thread inserted the same tenant+clientSessionId between our check and insert.
|
|
||||||
// Reload and verify ownership + fingerprint match.
|
|
||||||
log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution",
|
log.warn("DuplicateKeyException on clientSessionId={} for tenant={} — reloading for idempotency resolution",
|
||||||
reqVO.getClientSessionId(), tenantId);
|
reqVO.getClientSessionId(), tenantId);
|
||||||
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
|
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId());
|
||||||
if (winner == null) {
|
if (winner == null) {
|
||||||
// Defensive: should not happen if UK constraint triggered
|
|
||||||
throw exception(SESSION_DUPLICATE_CLIENT_ID);
|
throw exception(SESSION_DUPLICATE_CLIENT_ID);
|
||||||
}
|
}
|
||||||
if (!Objects.equals(winner.getUserId(), userId)) {
|
if (!Objects.equals(winner.getUserId(), userId)) {
|
||||||
@@ -112,7 +113,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
|
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create question snapshots (server-determined order)
|
// 4. Create question snapshots with protected answer key
|
||||||
List<PracticeQuestionDO> questionDOs = new ArrayList<>(questions.size());
|
List<PracticeQuestionDO> questionDOs = new ArrayList<>(questions.size());
|
||||||
int seq = 1;
|
int seq = 1;
|
||||||
for (CatalogQuestionDTO q : questions) {
|
for (CatalogQuestionDTO q : questions) {
|
||||||
@@ -126,6 +127,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
pq.setType(q.getType());
|
pq.setType(q.getType());
|
||||||
pq.setDifficulty(q.getDifficulty());
|
pq.setDifficulty(q.getDifficulty());
|
||||||
pq.setOptions(optionsToSafeJson(q.getOptions()));
|
pq.setOptions(optionsToSafeJson(q.getOptions()));
|
||||||
|
pq.setCorrectAnswer(correctAnswerToJson(q.getCorrectAnswer()));
|
||||||
|
pq.setExplanation(q.getExplanation());
|
||||||
pq.setIsAnswered(false);
|
pq.setIsAnswered(false);
|
||||||
questionDOs.add(pq);
|
questionDOs.add(pq);
|
||||||
}
|
}
|
||||||
@@ -163,22 +166,32 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId) {
|
public PracticeAnswerRespVO submitAnswer(PracticeAnswerReqVO reqVO, Long userId, Long tenantId) {
|
||||||
// 1. Compute request hash for content-based idempotency (lightweight, no DB)
|
|
||||||
String requestHash = computeRequestHash(reqVO);
|
String requestHash = computeRequestHash(reqVO);
|
||||||
|
|
||||||
// 2. Check idempotency FIRST — before any business validation (replay safety)
|
// 1. INSERT IGNORE idempotency — replay committed responses before any state checks
|
||||||
AnswerIdempotencyDO existingRecord = idempotencyMapper.selectByKey(
|
AnswerIdempotencyDO idempotency = new AnswerIdempotencyDO();
|
||||||
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
|
idempotency.setTenantId(tenantId);
|
||||||
if (existingRecord != null) {
|
idempotency.setUserId(userId);
|
||||||
// Same key + same hash → replay original committed response
|
idempotency.setOperation("SUBMIT_ANSWER");
|
||||||
if (Objects.equals(existingRecord.getRequestHash(), requestHash)) {
|
idempotency.setIdempotencyKey(reqVO.getIdempotencyKey());
|
||||||
return JsonUtils.parseObject(existingRecord.getResponseJson(), PracticeAnswerRespVO.class);
|
idempotency.setRequestHash(requestHash);
|
||||||
|
idempotency.setSessionId(reqVO.getSessionId());
|
||||||
|
idempotency.setQuestionId(""); // placeholder, updated after question lookup
|
||||||
|
idempotency.setSelectedAnswer(reqVO.getSelectedAnswer());
|
||||||
|
idempotency.setStatus("ACCEPTED");
|
||||||
|
idempotency.setResponseJson(""); // placeholder
|
||||||
|
|
||||||
|
int idemInserted = idempotencyMapper.insertIgnore(idempotency);
|
||||||
|
if (idemInserted == 0) {
|
||||||
|
AnswerIdempotencyDO existing = idempotencyMapper.selectByKey(
|
||||||
|
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
|
||||||
|
if (existing != null && Objects.equals(existing.getRequestHash(), requestHash)) {
|
||||||
|
return JsonUtils.parseObject(existing.getResponseJson(), PracticeAnswerRespVO.class);
|
||||||
}
|
}
|
||||||
// Same key + different hash → conflict
|
|
||||||
throw exception(ANSWER_IDEMPOTENCY_CONFLICT);
|
throw exception(ANSWER_IDEMPOTENCY_CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load and validate session ownership, status, version
|
// 2. Load and validate session
|
||||||
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(reqVO.getSessionId(), tenantId);
|
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(reqVO.getSessionId(), tenantId);
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
throw exception(SESSION_NOT_FOUND);
|
throw exception(SESSION_NOT_FOUND);
|
||||||
@@ -195,7 +208,6 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Version guard: null/overflow defensive check (finding #5)
|
|
||||||
if (session.getVersion() == null) {
|
if (session.getVersion() == null) {
|
||||||
throw exception(ANSWER_SESSION_VERSION_NULL);
|
throw exception(ANSWER_SESSION_VERSION_NULL);
|
||||||
}
|
}
|
||||||
@@ -206,26 +218,24 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
throw exception(ANSWER_SESSION_VERSION_OVERFLOW);
|
throw exception(ANSWER_SESSION_VERSION_OVERFLOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Session-level clientSequence guard (finding #3)
|
|
||||||
if (session.getLastClientSequence() != null
|
if (session.getLastClientSequence() != null
|
||||||
&& reqVO.getClientSequence() <= session.getLastClientSequence()) {
|
&& reqVO.getClientSequence() <= session.getLastClientSequence()) {
|
||||||
throw exception(ANSWER_STALE_CROSS_QUESTION_SEQUENCE,
|
throw exception(ANSWER_STALE_CROSS_QUESTION_SEQUENCE,
|
||||||
reqVO.getClientSequence(), session.getLastClientSequence());
|
reqVO.getClientSequence(), session.getLastClientSequence());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Load question, validate it belongs to session
|
// 3. Load question and validate
|
||||||
PracticeQuestionDO question = questionMapper.selectBySessionIdAndSequence(
|
PracticeQuestionDO question = questionMapper.selectBySessionIdAndSequence(
|
||||||
reqVO.getSessionId(), reqVO.getQuestionSequence());
|
reqVO.getSessionId(), reqVO.getQuestionSequence());
|
||||||
if (question == null) {
|
if (question == null) {
|
||||||
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
|
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()) {
|
if (reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty()) {
|
||||||
validateOption(question, reqVO.getSelectedAnswer());
|
validateOption(question, reqVO.getSelectedAnswer());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Compute anticipated response
|
// 4. Build response and update idempotency with full data
|
||||||
int newVersion = session.getVersion() + 1;
|
int newVersion = session.getVersion() + 1;
|
||||||
PracticeAnswerRespVO response = PracticeAnswerRespVO.builder()
|
PracticeAnswerRespVO response = PracticeAnswerRespVO.builder()
|
||||||
.sessionId(session.getId())
|
.sessionId(session.getId())
|
||||||
@@ -234,68 +244,254 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
.serverVersion(newVersion)
|
.serverVersion(newVersion)
|
||||||
.acceptedSequence(reqVO.getClientSequence())
|
.acceptedSequence(reqVO.getClientSequence())
|
||||||
.build();
|
.build();
|
||||||
|
String responseJson = JsonUtils.toJsonString(response);
|
||||||
|
|
||||||
// 9. Insert idempotency record (reserve key — rollback on failure)
|
// Re-select idempotency to get auto-generated ID, then update with full response
|
||||||
AnswerIdempotencyDO idempotency = new AnswerIdempotencyDO();
|
AnswerIdempotencyDO inserted = idempotencyMapper.selectByKey(
|
||||||
idempotency.setTenantId(tenantId);
|
tenantId, userId, "SUBMIT_ANSWER", reqVO.getIdempotencyKey());
|
||||||
idempotency.setUserId(userId);
|
if (inserted != null) {
|
||||||
idempotency.setOperation("SUBMIT_ANSWER");
|
idempotencyMapper.update(null,
|
||||||
idempotency.setIdempotencyKey(reqVO.getIdempotencyKey());
|
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<AnswerIdempotencyDO>()
|
||||||
idempotency.setRequestHash(requestHash);
|
.eq(AnswerIdempotencyDO::getId, inserted.getId())
|
||||||
idempotency.setSessionId(session.getId());
|
.set(AnswerIdempotencyDO::getQuestionId, question.getQuestionId())
|
||||||
idempotency.setQuestionId(question.getQuestionId());
|
.set(AnswerIdempotencyDO::getResponseJson, responseJson));
|
||||||
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)
|
// 5. CAS session version + lastClientSequence
|
||||||
int casResult = sessionMapper.casIncrementVersion(session.getId(), tenantId, userId,
|
int casResult = sessionMapper.casIncrementVersion(session.getId(), tenantId, userId,
|
||||||
session.getVersion(), reqVO.getClientSequence());
|
session.getVersion(), reqVO.getClientSequence());
|
||||||
if (casResult == 0) {
|
if (casResult == 0) {
|
||||||
// Version changed or session no longer ACTIVE — rollback + throw
|
|
||||||
throw exception(ANSWER_STALE_VERSION, session.getVersion(),
|
throw exception(ANSWER_STALE_VERSION, session.getVersion(),
|
||||||
"已被其他请求更新,请刷新重试");
|
"已被其他请求更新,请刷新重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. CAS update question answer (finding #1: conditional, affected-row result)
|
// 6. Update question answer with clientSequence guard
|
||||||
int updated = questionMapper.updateAnswerIfNewer(question.getId(), tenantId,
|
int updated = questionMapper.updateAnswerIfNewer(question.getId(), tenantId,
|
||||||
reqVO.getSelectedAnswer(),
|
reqVO.getSelectedAnswer(),
|
||||||
reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty(),
|
reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty(),
|
||||||
reqVO.getClientSequence());
|
reqVO.getClientSequence());
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
// Reload and classify staleness
|
|
||||||
PracticeQuestionDO reloaded = questionMapper.selectById(question.getId());
|
PracticeQuestionDO reloaded = questionMapper.selectById(question.getId());
|
||||||
if (reloaded != null && reloaded.getClientSequence() != null
|
if (reloaded != null && reloaded.getClientSequence() != null
|
||||||
&& reqVO.getClientSequence() <= reloaded.getClientSequence()) {
|
&& reqVO.getClientSequence() <= reloaded.getClientSequence()) {
|
||||||
throw exception(ANSWER_STALE_CLIENT_SEQUENCE,
|
throw exception(ANSWER_STALE_CLIENT_SEQUENCE,
|
||||||
reqVO.getClientSequence(), reloaded.getClientSequence());
|
reqVO.getClientSequence(), reloaded.getClientSequence());
|
||||||
}
|
}
|
||||||
// tenant guard failed or question deleted — treat as not-in-session
|
|
||||||
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
|
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public PracticeSubmitRespVO submitSession(PracticeSubmitReqVO reqVO, Long userId, Long tenantId) {
|
||||||
|
// 1. Compute request hash
|
||||||
|
String requestHash = computeSubmitHash(reqVO);
|
||||||
|
|
||||||
|
// 2. Idempotency check: SELECT before any writes
|
||||||
|
SubmitIdempotencyDO existing = submitIdempotencyMapper.selectByKey(
|
||||||
|
tenantId, userId, "SUBMIT_SESSION", reqVO.getIdempotencyKey());
|
||||||
|
if (existing != null) {
|
||||||
|
if (!Objects.equals(existing.getRequestHash(), requestHash)) {
|
||||||
|
throw exception(SUBMIT_IDEMPOTENCY_CONFLICT);
|
||||||
|
}
|
||||||
|
// Same hash — only replay a fully committed response (reportId != null)
|
||||||
|
if (existing.getReportId() != null) {
|
||||||
|
return JsonUtils.parseObject(existing.getResponseJson(), PracticeSubmitRespVO.class);
|
||||||
|
}
|
||||||
|
// Same key, same hash, but reportId is null: another request with this key is in-flight.
|
||||||
|
throw exception(SUBMIT_CONCURRENT_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load and validate session
|
||||||
|
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())) {
|
||||||
|
// If session is SUBMITTED and a report exists, return it (different-key concurrent submit loser)
|
||||||
|
if ("SUBMITTED".equals(session.getStatus())) {
|
||||||
|
PracticeReportDO existingReport = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
|
||||||
|
if (existingReport != null) {
|
||||||
|
List<PracticeReportDetailDO> details =
|
||||||
|
reportDetailMapper.selectByReportIdAndTenantAndUser(
|
||||||
|
existingReport.getId(), tenantId, userId);
|
||||||
|
return buildSubmitResp(existingReport, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw exception(switch (session.getStatus()) {
|
||||||
|
case "SUBMITTED" -> SESSION_ALREADY_SUBMITTED;
|
||||||
|
case "EXPIRED" -> SESSION_EXPIRED;
|
||||||
|
case "CANCELLED" -> SESSION_CANCELLED;
|
||||||
|
default -> SUBMIT_SESSION_NOT_ACTIVE;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Objects.equals(session.getVersion(), reqVO.getExpectedSessionVersion())) {
|
||||||
|
throw exception(SUBMIT_STALE_VERSION, reqVO.getExpectedSessionVersion(), session.getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Load question snapshots and score
|
||||||
|
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
|
||||||
|
int score = computeScore(questions);
|
||||||
|
int answeredCount = (int) questions.stream().filter(q -> q.getIsAnswered() != null && q.getIsAnswered()).count();
|
||||||
|
int unansweredCount = questions.size() - answeredCount;
|
||||||
|
int correctCount = (int) questions.stream()
|
||||||
|
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
|
||||||
|
.count();
|
||||||
|
int incorrectCount = answeredCount - correctCount;
|
||||||
|
|
||||||
|
// 5. Build report details
|
||||||
|
List<PracticeReportDetailDO> details = new ArrayList<>(questions.size());
|
||||||
|
for (PracticeQuestionDO q : questions) {
|
||||||
|
PracticeReportDetailDO detail = new PracticeReportDetailDO();
|
||||||
|
detail.setTenantId(tenantId);
|
||||||
|
detail.setUserId(userId);
|
||||||
|
detail.setSessionId(session.getId());
|
||||||
|
detail.setQuestionId(q.getQuestionId());
|
||||||
|
detail.setSequence(q.getSequence());
|
||||||
|
detail.setStem(q.getStem());
|
||||||
|
detail.setType(q.getType());
|
||||||
|
detail.setDifficulty(q.getDifficulty());
|
||||||
|
detail.setSelectedAnswer(q.getSelectedAnswer());
|
||||||
|
detail.setCorrectAnswer(q.getCorrectAnswer());
|
||||||
|
detail.setIsCorrect(q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q));
|
||||||
|
detail.setExplanation(q.getExplanation());
|
||||||
|
details.add(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build report
|
||||||
|
PracticeReportDO report = new PracticeReportDO();
|
||||||
|
report.setTenantId(tenantId);
|
||||||
|
report.setUserId(userId);
|
||||||
|
report.setSessionId(session.getId());
|
||||||
|
report.setQuestionCount(questions.size());
|
||||||
|
report.setAnsweredCount(answeredCount);
|
||||||
|
report.setUnansweredCount(unansweredCount);
|
||||||
|
report.setCorrectCount(correctCount);
|
||||||
|
report.setIncorrectCount(incorrectCount);
|
||||||
|
report.setScore(score);
|
||||||
|
report.setStatus("SUBMITTED");
|
||||||
|
|
||||||
|
// 7. INSERT IGNORE report — resolves concurrent different-key submit races
|
||||||
|
int reportInserted = reportMapper.insertIgnore(report);
|
||||||
|
if (reportInserted == 0) {
|
||||||
|
// Another submit already committed for this session — return winner's report
|
||||||
|
PracticeReportDO winnerReport = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
|
||||||
|
if (winnerReport != null) {
|
||||||
|
SubmitIdempotencyDO winnerKey = submitIdempotencyMapper.selectByKey(
|
||||||
|
tenantId, userId, "SUBMIT_SESSION", reqVO.getIdempotencyKey());
|
||||||
|
if (winnerKey != null && !Objects.equals(winnerKey.getRequestHash(), requestHash)) {
|
||||||
|
throw exception(SUBMIT_IDEMPOTENCY_CONFLICT);
|
||||||
|
}
|
||||||
|
// A different idempotency key may have won. Return the immutable winner report;
|
||||||
|
// do not create a second replay record for this losing key.
|
||||||
|
List<PracticeReportDetailDO> winnerDetails =
|
||||||
|
reportDetailMapper.selectByReportIdAndTenantAndUser(
|
||||||
|
winnerReport.getId(), tenantId, userId);
|
||||||
|
return buildSubmitResp(winnerReport, winnerDetails);
|
||||||
|
}
|
||||||
|
throw exception(SUBMIT_CONCURRENT_CONFLICT);
|
||||||
|
}
|
||||||
|
// Re-select to get auto-generated ID
|
||||||
|
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
|
||||||
|
|
||||||
|
// 8. Insert details
|
||||||
|
for (PracticeReportDetailDO detail : details) {
|
||||||
|
detail.setReportId(report.getId());
|
||||||
|
}
|
||||||
|
reportDetailMapper.insertBatch(details);
|
||||||
|
|
||||||
|
// 9. CAS: ACTIVE → SUBMITTED
|
||||||
|
int casResult = sessionMapper.update(null,
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PracticeSessionDO>()
|
||||||
|
.eq(PracticeSessionDO::getId, session.getId())
|
||||||
|
.eq(PracticeSessionDO::getTenantId, tenantId)
|
||||||
|
.eq(PracticeSessionDO::getUserId, userId)
|
||||||
|
.eq(PracticeSessionDO::getStatus, "ACTIVE")
|
||||||
|
.eq(PracticeSessionDO::getVersion, reqVO.getExpectedSessionVersion())
|
||||||
|
.set(PracticeSessionDO::getStatus, "SUBMITTED")
|
||||||
|
.setSql("version = version + 1"));
|
||||||
|
if (casResult == 0) {
|
||||||
|
log.error("Submit CAS failed for sessionId={}, expectedVersion={}",
|
||||||
|
session.getId(), reqVO.getExpectedSessionVersion());
|
||||||
|
throw exception(SUBMIT_CONCURRENT_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Build final response
|
||||||
|
PracticeSubmitRespVO submitResp = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(report.getId())
|
||||||
|
.sessionId(session.getId())
|
||||||
|
.questionCount(questions.size())
|
||||||
|
.answeredCount(answeredCount)
|
||||||
|
.unansweredCount(unansweredCount)
|
||||||
|
.correctCount(correctCount)
|
||||||
|
.incorrectCount(incorrectCount)
|
||||||
|
.score(score)
|
||||||
|
.details(buildReportDetailVOs(details))
|
||||||
|
.build();
|
||||||
|
String responseJson = JsonUtils.toJsonString(submitResp);
|
||||||
|
|
||||||
|
// 11. Regular INSERT idempotency — only winners reach here (no race on same key)
|
||||||
|
// If DuplicateKeyException occurs, the transaction rolls back cleanly (CAS + details also rolled back).
|
||||||
|
SubmitIdempotencyDO idempotency = new SubmitIdempotencyDO();
|
||||||
|
idempotency.setTenantId(tenantId);
|
||||||
|
idempotency.setUserId(userId);
|
||||||
|
idempotency.setOperation("SUBMIT_SESSION");
|
||||||
|
idempotency.setIdempotencyKey(reqVO.getIdempotencyKey());
|
||||||
|
idempotency.setRequestHash(requestHash);
|
||||||
|
idempotency.setSessionId(session.getId());
|
||||||
|
idempotency.setReportId(report.getId());
|
||||||
|
idempotency.setStatus("ACCEPTED");
|
||||||
|
idempotency.setResponseJson(responseJson);
|
||||||
|
submitIdempotencyMapper.insert(idempotency);
|
||||||
|
|
||||||
|
return submitResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId) {
|
||||||
|
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);
|
||||||
|
if (session == null) {
|
||||||
|
throw exception(SESSION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!Objects.equals(session.getUserId(), userId)) {
|
||||||
|
throw exception(REPORT_NOT_OWN);
|
||||||
|
}
|
||||||
|
if (!"SUBMITTED".equals(session.getStatus())) {
|
||||||
|
throw exception(REPORT_SESSION_NOT_SUBMITTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
PracticeReportDO report = reportMapper.selectBySessionIdAndTenant(sessionId, tenantId);
|
||||||
|
if (report == null) {
|
||||||
|
throw exception(REPORT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
List<PracticeReportDetailDO> details = reportDetailMapper.selectByReportIdAndTenantAndUser(
|
||||||
|
report.getId(), tenantId, userId);
|
||||||
|
|
||||||
|
return buildSubmitResp(report, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageResult<PracticeSubmitRespVO> getReportHistory(Long userId, Long tenantId, int pageNo, int pageSize) {
|
||||||
|
IPage<PracticeReportDO> page = reportMapper.selectPageByTenantAndUser(
|
||||||
|
new Page<>(pageNo, pageSize), tenantId, userId);
|
||||||
|
|
||||||
|
List<PracticeSubmitRespVO> list = page.getRecords().stream()
|
||||||
|
.map(report -> {
|
||||||
|
List<PracticeReportDetailDO> details =
|
||||||
|
reportDetailMapper.selectByReportIdAndTenantAndUser(report.getId(), tenantId, userId);
|
||||||
|
return buildSubmitResp(report, details);
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageResult<>(list, page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
// ========== internal helpers ==========
|
// ========== internal helpers ==========
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
|
* 从 provider 获取题目列表,按 questionId 稳定排序,取前 count 条。
|
||||||
* 所有筛选条件(含 nodeId)完整转发到 provider,不做客户端裁剪。
|
* 所有筛选条件(含 nodeId)完整转发到 provider,不做客户端裁剪。
|
||||||
@@ -351,6 +547,28 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
return JsonUtils.toJsonString(safeOptions);
|
return JsonUtils.toJsonString(safeOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical answer representation for storage.
|
||||||
|
* String → stored as-is (e.g. "B"); List → sorted JSON array (e.g. '["A","C"]');
|
||||||
|
* null → null. This normalizes provider correctAnswer which may be a String or List.
|
||||||
|
*/
|
||||||
|
private String correctAnswerToJson(Object correctAnswer) {
|
||||||
|
if (correctAnswer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (correctAnswer instanceof String s) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (correctAnswer instanceof List<?> list) {
|
||||||
|
List<String> sorted = list.stream()
|
||||||
|
.map(Object::toString)
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return JsonUtils.toJsonString(sorted);
|
||||||
|
}
|
||||||
|
return JsonUtils.toJsonString(correctAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建会话响应 VO。
|
* 构建会话响应 VO。
|
||||||
*/
|
*/
|
||||||
@@ -372,6 +590,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建单题响应 VO — 从快照安全还原。
|
* 构建单题响应 VO — 从快照安全还原。
|
||||||
|
* 绝不包含 correctAnswer/explanation,仅暴露 label/content/order。
|
||||||
*/
|
*/
|
||||||
private PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) {
|
private PracticeQuestionRespVO buildQuestionResp(PracticeQuestionDO pq) {
|
||||||
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
|
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
|
||||||
@@ -407,11 +626,101 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build submit response from report + details.
|
||||||
|
*/
|
||||||
|
private PracticeSubmitRespVO buildSubmitResp(PracticeReportDO report, List<PracticeReportDetailDO> details) {
|
||||||
|
return PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(report.getId())
|
||||||
|
.sessionId(report.getSessionId())
|
||||||
|
.questionCount(report.getQuestionCount())
|
||||||
|
.answeredCount(report.getAnsweredCount())
|
||||||
|
.unansweredCount(report.getUnansweredCount())
|
||||||
|
.correctCount(report.getCorrectCount())
|
||||||
|
.incorrectCount(report.getIncorrectCount())
|
||||||
|
.score(report.getScore())
|
||||||
|
.details(buildReportDetailVOs(details))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert report detail DOs to VOs (exposes correctAnswer/explanation for submitted sessions only).
|
||||||
|
*/
|
||||||
|
private List<PracticeReportDetailRespVO> buildReportDetailVOs(List<PracticeReportDetailDO> details) {
|
||||||
|
return details.stream()
|
||||||
|
.map(d -> PracticeReportDetailRespVO.builder()
|
||||||
|
.sequence(d.getSequence())
|
||||||
|
.questionId(d.getQuestionId())
|
||||||
|
.stem(d.getStem())
|
||||||
|
.type(d.getType())
|
||||||
|
.difficulty(d.getDifficulty())
|
||||||
|
.selectedAnswer(d.getSelectedAnswer())
|
||||||
|
.correctAnswer(d.getCorrectAnswer())
|
||||||
|
.isCorrect(d.getIsCorrect())
|
||||||
|
.explanation(d.getExplanation())
|
||||||
|
.build())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a stored answer string into its canonical form for comparison.
|
||||||
|
* - null/empty → null (treated as no answer / no correct answer)
|
||||||
|
* - JSON array (starts with '[') → parse as String list, sort, re-serialize
|
||||||
|
* - plain string → trim
|
||||||
|
* Malformed JSON arrays fall through as plain string comparison.
|
||||||
|
*/
|
||||||
|
private String normalizeAnswer(String stored) {
|
||||||
|
if (stored == null || stored.isEmpty()) return null;
|
||||||
|
String trimmed = stored.trim();
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
List<String> list = (List) JsonUtils.parseArray(trimmed, String.class);
|
||||||
|
if (list != null) {
|
||||||
|
List<String> sorted = new ArrayList<>(list);
|
||||||
|
Collections.sort(sorted);
|
||||||
|
return JsonUtils.toJsonString(sorted);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Not valid JSON array → compare as plain string below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the student's selected answer matches the correct answer snapshot.
|
||||||
|
* Uses canonical normalization: single choice compares trimmed strings;
|
||||||
|
* multiple choice compares sorted sets order-insensitively via normalized JSON.
|
||||||
|
* Fail-closed: null/empty on either side → false.
|
||||||
|
*/
|
||||||
|
private boolean isAnswerCorrect(PracticeQuestionDO question) {
|
||||||
|
String normCorrect = normalizeAnswer(question.getCorrectAnswer());
|
||||||
|
String normSelected = normalizeAnswer(question.getSelectedAnswer());
|
||||||
|
if (normCorrect == null || normSelected == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return normCorrect.equals(normSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a score from the question snapshots on a 100-point integer basis.
|
||||||
|
* Deterministic: answered correct / total questions * 100, rounded half-up to integer.
|
||||||
|
*/
|
||||||
|
private int computeScore(List<PracticeQuestionDO> questions) {
|
||||||
|
int total = questions.size();
|
||||||
|
if (total == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int correct = (int) questions.stream()
|
||||||
|
.filter(q -> q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q))
|
||||||
|
.count();
|
||||||
|
// Deterministic rounding: (correct * 100 + total/2) / total
|
||||||
|
return (int) ((correct * 100L + total / 2) / total);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify that the existing session's immutable selection criteria match the current request.
|
* Verify that the existing session's immutable selection criteria match the current request.
|
||||||
* Used during race-resolution: if a concurrent insert wins, the calling thread's payload
|
|
||||||
* must be identical, otherwise it's an idempotency mismatch.
|
|
||||||
*/
|
*/
|
||||||
private boolean isSameFingerprint(PracticeSessionDO existing, PracticeSessionCreateReqVO reqVO) {
|
private boolean isSameFingerprint(PracticeSessionDO existing, PracticeSessionCreateReqVO reqVO) {
|
||||||
return Objects.equals(existing.getCollectionId(), reqVO.getCollectionId())
|
return Objects.equals(existing.getCollectionId(), reqVO.getCollectionId())
|
||||||
@@ -422,8 +731,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute SHA-256 hash of canonical request payload for idempotency content checking.
|
* Compute SHA-256 hash of canonical answer request payload for idempotency content checking.
|
||||||
* Fields are ordered deterministically; same payload always produces same hash.
|
|
||||||
*/
|
*/
|
||||||
private String computeRequestHash(PracticeAnswerReqVO reqVO) {
|
private String computeRequestHash(PracticeAnswerReqVO reqVO) {
|
||||||
Map<String, Object> canonical = new TreeMap<>();
|
Map<String, Object> canonical = new TreeMap<>();
|
||||||
@@ -436,12 +744,23 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that the selected answer exists among the question's options.
|
* Compute SHA-256 hash of canonical submit request payload for idempotency content checking.
|
||||||
* Fail-closed: malformed options data → immediate rejection.
|
*/
|
||||||
|
private String computeSubmitHash(PracticeSubmitReqVO reqVO) {
|
||||||
|
Map<String, Object> canonical = new TreeMap<>();
|
||||||
|
canonical.put("sessionId", reqVO.getSessionId());
|
||||||
|
canonical.put("expectedSessionVersion", reqVO.getExpectedSessionVersion());
|
||||||
|
return DigestUtil.sha256Hex(JsonUtils.toJsonString(canonical));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the selected answer(s) exist among the question's options.
|
||||||
|
* Single choice: selectedAnswer is a plain label string (e.g. "B").
|
||||||
|
* Multiple choice: selectedAnswer is a JSON array string (e.g. '["A","C"]').
|
||||||
|
* Fail-closed: malformed options data or invalid answer → immediate rejection.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
private void validateOption(PracticeQuestionDO question, String selectedAnswer) {
|
private void validateOption(PracticeQuestionDO question, String selectedAnswer) {
|
||||||
// null or blank options → upstream malformed
|
|
||||||
if (question.getOptions() == null || question.getOptions().isBlank()) {
|
if (question.getOptions() == null || question.getOptions().isBlank()) {
|
||||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||||
}
|
}
|
||||||
@@ -453,12 +772,12 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// non-array or empty → malformed
|
|
||||||
if (options == null || options.isEmpty()) {
|
if (options == null || options.isEmpty()) {
|
||||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate every element: non-null, has non-blank label
|
// Validate option labels exist
|
||||||
|
java.util.Set<String> validLabels = new java.util.HashSet<>();
|
||||||
for (Map<String, Object> o : options) {
|
for (Map<String, Object> o : options) {
|
||||||
if (o == null) {
|
if (o == null) {
|
||||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||||
@@ -467,13 +786,32 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
|||||||
if (label == null || (label instanceof String s && s.isBlank())) {
|
if (label == null || (label instanceof String s && s.isBlank())) {
|
||||||
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
throw exception(CATALOG_UPSTREAM_OPTIONS_MALFORMED);
|
||||||
}
|
}
|
||||||
|
validLabels.add(label.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate selectedAnswer exists in options
|
// Parse selected answer(s) — support single label or JSON array of labels
|
||||||
boolean valid = options.stream()
|
String trimmed = selectedAnswer.trim();
|
||||||
.anyMatch(o -> Objects.equals(o.get("label"), selectedAnswer));
|
if (trimmed.startsWith("[")) {
|
||||||
if (!valid) {
|
// Multiple-choice: parse JSON array, validate each element
|
||||||
throw exception(ANSWER_OPTION_INVALID, selectedAnswer);
|
List<String> selections;
|
||||||
|
try {
|
||||||
|
selections = (List<String>) JsonUtils.parseArray(trimmed, String.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw exception(ANSWER_OPTION_INVALID, selectedAnswer);
|
||||||
|
}
|
||||||
|
if (selections == null || selections.isEmpty()) {
|
||||||
|
throw exception(ANSWER_OPTION_INVALID, selectedAnswer);
|
||||||
|
}
|
||||||
|
for (String sel : selections) {
|
||||||
|
if (!validLabels.contains(sel)) {
|
||||||
|
throw exception(ANSWER_OPTION_INVALID, sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single choice: plain label
|
||||||
|
if (!validLabels.contains(trimmed)) {
|
||||||
|
throw exception(ANSWER_OPTION_INVALID, trimmed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,6 +322,33 @@ class PracticeSessionControllerHttpTest {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========== Ticket #8 #6: Page bounds validation ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that @Min/@Max annotations are present on the report history endpoint parameters.
|
||||||
|
* Method-level validation is handled by Spring's MethodValidationPostProcessor in production.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldHavePageNoMinValidation() throws Exception {
|
||||||
|
var method = PracticeSessionController.class.getMethod("getReportHistory", int.class, int.class);
|
||||||
|
var params = method.getParameters();
|
||||||
|
var min = params[0].getAnnotation(jakarta.validation.constraints.Min.class);
|
||||||
|
assertNotNull(min, "pageNo must have @Min annotation");
|
||||||
|
assertEquals(1L, min.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHavePageSizeMinAndMaxValidation() throws Exception {
|
||||||
|
var method = PracticeSessionController.class.getMethod("getReportHistory", int.class, int.class);
|
||||||
|
var params = method.getParameters();
|
||||||
|
var min = params[1].getAnnotation(jakarta.validation.constraints.Min.class);
|
||||||
|
assertNotNull(min, "pageSize must have @Min annotation");
|
||||||
|
assertEquals(1L, min.value());
|
||||||
|
var max = params[1].getAnnotation(jakarta.validation.constraints.Max.class);
|
||||||
|
assertNotNull(max, "pageSize must have @Max annotation");
|
||||||
|
assertEquals(100L, max.value());
|
||||||
|
}
|
||||||
private record EndpointTest(String method, String url, String body) {}
|
private record EndpointTest(String method, String url, String body) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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.common.pojo.PageResult;
|
||||||
|
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.*;
|
||||||
|
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 java.util.List;
|
||||||
|
|
||||||
|
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.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PracticeSessionController HTTP seam test — submit + report endpoints.
|
||||||
|
*
|
||||||
|
* @author 恭学教育
|
||||||
|
*/
|
||||||
|
class PracticeSessionControllerSubmitHttpTest {
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== POST /submit ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSubmitAndReturnReportWhenAuthenticated() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
|
||||||
|
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(5001L)
|
||||||
|
.sessionId(1001L)
|
||||||
|
.questionCount(10)
|
||||||
|
.answeredCount(8)
|
||||||
|
.unansweredCount(2)
|
||||||
|
.correctCount(6)
|
||||||
|
.incorrectCount(2)
|
||||||
|
.score(75)
|
||||||
|
.details(List.of(
|
||||||
|
PracticeReportDetailRespVO.builder()
|
||||||
|
.sequence(1)
|
||||||
|
.questionId("q-001")
|
||||||
|
.stem("1+1=?")
|
||||||
|
.type("choice")
|
||||||
|
.difficulty("easy")
|
||||||
|
.selectedAnswer("B")
|
||||||
|
.correctAnswer("B")
|
||||||
|
.isCorrect(true)
|
||||||
|
.explanation("1+1=2, B is correct")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
when(service.submitSession(any(), eq(100L), eq(1L))).thenReturn(mockResp);
|
||||||
|
|
||||||
|
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||||
|
req.setSessionId(1001L);
|
||||||
|
req.setIdempotencyKey("uuid-submit");
|
||||||
|
req.setExpectedSessionVersion(5);
|
||||||
|
|
||||||
|
MvcResult result = mockMvc.perform(post("/education/practice-session/submit")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.reportId").value(5001))
|
||||||
|
.andExpect(jsonPath("$.data.score").value(75))
|
||||||
|
.andExpect(jsonPath("$.data.details[0].correctAnswer").value("B"))
|
||||||
|
.andExpect(jsonPath("$.data.details[0].explanation").value("1+1=2, B is correct"))
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String json = result.getResponse().getContentAsString();
|
||||||
|
assertTrue(json.contains("correctAnswer"), "submit: must include correctAnswer");
|
||||||
|
assertTrue(json.contains("explanation"), "submit: must include explanation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401ForSubmitWhenNotAuthenticated() throws Exception {
|
||||||
|
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||||
|
req.setSessionId(1001L);
|
||||||
|
req.setIdempotencyKey("uuid-submit");
|
||||||
|
req.setExpectedSessionVersion(1);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/education/practice-session/submit")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectSubmitWhenSessionNotActive() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
when(service.submitSession(any(), eq(100L), eq(1L)))
|
||||||
|
.thenThrow(new ServiceException(SUBMIT_SESSION_NOT_ACTIVE.getCode(), SUBMIT_SESSION_NOT_ACTIVE.getMsg()));
|
||||||
|
|
||||||
|
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||||
|
req.setSessionId(1001L);
|
||||||
|
req.setIdempotencyKey("uuid-submit");
|
||||||
|
req.setExpectedSessionVersion(1);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/education/practice-session/submit")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||||
|
.andExpect(jsonPath("$.code").value(SUBMIT_SESSION_NOT_ACTIVE.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReplayOnDuplicateSubmit() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
|
||||||
|
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(5001L)
|
||||||
|
.sessionId(1001L)
|
||||||
|
.questionCount(5)
|
||||||
|
.score(80)
|
||||||
|
.build();
|
||||||
|
when(service.submitSession(any(), eq(100L), eq(1L))).thenReturn(mockResp);
|
||||||
|
|
||||||
|
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||||
|
req.setSessionId(1001L);
|
||||||
|
req.setIdempotencyKey("uuid-replay");
|
||||||
|
req.setExpectedSessionVersion(3);
|
||||||
|
|
||||||
|
// Two calls — service handles idempotency
|
||||||
|
mockMvc.perform(post("/education/practice-session/submit")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.reportId").value(5001));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/education/practice-session/submit")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.reportId").value(5001));
|
||||||
|
|
||||||
|
verify(service, times(2)).submitSession(any(), eq(100L), eq(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GET /report ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldGetReportWhenSubmitted() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
|
||||||
|
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(5001L)
|
||||||
|
.sessionId(1001L)
|
||||||
|
.questionCount(10)
|
||||||
|
.score(90)
|
||||||
|
.details(List.of(
|
||||||
|
PracticeReportDetailRespVO.builder()
|
||||||
|
.sequence(1)
|
||||||
|
.questionId("q-001")
|
||||||
|
.stem("stem")
|
||||||
|
.type("choice")
|
||||||
|
.selectedAnswer("A")
|
||||||
|
.correctAnswer("A")
|
||||||
|
.isCorrect(true)
|
||||||
|
.explanation("Correct!")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
when(service.getReport(1001L, 100L, 1L)).thenReturn(mockResp);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.reportId").value(5001))
|
||||||
|
.andExpect(jsonPath("$.data.score").value(90))
|
||||||
|
.andExpect(jsonPath("$.data.details[0].explanation").value("Correct!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401ForReportWhenNotAuthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectReportForUnsubmittedSession() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
when(service.getReport(1001L, 100L, 1L))
|
||||||
|
.thenThrow(new ServiceException(REPORT_SESSION_NOT_SUBMITTED.getCode(), REPORT_SESSION_NOT_SUBMITTED.getMsg()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||||
|
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||||
|
.andExpect(jsonPath("$.code").value(REPORT_SESSION_NOT_SUBMITTED.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectReportForDifferentUser() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
when(service.getReport(1001L, 100L, 1L))
|
||||||
|
.thenThrow(new ServiceException(REPORT_NOT_OWN.getCode(), REPORT_NOT_OWN.getMsg()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||||
|
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||||
|
.andExpect(jsonPath("$.code").value(REPORT_NOT_OWN.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GET /reports (history) ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnPaginatedHistory() throws Exception {
|
||||||
|
setLoginUser(100L, 1L);
|
||||||
|
|
||||||
|
PracticeSubmitRespVO report1 = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(5001L).sessionId(1001L).score(80).build();
|
||||||
|
PracticeSubmitRespVO report2 = PracticeSubmitRespVO.builder()
|
||||||
|
.reportId(5002L).sessionId(1002L).score(90).build();
|
||||||
|
|
||||||
|
PageResult<PracticeSubmitRespVO> mockPage = new PageResult<>(
|
||||||
|
List.of(report1, report2), 5L);
|
||||||
|
when(service.getReportHistory(100L, 1L, 1, 2)).thenReturn(mockPage);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/education/practice-session/reports")
|
||||||
|
.param("pageNo", "1")
|
||||||
|
.param("pageSize", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.total").value(5))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].reportId").value(5001))
|
||||||
|
.andExpect(jsonPath("$.data.list[1].score").value(90));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401ForHistoryWhenNotAuthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/education/practice-session/reports"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
private void setLoginUser(Long userId, Long tenantId) {
|
||||||
|
LoginUser loginUser = new LoginUser();
|
||||||
|
loginUser.setId(userId);
|
||||||
|
loginUser.setTenantId(tenantId);
|
||||||
|
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
|||||||
|
DELETE FROM education_practice_report_detail;
|
||||||
|
DELETE FROM education_practice_report;
|
||||||
|
DELETE FROM education_submit_idempotency;
|
||||||
DELETE FROM education_practice_question;
|
DELETE FROM education_practice_question;
|
||||||
DELETE FROM education_practice_session;
|
DELETE FROM education_practice_session;
|
||||||
DELETE FROM education_answer_idempotency;
|
DELETE FROM education_answer_idempotency;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS "education_practice_question" (
|
|||||||
"selected_answer" CLOB DEFAULT NULL,
|
"selected_answer" CLOB DEFAULT NULL,
|
||||||
"is_answered" BIT NOT NULL DEFAULT FALSE,
|
"is_answered" BIT NOT NULL DEFAULT FALSE,
|
||||||
"client_sequence" INT DEFAULT NULL,
|
"client_sequence" INT DEFAULT NULL,
|
||||||
|
"correct_answer" CLOB DEFAULT NULL,
|
||||||
|
"explanation" CLOB DEFAULT NULL,
|
||||||
"creator" VARCHAR(64) DEFAULT '',
|
"creator" VARCHAR(64) DEFAULT '',
|
||||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updater" VARCHAR(64) DEFAULT '',
|
"updater" VARCHAR(64) DEFAULT '',
|
||||||
@@ -69,3 +71,75 @@ CREATE TABLE IF NOT EXISTS "education_answer_idempotency" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "idx_tenant_session" ON "education_answer_idempotency" ("tenant_id", "session_id");
|
CREATE INDEX IF NOT EXISTS "idx_tenant_session" ON "education_answer_idempotency" ("tenant_id", "session_id");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "education_submit_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_SESSION',
|
||||||
|
"idempotency_key" VARCHAR(64) NOT NULL,
|
||||||
|
"request_hash" VARCHAR(64) NOT NULL,
|
||||||
|
"session_id" BIGINT NOT NULL,
|
||||||
|
"report_id" BIGINT 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_submit_idempotency" UNIQUE ("tenant_id", "user_id", "operation", "idempotency_key")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_submit_session" ON "education_submit_idempotency" ("tenant_id", "session_id");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "education_practice_report" (
|
||||||
|
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
"tenant_id" BIGINT NOT NULL,
|
||||||
|
"user_id" BIGINT NOT NULL,
|
||||||
|
"session_id" BIGINT NOT NULL,
|
||||||
|
"question_count" INT NOT NULL,
|
||||||
|
"answered_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"unanswered_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"correct_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"incorrect_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"score" INT NOT NULL DEFAULT 0,
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
|
||||||
|
"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_report_session" UNIQUE ("session_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_report_tenant_user" ON "education_practice_report" ("tenant_id", "user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_report_create_time" ON "education_practice_report" ("create_time");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
|
||||||
|
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
"tenant_id" BIGINT NOT NULL,
|
||||||
|
"user_id" BIGINT NOT NULL,
|
||||||
|
"report_id" BIGINT NOT NULL,
|
||||||
|
"session_id" BIGINT NOT NULL,
|
||||||
|
"question_id" VARCHAR(64) NOT NULL,
|
||||||
|
"sequence" INT NOT NULL,
|
||||||
|
"stem" CLOB NOT NULL,
|
||||||
|
"type" VARCHAR(32) NOT NULL,
|
||||||
|
"difficulty" VARCHAR(32) DEFAULT NULL,
|
||||||
|
"selected_answer" CLOB DEFAULT NULL,
|
||||||
|
"correct_answer" CLOB DEFAULT NULL,
|
||||||
|
"is_correct" BIT NOT NULL DEFAULT FALSE,
|
||||||
|
"explanation" CLOB DEFAULT 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_report_sequence" UNIQUE ("report_id", "sequence")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_detail_session" ON "education_practice_report_detail" ("session_id");
|
||||||
|
|||||||
Reference in New Issue
Block a user