forked from wangziqi/ruoyi-vue-pro
feat(education): complete student core loop delivery
This commit is contained in:
@@ -24,6 +24,26 @@
|
||||
|
||||
在 `application.yaml` 或对应 profile 中配置:
|
||||
|
||||
```yaml
|
||||
yudao:
|
||||
education:
|
||||
enabled: true
|
||||
# 题库目录与题目读取开关;关闭不会删除已有练习、报告、错题或收藏
|
||||
catalog-read-enabled: true
|
||||
# 练习创建、答案保存、交卷写入开关;关闭后历史会话与报告仍可读取
|
||||
practice-write-enabled: true
|
||||
# Pilot 灰度租户;空列表表示不限制,生产 Pilot 应显式配置目标租户 ID
|
||||
pilot-tenant-ids: [1024]
|
||||
catalog-mode: SCALAR_READ
|
||||
```
|
||||
|
||||
灰度与回滚约束:
|
||||
|
||||
- `enabled=false`:移除 Education HTTP 能力,不执行任何数据删除。
|
||||
- `catalog-read-enabled=false`:停止 Scalar 题库读取;已有会话、报告、错题和收藏仍保存在 MySQL。
|
||||
- `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。
|
||||
- `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。
|
||||
- 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -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 = 租户名。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "题库数据源未启用,请联系管理员");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,6 +47,9 @@ public class EducationPropertiesTest {
|
||||
EducationProperties defaults = new EducationProperties();
|
||||
assertFalse(defaults.isEnabled(), "默认应禁用");
|
||||
assertEquals("1.0.0", defaults.getVersion(), "默认版本应为 1.0.0");
|
||||
assertTrue(defaults.isCatalogReadEnabled(), "模块启用后题库读取默认开放");
|
||||
assertTrue(defaults.isPracticeWriteEnabled(), "模块启用后练习写入默认开放");
|
||||
assertTrue(defaults.getPilotTenantIds().isEmpty(), "Pilot 租户为空时不限制租户");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
|
||||
@@ -49,6 +50,9 @@ class CatalogControllerHttpTest {
|
||||
var field = CatalogController.class.getDeclaredField("catalogService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, catalogService);
|
||||
var accessField = CatalogController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.education.controller.app.catalog;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
|
||||
@@ -31,7 +33,9 @@ import static org.mockito.Mockito.when;
|
||||
@SpringBootTest(
|
||||
classes = {
|
||||
CatalogController.class,
|
||||
CatalogServiceImpl.class
|
||||
CatalogServiceImpl.class,
|
||||
EducationAccessService.class,
|
||||
EducationProperties.class
|
||||
},
|
||||
properties = {
|
||||
"yudao.education.enabled=true",
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -50,6 +51,9 @@ class PracticeAnswerControllerHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -55,6 +56,9 @@ class PracticeSessionControllerHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -53,6 +54,9 @@ class PracticeSessionControllerSubmitHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
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.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl;
|
||||
@@ -55,6 +56,9 @@ class QuestionControllerHttpTest {
|
||||
var field = QuestionController.class.getDeclaredField("questionCatalogService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = QuestionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package cn.iocoder.yudao.module.education.service.access;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class EducationAccessServiceTest {
|
||||
|
||||
private EducationProperties properties;
|
||||
private EducationAccessService accessService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
properties = new EducationProperties();
|
||||
accessService = new EducationAccessService();
|
||||
Field field = EducationAccessService.class.getDeclaredField("properties");
|
||||
field.setAccessible(true);
|
||||
field.set(accessService, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowAllTenantsWhenPilotListEmpty() {
|
||||
assertDoesNotThrow(() -> accessService.assertCatalogReadAllowed(100L));
|
||||
assertDoesNotThrow(() -> accessService.assertPracticeWriteAllowed(100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTenantOutsidePilot() {
|
||||
properties.setPilotTenantIds(List.of(100L));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertCatalogReadAllowed(200L));
|
||||
|
||||
assertEquals(EDUCATION_TENANT_NOT_IN_PILOT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisableCatalogWithoutDisablingHistoryReads() {
|
||||
properties.setCatalogReadEnabled(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertCatalogReadAllowed(100L));
|
||||
|
||||
assertEquals(EDUCATION_CATALOG_READ_DISABLED.getCode(), ex.getCode());
|
||||
assertDoesNotThrow(() -> accessService.assertPilotTenant(100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisablePracticeWritesIndependently() {
|
||||
properties.setPracticeWriteEnabled(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertPracticeWriteAllowed(100L));
|
||||
|
||||
assertEquals(EDUCATION_PRACTICE_WRITE_DISABLED.getCode(), ex.getCode());
|
||||
assertDoesNotThrow(() -> accessService.assertCatalogReadAllowed(100L));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.education.service.practice;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||
@@ -14,6 +16,8 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.REPORT_NOT_OWN;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.SESSION_NOT_FOUND;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
@@ -58,9 +62,14 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
return createSessionWithQuestions(clientSessionId, questionCount, correctAnswer, tenantId, userId);
|
||||
}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer,
|
||||
Long fixtureTenantId, Long fixtureUserId) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setTenantId(fixtureTenantId);
|
||||
session.setUserId(fixtureUserId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
@@ -71,10 +80,12 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setTenantId(fixtureTenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
String questionPrefix = fixtureTenantId.equals(tenantId) && fixtureUserId.equals(userId)
|
||||
? "q-" : "q-" + fixtureTenantId + "-" + fixtureUserId + "-";
|
||||
q.setQuestionId(questionPrefix + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
@@ -233,6 +244,65 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
assertEquals(1, wqs.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIsolateSessionsReportsAndWrongQuestionsAcrossTwoTenantsAndStudents() {
|
||||
Long[][] owners = {{11L, 101L}, {11L, 102L}, {22L, 201L}, {22L, 202L}};
|
||||
java.util.Map<String, SessionFixture> fixtures = new java.util.LinkedHashMap<>();
|
||||
java.util.Map<String, PracticeSubmitRespVO> reports = new java.util.LinkedHashMap<>();
|
||||
|
||||
for (Long[] owner : owners) {
|
||||
Long fixtureTenantId = owner[0];
|
||||
Long fixtureUserId = owner[1];
|
||||
String key = fixtureTenantId + ":" + fixtureUserId;
|
||||
SessionFixture fixture = createSessionWithQuestions(
|
||||
"isolation-" + key, 1, "B", fixtureTenantId, fixtureUserId);
|
||||
answerQuestion(fixture.questions().get(0).getId(), "A");
|
||||
PracticeSubmitRespVO report = service.submitSession(
|
||||
createSubmitReq(fixture.sessionId(), "submit-" + key, 1),
|
||||
fixtureUserId, fixtureTenantId);
|
||||
fixtures.put(key, fixture);
|
||||
reports.put(key, report);
|
||||
}
|
||||
|
||||
assertEquals(4, reports.values().stream().map(PracticeSubmitRespVO::getReportId).distinct().count());
|
||||
assertEquals(4, wrongQuestionMapper.selectList().size());
|
||||
|
||||
for (Long[] owner : owners) {
|
||||
Long fixtureTenantId = owner[0];
|
||||
Long fixtureUserId = owner[1];
|
||||
String key = fixtureTenantId + ":" + fixtureUserId;
|
||||
SessionFixture fixture = fixtures.get(key);
|
||||
PracticeSubmitRespVO report = reports.get(key);
|
||||
|
||||
assertEquals(report.getReportId(),
|
||||
service.getReport(fixture.sessionId(), fixtureUserId, fixtureTenantId).getReportId());
|
||||
PageResult<PracticeSubmitRespVO> history =
|
||||
service.getReportHistory(fixtureUserId, fixtureTenantId, 1, 10);
|
||||
assertEquals(1L, history.getTotal());
|
||||
assertEquals(fixture.sessionId(), history.getList().get(0).getSessionId());
|
||||
|
||||
List<WrongQuestionDO> ownWrongQuestions = wrongQuestionMapper.selectList().stream()
|
||||
.filter(wq -> fixtureTenantId.equals(wq.getTenantId()) && fixtureUserId.equals(wq.getUserId()))
|
||||
.toList();
|
||||
assertEquals(1, ownWrongQuestions.size());
|
||||
WrongQuestionDO wrongQuestion = ownWrongQuestions.get(0);
|
||||
assertEquals(fixture.sessionId(), wrongQuestion.getLastSessionId());
|
||||
assertEquals(report.getReportId(), wrongQuestion.getLastReportId());
|
||||
|
||||
Long otherUserSameTenant = java.util.Arrays.stream(owners)
|
||||
.filter(candidate -> fixtureTenantId.equals(candidate[0]) && !fixtureUserId.equals(candidate[1]))
|
||||
.findFirst().orElseThrow()[1];
|
||||
ServiceException crossStudent = assertThrows(ServiceException.class,
|
||||
() -> service.getReport(fixture.sessionId(), otherUserSameTenant, fixtureTenantId));
|
||||
assertEquals(REPORT_NOT_OWN.getCode(), crossStudent.getCode());
|
||||
|
||||
Long otherTenant = fixtureTenantId.equals(11L) ? 22L : 11L;
|
||||
ServiceException crossTenant = assertThrows(ServiceException.class,
|
||||
() -> service.getReport(fixture.sessionId(), fixtureUserId, otherTenant));
|
||||
assertEquals(SESSION_NOT_FOUND.getCode(), crossTenant.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== wrong_question_id populated in idempotency guard ==========
|
||||
|
||||
@Test
|
||||
|
||||
@@ -300,7 +300,26 @@ class QuestionCatalogServiceImplTest {
|
||||
assertEquals(3L, result.getTotal());
|
||||
}
|
||||
|
||||
// ========== Practice config validation ==========
|
||||
@Test
|
||||
void shouldCapPracticeBoundsByEligibleCount() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(5)
|
||||
.totalCount(5)
|
||||
.minQuestions(10)
|
||||
.maxQuestions(100)
|
||||
.suggestedCount(10)
|
||||
.build();
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())).thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(1);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.previewPracticeConfig(req));
|
||||
assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldReturnNormalizedPracticePreview() {
|
||||
@@ -446,10 +465,8 @@ class QuestionCatalogServiceImplTest {
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #8: null list/page result handling ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResult() {
|
||||
void shouldRejectNullPageResult() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(null);
|
||||
|
||||
@@ -457,14 +474,12 @@ class QuestionCatalogServiceImplTest {
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
assertEquals(0L, result.getTotal());
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.pageQuestions(req));
|
||||
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResultItems() {
|
||||
void shouldRejectNullPageResultItems() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(null)
|
||||
@@ -475,9 +490,8 @@ class QuestionCatalogServiceImplTest {
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.pageQuestions(req));
|
||||
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Two tenant contexts ==========
|
||||
|
||||
Reference in New Issue
Block a user