feat(education): complete student core loop delivery

This commit is contained in:
2026-07-28 14:58:14 +08:00
parent 93f02df68c
commit ce02f8acb4
50 changed files with 1536 additions and 64 deletions

View File

@@ -35,6 +35,21 @@ public class EducationProperties {
*/
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
/**
* 是否允许读取题库目录和题目。关闭后不影响已持久化的练习、报告、错题和收藏数据。
*/
private boolean catalogReadEnabled = true;
/**
* 是否允许创建练习、保存答案和交卷。关闭后仍允许读取已有会话和历史报告。
*/
private boolean practiceWriteEnabled = true;
/**
* Education Pilot 租户 ID 列表。为空表示不限制租户;配置后仅列表内租户可使用学生端能力。
*/
private List<Long> pilotTenantIds = List.of();
/**
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
* key = 标准化后的主机名小写、无端口value = 租户名。

View File

@@ -37,6 +37,9 @@ public class EducationCapabilityController {
.version(educationProperties.getVersion())
.capabilities(List.of("shell", "catalog", "questions", "practice-preview",
"answer-save", "session-submit", "practice-report"))
.catalogReadEnabled(educationProperties.isCatalogReadEnabled())
.practiceWriteEnabled(educationProperties.isPracticeWriteEnabled())
.pilotTenantCount(educationProperties.getPilotTenantIds().size())
.build();
return success(resp);
}

View File

@@ -27,4 +27,13 @@ public class EducationCapabilityRespVO {
@Schema(description = "支持的能力列表")
private List<String> capabilities;
@Schema(description = "题库读取是否开放", example = "true")
private boolean catalogReadEnabled;
@Schema(description = "练习写入是否开放", example = "true")
private boolean practiceWriteEnabled;
@Schema(description = "Pilot 租户数量0 表示不限制租户", example = "1")
private int pilotTenantCount;
}

View File

@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -35,6 +37,9 @@ public class CatalogController {
@Resource
private CatalogService catalogService;
@Resource
private EducationAccessService educationAccessService;
@GetMapping("/regions")
@Operation(summary = "查询可用地区列表")
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
@@ -108,12 +113,14 @@ public class CatalogController {
}
/**
* 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生
* 校验当前学生的租户上下文和题库读取灰度开关
*/
private void assertAuthenticated() {
if (SecurityFrameworkUtils.getLoginUserId() == null) {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null || loginUser.getTenantId() == null) {
throw exception(UNAUTHORIZED);
}
educationAccessService.assertCatalogReadAllowed(loginUser.getTenantId());
}
}

View File

@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSess
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.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -44,6 +45,9 @@ public class PracticeSessionController {
@Resource
private PracticeSessionService practiceSessionService;
@Resource
private EducationAccessService educationAccessService;
// ========== 会话管理 ==========
@PostMapping("/practice-session/create")
@@ -51,7 +55,9 @@ public class PracticeSessionController {
description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
+ "题目顺序由服务端固定,选项不含答案标记。")
public CommonResult<PracticeSessionRespVO> createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
return success(practiceSessionService.createPracticeSession(reqVO, getUserId(), getTenantId()));
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.createPracticeSession(reqVO, getUserId(), tenantId));
}
@GetMapping("/practice-session/current")
@@ -77,7 +83,9 @@ public class PracticeSessionController {
+ "旧 clientSequence 或旧 expectedSessionVersion 拒绝覆盖。"
+ "客户端根据响应中的 serverVersion 和 acceptedSequence 更新本地状态。")
public CommonResult<PracticeAnswerRespVO> submitAnswer(@Valid @RequestBody PracticeAnswerReqVO reqVO) {
return success(practiceSessionService.submitAnswer(reqVO, getUserId(), getTenantId()));
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.submitAnswer(reqVO, getUserId(), tenantId));
}
// ========== 交卷提交 ==========
@@ -88,7 +96,9 @@ public class PracticeSessionController {
+ "同一 idempotencyKey + 相同载荷返回首次评分报告(超时重试安全)。"
+ "交卷后不可再修改答案。")
public CommonResult<PracticeSubmitRespVO> submitSession(@Valid @RequestBody PracticeSubmitReqVO reqVO) {
return success(practiceSessionService.submitSession(reqVO, getUserId(), getTenantId()));
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.submitSession(reqVO, getUserId(), tenantId));
}
// ========== 报告查看 ==========
@@ -125,7 +135,11 @@ public class PracticeSessionController {
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.getTenantId();
Long tenantId = loginUser.getTenantId();
if (tenantId == null) {
throw exception(UNAUTHORIZED);
}
return tenantId;
}
}

View File

@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.controller.app.question;
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.question.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -38,6 +40,9 @@ public class QuestionController {
@Resource
private QuestionCatalogService questionCatalogService;
@Resource
private EducationAccessService educationAccessService;
// ========== 题目浏览 ==========
@GetMapping("/questions/page")
@@ -98,12 +103,14 @@ public class QuestionController {
}
/**
* 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生
* 校验当前学生的租户上下文和题库读取灰度开关
*/
private void assertAuthenticated() {
if (SecurityFrameworkUtils.getLoginUserId() == null) {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null || loginUser.getTenantId() == null) {
throw exception(UNAUTHORIZED);
}
educationAccessService.assertCatalogReadAllowed(loginUser.getTenantId());
}
}

View File

@@ -107,6 +107,10 @@ public class WrongQuestionController {
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.getTenantId();
Long tenantId = loginUser.getTenantId();
if (tenantId == null) {
throw exception(UNAUTHORIZED);
}
return tenantId;
}
}

View File

@@ -139,11 +139,20 @@ public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO
}
/**
* 按 ID 更新 available 标记
*
* @param id 记录 ID
* @param available 源资源是否可用
* @return 受影响行数
* 按 ID、租户和用户更新可用标记,避免调用方遗漏所有权边界
*/
default int updateAvailableByIdAndTenantAndUser(Long id, Long tenantId, Long userId, Boolean available) {
return update(null,
new LambdaUpdateWrapper<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getId, id)
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getDeleted, false)
.set(EducationFavoriteDO::getAvailable, available));
}
/**
* 兼容旧调用方;业务服务应使用带租户和用户的重载。
*/
default int updateAvailable(Long id, Boolean available) {
return update(null,
@@ -151,6 +160,4 @@ public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO
.eq(EducationFavoriteDO::getId, id)
.set(EducationFavoriteDO::getAvailable, available));
}
}

View File

@@ -16,8 +16,26 @@ import java.util.List;
@Mapper
public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO> {
default List<PracticeQuestionDO> selectBySessionIdAndTenantIdOrderBySequence(Long sessionId, Long tenantId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.orderByAsc(PracticeQuestionDO::getSequence));
}
/**
* 按会话 ID 和序号查询题目快照列表,按 sequence 升序
* 按 tenant、会话 ID 和序号查询题目快照。
*/
default PracticeQuestionDO selectBySessionIdAndTenantIdAndSequence(Long sessionId, Long tenantId,
Integer sequence) {
return selectOne(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.eq(PracticeQuestionDO::getSequence, sequence));
}
/**
* 兼容内部测试和迁移查询;业务服务优先使用带 tenantId 的方法。
*/
default List<PracticeQuestionDO> selectBySessionIdOrderBySequence(Long sessionId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
@@ -26,7 +44,7 @@ public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO>
}
/**
* 按会话 ID 和题目序号查找单题快照
* 兼容内部测试和迁移查询;业务服务优先使用带 tenantId 的方法
*/
default PracticeQuestionDO selectBySessionIdAndSequence(Long sessionId, Integer sequence) {
return selectOne(new LambdaQueryWrapperX<PracticeQuestionDO>()
@@ -35,8 +53,25 @@ public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO>
}
/**
* CAS 条件更新题目答案。仅当 tenant 匹配且当前 clientSequence 小于传入值(或为 NULL
* 时才执行更新。返回受影响行数1 = 成功0 = 序列过期或租户不匹配)
* CAS 条件更新题目答案。仅当指定租户、会话和题目匹配且当前 clientSequence
* 小于传入值(或为 NULL时才执行更新。
*/
default int updateAnswerIfNewer(Long questionId, Long sessionId, Long tenantId, String selectedAnswer,
Boolean isAnswered, Integer clientSequence) {
return update(null,
new LambdaUpdateWrapper<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getId, questionId)
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.and(w -> w.isNull(PracticeQuestionDO::getClientSequence)
.or().lt(PracticeQuestionDO::getClientSequence, clientSequence))
.set(PracticeQuestionDO::getSelectedAnswer, selectedAnswer)
.set(PracticeQuestionDO::getIsAnswered, isAnswered)
.set(PracticeQuestionDO::getClientSequence, clientSequence));
}
/**
* 兼容旧调用方;业务服务应使用包含 sessionId 的重载。
*/
default int updateAnswerIfNewer(Long questionId, Long tenantId, String selectedAnswer,
Boolean isAnswered, Integer clientSequence) {

View File

@@ -17,6 +17,9 @@ public interface ErrorCodeConstants {
ErrorCode EDUCATION_TENANT_DISABLED = new ErrorCode(1_005_001_002, "租户已被禁用");
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
ErrorCode EDUCATION_TENANT_NOT_IN_PILOT = new ErrorCode(1_005_001_005, "当前租户尚未开放教育 Pilot 能力");
ErrorCode EDUCATION_CATALOG_READ_DISABLED = new ErrorCode(1_005_001_006, "题库读取能力已关闭,请稍后重试");
ErrorCode EDUCATION_PRACTICE_WRITE_DISABLED = new ErrorCode(1_005_001_007, "练习写入能力已关闭,历史数据仍可查看");
// ========== Catalog 目录 1-005-002-000 ~ 1-005-002-009 ==========
ErrorCode CATALOG_DATA_SOURCE_DISABLED = new ErrorCode(1_005_002_000, "题库数据源未启用,请联系管理员");

View File

@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.education.service.access;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_CATALOG_READ_DISABLED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_PRACTICE_WRITE_DISABLED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_NOT_IN_PILOT;
/**
* Education 灰度访问控制。
*
* 仅控制新请求是否进入对应能力,不修改或删除任何学生历史数据。
*/
@Service
public class EducationAccessService {
@Resource
private EducationProperties properties;
public void assertCatalogReadAllowed(Long tenantId) {
assertPilotTenant(tenantId);
if (!properties.isCatalogReadEnabled()) {
throw exception(EDUCATION_CATALOG_READ_DISABLED);
}
}
public void assertPracticeWriteAllowed(Long tenantId) {
assertPilotTenant(tenantId);
if (!properties.isPracticeWriteEnabled()) {
throw exception(EDUCATION_PRACTICE_WRITE_DISABLED);
}
}
public void assertPilotTenant(Long tenantId) {
if (!properties.getPilotTenantIds().isEmpty() && !properties.getPilotTenantIds().contains(tenantId)) {
throw exception(EDUCATION_TENANT_NOT_IN_PILOT);
}
}
}

View File

@@ -161,7 +161,7 @@ public class FavoriteServiceImpl implements FavoriteService {
tenantId, userId, reqVO.getTargetType());
// 2. Refresh availability for the returned favorites
refreshAvailability(page.getRecords());
refreshAvailability(page.getRecords(), userId, tenantId);
List<FavoritePageItemRespVO> list = page.getRecords().stream()
.map(this::toPageItem)
@@ -257,7 +257,7 @@ public class FavoriteServiceImpl implements FavoriteService {
* 如果题目恢复可见,将 available 标记为 true 并持久化。
* 其他上游异常(网络错误、超时等)直接传播,不静默标记为不可用。</p>
*/
private void refreshAvailability(List<EducationFavoriteDO> favorites) {
private void refreshAvailability(List<EducationFavoriteDO> favorites, Long userId, Long tenantId) {
if (favorites == null || favorites.isEmpty()) {
return;
}
@@ -286,7 +286,8 @@ public class FavoriteServiceImpl implements FavoriteService {
.filter(f -> targetId.equals(f.getTargetId())
&& !Objects.equals(finalAvailable, f.getAvailable()))
.forEach(f -> {
favoriteMapper.updateAvailable(f.getId(), finalAvailable);
favoriteMapper.updateAvailableByIdAndTenantAndUser(
f.getId(), tenantId, userId, finalAvailable);
f.setAvailable(finalAvailable);
});
}

View File

@@ -72,7 +72,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!isSameFingerprint(existing, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
return buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
}
// 2. Fetch eligible questions — provider returns visible-only per contract
@@ -113,7 +113,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!isSameFingerprint(winner, reqVO)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
return buildSessionResp(winner, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
}
// 4. Create question snapshots with protected answer key
@@ -147,7 +147,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (session == null) {
return null;
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
return buildSessionResp(session, questions);
}
@@ -160,7 +160,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!Objects.equals(session.getUserId(), userId)) {
throw exception(SESSION_NOT_OWN);
}
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
return buildSessionResp(session, questions);
}
@@ -228,8 +228,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
}
// 3. Load question and validate
PracticeQuestionDO question = questionMapper.selectBySessionIdAndSequence(
reqVO.getSessionId(), reqVO.getQuestionSequence());
PracticeQuestionDO question = questionMapper.selectBySessionIdAndTenantIdAndSequence(
reqVO.getSessionId(), tenantId, reqVO.getQuestionSequence());
if (question == null) {
throw exception(ANSWER_QUESTION_NOT_IN_SESSION);
}
@@ -269,7 +269,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
}
// 6. Update question answer with clientSequence guard
int updated = questionMapper.updateAnswerIfNewer(question.getId(), tenantId,
int updated = questionMapper.updateAnswerIfNewer(question.getId(), reqVO.getSessionId(), tenantId,
reqVO.getSelectedAnswer(),
reqVO.getSelectedAnswer() != null && !reqVO.getSelectedAnswer().isEmpty(),
reqVO.getClientSequence());
@@ -337,7 +337,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
}
// 4. Load question snapshots and score
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdOrderBySequence(session.getId());
List<PracticeQuestionDO> questions = questionMapper.selectBySessionIdAndTenantIdOrderBySequence(session.getId(), tenantId);
int score = computeScore(questions);
int answeredCount = (int) questions.stream().filter(q -> q.getIsAnswered() != null && q.getIsAnswered()).count();
int unansweredCount = questions.size() - answeredCount;
@@ -527,12 +527,11 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, 0, count);
}
allQuestions.sort(Comparator.comparing(CatalogQuestionDTO::getId, Comparator.nullsLast(String::compareTo))
.thenComparing(CatalogQuestionDTO::getContentVersion, Comparator.nullsLast(String::compareTo)));
if (allQuestions.size() > count) {
allQuestions = allQuestions.subList(0, count);
}
allQuestions.sort(Comparator.comparing(CatalogQuestionDTO::getId, Comparator.nullsLast(String::compareTo))
.thenComparing(CatalogQuestionDTO::getContentVersion, Comparator.nullsLast(String::compareTo)));
return allQuestions;
}

View File

@@ -47,7 +47,7 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
pageNo, pageSize);
if (pageResult == null || pageResult.getItems() == null) {
return new PageResult<>(Collections.emptyList(), 0L);
throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
}
// Fail-closed: provider contract guarantees visible-only items.
@@ -89,7 +89,7 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
collectionId, type, difficulty, pageNo, pageSize);
if (pageResult == null || pageResult.getItems() == null) {
return new PageResult<>(Collections.emptyList(), 0L);
throw exception(CATALOG_UPSTREAM_UNAVAILABLE);
}
// Fail-closed: provider contract guarantees visible-only items.
@@ -123,25 +123,30 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
int minQ = blueprint.getMinQuestions() != null ? blueprint.getMinQuestions() : 1;
// If requested exceeds eligible, reject with INSUFFICIENT
if (eligible == 0 || requestedCount > eligible) {
if (eligible == 0 || requestedCount > eligible || eligible < minQ) {
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, eligible, requestedCount);
}
// Normalize within min/max bounds
int normalized = Math.max(minQ, Math.min(requestedCount, maxQ));
int effectiveMax = Math.min(maxQ, eligible);
int effectiveMin = minQ;
if (effectiveMax <= 0 || effectiveMin <= 0 || effectiveMin > effectiveMax) {
throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, eligible, requestedCount);
}
// countWithinRange is true only if requested fits within [minQ, maxQ] AND <= eligible
boolean withinRange = requestedCount >= minQ
&& requestedCount <= maxQ
&& requestedCount <= eligible;
// Normalize within the provider bounds and the actual eligible count.
int normalized = Math.max(effectiveMin, Math.min(requestedCount, effectiveMax));
// countWithinRange is true only if requested fits within the effective bounds.
boolean withinRange = requestedCount >= effectiveMin
&& requestedCount <= effectiveMax;
return PracticeConfigPreviewRespVO.builder()
.eligibleCount(eligible)
.totalCount(blueprint.getTotalCount())
.availableTypes(blueprint.getAvailableTypes())
.availableDifficulties(blueprint.getAvailableDifficulties())
.minQuestions(minQ)
.maxQuestions(maxQ)
.minQuestions(effectiveMin)
.maxQuestions(effectiveMax)
.suggestedCount(blueprint.getSuggestedCount())
.normalizedCount(normalized)
.countWithinRange(withinRange)

View File

@@ -148,7 +148,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
// Same tenant + clientSessionId + user + fingerprint → replay
return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
return buildSessionResp(existing, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(existing.getId(), tenantId));
}
// 3. Load all wrong questions by ID, verify ownership
@@ -184,7 +184,7 @@ public class WrongQuestionServiceImpl implements WrongQuestionService {
if (!Objects.equals(winner.getReviewFingerprint(), fingerprint)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
return buildSessionResp(winner, questionMapper.selectBySessionIdAndTenantIdOrderBySequence(winner.getId(), tenantId));
}
// 4. Create question snapshots from wrong question data (NO correct answer exposed pre-submit)