From 73b2a8edccece9247727a13a1a112dcab4412347 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 27 Jul 2026 20:13:09 +0800 Subject: [PATCH] feat(education): question browsing and practice configuration preview --- yudao-module-education/README.md | 115 +++- .../admin/EducationCapabilityController.java | 2 +- .../app/question/QuestionController.java | 109 ++++ .../vo/PracticeConfigPreviewReqVO.java | 36 ++ .../vo/PracticeConfigPreviewRespVO.java | 50 ++ .../app/question/vo/QuestionPageReqVO.java | 33 ++ .../app/question/vo/SafeQuestionRespVO.java | 61 ++ .../education/enums/ErrorCodeConstants.java | 7 + .../config/ScalarAutoConfiguration.java | 17 +- .../scalar/dto/ScalarListResponse.java | 11 +- .../ScalarPracticeBlueprintResponseDto.java | 45 ++ .../scalar/dto/ScalarQuestionOptionDto.java | 33 ++ .../scalar/dto/ScalarQuestionResponseDto.java | 76 +++ .../service/catalog/CatalogServiceImpl.java | 2 + .../catalog/ScalarCatalogProvider.java | 258 +++++++- .../question/QuestionCatalogProvider.java | 75 +++ .../question/QuestionCatalogService.java | 52 ++ .../question/QuestionCatalogServiceImpl.java | 201 +++++++ ...nsupportedModeQuestionCatalogProvider.java | 54 ++ .../dto/CatalogPracticeBlueprintDTO.java | 43 ++ .../question/dto/CatalogQuestionDTO.java | 92 +++ .../dto/CatalogQuestionPageResult.java | 40 ++ .../question/QuestionControllerHttpTest.java | 330 +++++++++++ .../config/ScalarAutoConfigurationTest.java | 110 +++- .../QuestionCatalogServiceImplTest.java | 555 ++++++++++++++++++ .../question/ScalarQuestionContractTest.java | 507 ++++++++++++++++ 26 files changed, 2898 insertions(+), 16 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionController.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/QuestionPageReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/SafeQuestionRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarPracticeBlueprintResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionOptionDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogProvider.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogService.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/UnsupportedModeQuestionCatalogProvider.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogPracticeBlueprintDTO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionDTO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionPageResult.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/ScalarQuestionContractTest.java diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 8941f204..a4381729 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -4,7 +4,7 @@ ## 当前状态 -此模块提供教育业务功能骨架和题库目录浏览 tracer bullet。 +此模块提供教育业务功能骨架、题库目录浏览 tracer bullet,以及题目预览与练习配置预览。 **已实现**: - 模块骨架与包结构 @@ -12,8 +12,11 @@ - 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用 - 教育上下文端点 (`/education/context`) — 学生端已认证状态 - 题库目录端点 (见下方 Catalog API) — 学生端已认证 +- 题目浏览与筛选端点 (见下方 Questions API) — 学生端已认证 +- 练习配置预览端点 (见下方 Practice API) — 学生端已认证 +- 题目安全过滤(答案/解析绝不暴露到前端) - 独立的功能开关配置 + Scalar 数据源配置 -- 错误码常量(通用 + 租户 + Catalog/Scalar) +- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习) - 权限与菜单种子数据 ## 功能配置 @@ -40,7 +43,7 @@ GET /admin-api/education/capability "module": "education", "enabled": true, "version": "1.0.0", - "capabilities": ["shell", "catalog"] + "capabilities": ["shell", "catalog", "questions", "practice-preview"] } } ``` @@ -166,6 +169,7 @@ mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-rollba - 每个正向脚本应有对应的回滚脚本 - schema 文件仅包含 DDL,seed 文件仅包含 DML - **不修改**项目根目录的 `ruoyi-vue-pro.sql` 巨量全量转储 +- Ticket #5 为只读/预览操作,无新增数据库 schema 或 DML ## 前端状态 @@ -278,6 +282,104 @@ Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvi **当前阻塞**:完整前端源码不存在,后端目录接口已就绪可通过 Swagger/curl 验证。 +### 用户 APP - 题目与练习预览(Questions & Practice) + +所有端点需要学生登录态(Bearer Token)。`userId` 和 `tenantId` 由安全上下文派生,不接受客户端传参。 +返回的题目数据经过白名单过滤,绝不包含 `correctAnswer`、`answer`、`explanation`、`analysis` 或选项的 `isCorrect` 字段。 + +#### 架构边界 + +``` +Browser -> Controller(/education/questions/*) -> QuestionCatalogService -> QuestionCatalogProvider -> [Scalar] + ↑ SafeQuestionRespVO ↑ CatalogQuestionDTO +``` +- **业务层**(Controller/Service):仅操作安全 VO(`SafeQuestionRespVO` 等),答案字段在 DTO→VO 转换时被剥离 +- **集成层**(Scalar DTO + ScalarCatalogProvider):封装 Scalar 协议差异,答案字段在此层被映射但绝不透传到上层 + +#### 端点列表 + +| 端点 | 说明 | 参数 | +|------|------|------| +| `GET /app-api/education/questions/page` | 分页查询安全题目 | `collectionId`, `nodeId`, `type`, `difficulty` (可选), `pageNo` (默认1), `pageSize` (默认20) | +| `GET /app-api/education/questions/get` | 获取单个安全题目 | `id` (必填) | +| `GET /app-api/education/questions/collection-questions` | 查询题集中的安全题目 | `collectionId` (必填), `type`, `difficulty` (可选), `pageNo`, `pageSize` | +| `GET /app-api/education/practice-config/preview` | 预览练习配置(不创建会话) | `collectionId` (必填), `nodeId`, `type`, `difficulty` (可选), `questionCount` (默认10, 1-1000) | + +#### 分页响应格式 + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "list": [ + { + "id": "q-001", + "contentVersion": "v2", + "stem": "1+1等于几?", + "type": "choice", + "difficulty": "easy", + "options": [ + {"label": "A", "content": "2", "order": 1.0} + ] + } + ], + "total": 50 + } +} +``` + +#### 练习预览响应格式 + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "eligibleCount": 50, + "totalCount": 100, + "availableTypes": ["choice", "fill"], + "availableDifficulties": ["easy", "medium"], + "minQuestions": 1, + "maxQuestions": 50, + "suggestedCount": 20, + "normalizedCount": 10, + "countWithinRange": true + } +} +``` + +#### 错误响应 + +| HTTP 状态 | 错误码 | 说明 | +|-----------|--------|------| +| 401 | 1_016_000_002 | 未登录或会话过期 | +| 404 | 1_005_003_001 | 题目不存在或不可见 | +| 400 | 1_005_003_002 | 无效的练习配置 | +| 400 | 1_005_003_003 | 符合条件的题目数量不足 | +| 500 | 1_005_003_004 | 题库数据源返回不安全内容 | + +#### 安全字段白名单 + +`SafeQuestionRespVO` 仅包含以下字段,前端可安全展示: +- `id`, `contentVersion`, `stem`, `type`, `difficulty` +- `options[]` 中仅包含 `label`, `content`, `order` + +以下字段**绝不**出现在响应中: +- `correctAnswer`, `answer`, `explanation`, `analysis` +- 选项的 `isCorrect` +- 任何管理元数据 + +#### 前端集成提示 + +前端就位后,学生端练习入口应: +1. 浏览题库目录(Catalog API)选择题集 +2. 调用 `/education/questions/page` 或 `/education/questions/collection-questions` 预览题目概要 +3. 调用 `/education/practice-config/preview` 获取可用题量范围和建议配置 +4. 展示预览结果后,用户在可用范围内选择题量开始练习(Ticket #6 创建持久会话) + +**当前阻塞**:完整前端源码不存在,后端接口已就绪可通过 Swagger/curl 验证。 + ### 更新后的错误码 | 错误码 | 说明 | @@ -297,3 +399,10 @@ Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvi | 1_005_002_007 | 上游超时 | | 1_005_002_008 | 上游返回异常:{状态码} | | 1_005_002_009 | 不支持的题库数据源模式 | +| 1_005_002_010 | Scalar 数据源未配置 | +| 1_005_002_011 | 上游题库返回数据格式异常 | +| 1_005_002_012 | 上游题库服务不可达 | +| 1_005_003_001 | 题目不存在或不可见 | +| 1_005_003_002 | 无效的练习配置 | +| 1_005_003_003 | 符合条件的题目数量不足 | +| 1_005_003_004 | 题库数据源返回不安全内容 | diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java index 37b064d1..8d724e86 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java @@ -35,7 +35,7 @@ public class EducationCapabilityController { .module("education") .enabled(educationProperties.isEnabled()) .version(educationProperties.getVersion()) - .capabilities(List.of("shell", "catalog")) + .capabilities(List.of("shell", "catalog", "questions", "practice-preview")) .build(); return success(resp); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionController.java new file mode 100644 index 00000000..f30eec0f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionController.java @@ -0,0 +1,109 @@ +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.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.controller.app.question.vo.*; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 题目 Controller — 学生端已认证接口。 + * + *

所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。

+ *

返回的题目数据经过白名单过滤,绝不包含答案、解析或正确性标记。

+ * + * @author 恭学教育 + */ +@Tag(name = "用户 APP - 题目与练习") +@RestController +@RequestMapping("/education") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class QuestionController { + + @Resource + private QuestionCatalogService questionCatalogService; + + // ========== 题目浏览 ========== + + @GetMapping("/questions/page") + @Operation(summary = "分页查询安全题目列表", + description = "支持按题集、节点、题型、难度筛选。仅返回已发布且非隐藏的题目,不含答案字段。") + public CommonResult> pageQuestions( + @Parameter(description = "题集 ID") @RequestParam(required = false) String collectionId, + @Parameter(description = "目录节点 ID") @RequestParam(required = false) String nodeId, + @Parameter(description = "题型") @RequestParam(required = false) String type, + @Parameter(description = "难度") @RequestParam(required = false) String difficulty, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNo, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "20") Integer pageSize) { + assertAuthenticated(); + + QuestionPageReqVO reqVO = new QuestionPageReqVO(); + reqVO.setCollectionId(collectionId); + reqVO.setNodeId(nodeId); + reqVO.setType(type); + reqVO.setDifficulty(difficulty); + reqVO.setPageNo(pageNo); + reqVO.setPageSize(pageSize); + + return success(questionCatalogService.pageQuestions(reqVO)); + } + + @GetMapping("/questions/get") + @Operation(summary = "获取单个安全题目", + description = "根据 ID 获取题目详情。仅返回学生可见字段,不含答案/解析。未发布或不可见时返回错误。") + public CommonResult getQuestion( + @Parameter(description = "题目 ID", required = true) @RequestParam String id) { + assertAuthenticated(); + return success(questionCatalogService.getQuestion(id)); + } + + @GetMapping("/questions/collection-questions") + @Operation(summary = "查询题集中的安全题目", + description = "获取指定题集中的题目列表,支持题型和难度筛选。") + public CommonResult> listCollectionQuestions( + @Parameter(description = "题集 ID", required = true) @RequestParam String collectionId, + @Parameter(description = "题型") @RequestParam(required = false) String type, + @Parameter(description = "难度") @RequestParam(required = false) String difficulty, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNo, + @Parameter(description = "每页条数") @RequestParam(defaultValue = "20") Integer pageSize) { + assertAuthenticated(); + return success(questionCatalogService.listCollectionQuestions( + collectionId, type, difficulty, pageNo, pageSize)); + } + + // ========== 练习配置预览 ========== + + @GetMapping("/practice-config/preview") + @Operation(summary = "预览练习配置", + description = "验证筛选条件和题量,返回标准化配置摘要。不创建持久化会话。") + public CommonResult previewPracticeConfig( + @Valid PracticeConfigPreviewReqVO reqVO) { + assertAuthenticated(); + return success(questionCatalogService.previewPracticeConfig(reqVO)); + } + + /** + * 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生。 + */ + private void assertAuthenticated() { + if (SecurityFrameworkUtils.getLoginUserId() == null) { + throw exception(UNAUTHORIZED); + } + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewReqVO.java new file mode 100644 index 00000000..230a1cdf --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.education.controller.app.question.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 练习配置预览请求 VO。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 练习配置预览请求") +@Data +public class PracticeConfigPreviewReqVO { + + @Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001") + @NotBlank(message = "题集 ID 不能为空") + private String collectionId; + + @Schema(description = "目录节点 ID", example = "node-001") + private String nodeId; + + @Schema(description = "题型", example = "choice") + private String type; + + @Schema(description = "难度", example = "easy") + private String difficulty; + + @Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @Min(value = 1, message = "题量至少为 1") + @Max(value = 1000, message = "题量最多为 1000") + private Integer questionCount = 10; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewRespVO.java new file mode 100644 index 00000000..ef492889 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/PracticeConfigPreviewRespVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.education.controller.app.question.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。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 练习配置预览响应") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PracticeConfigPreviewRespVO { + + @Schema(description = "符合条件的题目数", example = "50") + private Integer eligibleCount; + + @Schema(description = "总题目数", example = "100") + private Integer totalCount; + + @Schema(description = "可用题型列表", example = "[\"choice\", \"fill\"]") + private List availableTypes; + + @Schema(description = "可用难度列表", example = "[\"easy\", \"medium\"]") + private List availableDifficulties; + + @Schema(description = "最少题量", example = "1") + private Integer minQuestions; + + @Schema(description = "最多题量", example = "50") + private Integer maxQuestions; + + @Schema(description = "建议题量", example = "20") + private Integer suggestedCount; + + @Schema(description = "服务器标准化后的请求题量", example = "10") + private Integer normalizedCount; + + @Schema(description = "请求题量是否在可用范围内", example = "true") + private Boolean countWithinRange; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/QuestionPageReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/QuestionPageReqVO.java new file mode 100644 index 00000000..3d0f32d4 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/QuestionPageReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.education.controller.app.question.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 题目分页查询请求 VO。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 题目分页查询请求") +@Data +public class QuestionPageReqVO { + + @Schema(description = "题集 ID", example = "col-001") + private String collectionId; + + @Schema(description = "目录节点 ID", example = "node-001") + private String nodeId; + + @Schema(description = "题型", example = "choice") + private String type; + + @Schema(description = "难度", example = "easy") + private String difficulty; + + @Schema(description = "页码,从 1 开始", example = "1") + private Integer pageNo = 1; + + @Schema(description = "每页条数", example = "20") + private Integer pageSize = 20; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/SafeQuestionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/SafeQuestionRespVO.java new file mode 100644 index 00000000..dd1d369a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/question/vo/SafeQuestionRespVO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.education.controller.app.question.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 — 仅包含学生可见字段的 allow-list。 + * 绝不包含 correctAnswer、answer、explanation、analysis 或选项的 isCorrect。 + * + * @author 恭学教育 + */ +@Schema(description = "用户 APP - 安全题目响应") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SafeQuestionRespVO { + + @Schema(description = "题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001") + private String id; + + @Schema(description = "内容版本", example = "v2") + private String contentVersion; + + @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 = "选项列表(不含正确性标记)") + private List options; + + /** + * 安全选项 VO — 仅包含 label 和 content,不含 isCorrect。 + */ + @Schema(description = "安全选项") + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SafeOptionVO { + @Schema(description = "选项标签", example = "A") + private String label; + + @Schema(description = "选项内容", example = "2") + private String content; + + @Schema(description = "排序", example = "1.0") + private Double order; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 182937c9..bc4f7f18 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -33,4 +33,11 @@ public interface ErrorCodeConstants { ErrorCode CATALOG_UPSTREAM_MALFORMED = new ErrorCode(1_005_002_011, "上游题库返回数据格式异常,请稍后重试"); ErrorCode CATALOG_UPSTREAM_UNAVAILABLE = new ErrorCode(1_005_002_012, "上游题库服务不可达,请稍后重试"); + // ========== 题目与练习 1-005-003-000 ~ 1-005-003-009 ========== + ErrorCode QUESTION_NOT_FOUND = new ErrorCode(1_005_003_001, "题目不存在或不可见"); + ErrorCode INVALID_PRACTICE_CONFIG = new ErrorCode(1_005_003_002, "无效的练习配置:{}"); + ErrorCode INSUFFICIENT_ELIGIBLE_QUESTIONS = new ErrorCode(1_005_003_003, "符合条件的题目数量不足,当前可用 {} 题,请求 {} 题"); + ErrorCode UNSAFE_PROVIDER_PAYLOAD = new ErrorCode(1_005_003_004, "题库数据源返回不安全内容,请稍后重试"); + ErrorCode QUESTION_NOT_VISIBLE = new ErrorCode(1_005_003_005, "题库数据源返回了不应出现的不可见题目"); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java index 84149893..416208fb 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java @@ -3,8 +3,10 @@ package cn.iocoder.yudao.module.education.integration.scalar.config; import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider; import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider; import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.UnsupportedModeQuestionCatalogProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -12,8 +14,9 @@ import org.springframework.context.annotation.Configuration; /** * Scalar 数据源自动配置。 - * 根据 yudao.education.catalog-mode 选择 CatalogProvider 实现。 - * SCALAR_READ 模式创建 ScalarCatalogProvider;其他模式(含 JAVA_READ)创建 UnsupportedModeCatalogProvider。 + * 根据 yudao.education.catalog-mode 选择 CatalogProvider 和 QuestionCatalogProvider 实现。 + * SCALAR_READ 模式创建 ScalarCatalogProvider(同时实现两个接口); + * JAVA_READ 模式创建 UnsupportedModeCatalogProvider 和 UnsupportedModeQuestionCatalogProvider。 * * @author 恭学教育 */ @@ -24,7 +27,7 @@ public class ScalarAutoConfiguration { @Bean @ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "SCALAR_READ", matchIfMissing = true) - public CatalogProvider scalarCatalogProvider(ScalarProperties scalarProperties) { + public ScalarCatalogProvider scalarCatalogProvider(ScalarProperties scalarProperties) { return new ScalarCatalogProvider(scalarProperties); } @@ -36,4 +39,12 @@ public class ScalarAutoConfiguration { return new UnsupportedModeCatalogProvider(mode); } + @Bean + @ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ") + public QuestionCatalogProvider unsupportedModeQuestionCatalogProvider(EducationProperties educationProperties) { + CatalogProviderMode mode = educationProperties.getCatalogMode() != null + ? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ; + return new UnsupportedModeQuestionCatalogProvider(mode); + } + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java index 247d7687..0c80a023 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java @@ -9,7 +9,7 @@ import java.util.Collections; import java.util.List; /** - * Scalar 列表响应通用包装 — { items, meta }。 + * Scalar 列表响应通用包装 — { items, meta, total }。 * 此 DTO 保留在 integration 包内,不进入业务层或前端。 * * @param 列表元素类型 @@ -27,11 +27,10 @@ public class ScalarListResponse { private ScalarApiResponseMetaDto meta; /** - * 获取原始 items(可能为 null)。 - * 调用方负责区分 null(字段缺失)和 [](显式空列表)。 + * 符合条件的总数(上游分页响应中的 total 字段)。 + * 为 null 时表示上游未返回总数。 */ - public List getItems() { - return items; - } + @JsonProperty("total") + private Long total; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarPracticeBlueprintResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarPracticeBlueprintResponseDto.java new file mode 100644 index 00000000..43df766f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarPracticeBlueprintResponseDto.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.education.integration.scalar.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Scalar 练习蓝图响应 DTO — 用于 /api/catalog/practice-blueprints 端点。 + * 此 DTO 保留在 integration 包内,不进入业务层或前端。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalarPracticeBlueprintResponseDto { + + @JsonProperty("eligibleCount") + private Integer eligibleCount; + + @JsonProperty("totalCount") + private Integer totalCount; + + @JsonProperty("availableTypes") + private java.util.List availableTypes; + + @JsonProperty("availableDifficulties") + private java.util.List availableDifficulties; + + @JsonProperty("minQuestions") + private Integer minQuestions; + + @JsonProperty("maxQuestions") + private Integer maxQuestions; + + @JsonProperty("suggestedCount") + private Integer suggestedCount; + + @JsonProperty("metadata") + private Map metadata; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionOptionDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionOptionDto.java new file mode 100644 index 00000000..7b42d4d2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionOptionDto.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.education.integration.scalar.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Scalar 题目选项 DTO — 反序列化 Scalar API 返回的题目选项。 + * 此 DTO 保留在 integration 包内,不进入业务层或前端。 + * + * 注意:{@code isCorrect} 字段在校验层被剥离,不可透传到前端。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalarQuestionOptionDto { + + @JsonProperty("label") + private String label; + + @JsonProperty("content") + private String content; + + @JsonProperty("isCorrect") + private Boolean isCorrect; + + @JsonProperty("order") + private Double order; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionResponseDto.java new file mode 100644 index 00000000..13854d40 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionResponseDto.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.education.integration.scalar.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Scalar 题目响应 DTO — 用于 /api/catalog/questions 和 /api/catalog/questions/{id} 端点。 + * 此 DTO 保留在 integration 包内,不进入业务层或前端。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalarQuestionResponseDto { + + @JsonProperty("id") + private String id; + + @JsonProperty("contentVersion") + private String contentVersion; + + @JsonProperty("stem") + private String stem; + + @JsonProperty("type") + private String type; + + @JsonProperty("difficulty") + private String difficulty; + + @JsonProperty("options") + private List options; + + @JsonProperty("correctAnswer") + private Object correctAnswer; + + @JsonProperty("answer") + private Object answer; + + @JsonProperty("explanation") + private String explanation; + + @JsonProperty("analysis") + private String analysis; + + @JsonProperty("status") + private String status; + + @JsonProperty("isPublished") + private Boolean isPublished; + + @JsonProperty("collectionId") + private String collectionId; + + @JsonProperty("subjectId") + private String subjectId; + + @JsonProperty("nodeId") + private String nodeId; + + @JsonProperty("tags") + private List tags; + + @JsonProperty("order") + private Double order; + + @JsonProperty("metadata") + private Map metadata; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java index b5cd0de9..c81efb79 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntry import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO; import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO; import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -24,6 +25,7 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG */ @Service @Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") public class CatalogServiceImpl implements CatalogService { private final CatalogProvider provider; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java index 96294096..95b26cab 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java @@ -9,6 +9,13 @@ import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntry import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO; import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO; import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarQuestionResponseDto; +import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarQuestionOptionDto; +import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarPracticeBlueprintResponseDto; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.restclient.RestTemplateBuilder; import org.springframework.core.ParameterizedTypeReference; @@ -18,6 +25,7 @@ import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.*; import org.springframework.web.util.UriComponentsBuilder; +import java.net.URI; import java.io.IOException; import java.time.Duration; @@ -38,7 +46,8 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; * @author 恭学教育 */ @Slf4j -public class ScalarCatalogProvider implements CatalogProvider { + +public class ScalarCatalogProvider implements CatalogProvider, QuestionCatalogProvider { private final ScalarProperties scalarProperties; private final RestTemplate restTemplate; @@ -52,6 +61,14 @@ public class ScalarCatalogProvider implements CatalogProvider { private static final ParameterizedTypeReference> COLLECTION_LIST_TYPE = new ParameterizedTypeReference<>() {}; + // ========== Question type references ========== + private static final ParameterizedTypeReference> QUESTION_LIST_TYPE = + new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> QUESTION_SINGLE_TYPE = + new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> BLUEPRINT_SINGLE_TYPE = + new ParameterizedTypeReference<>() {}; + public ScalarCatalogProvider(ScalarProperties scalarProperties) { this.scalarProperties = scalarProperties; if (scalarProperties.isEnabled()) { @@ -177,8 +194,9 @@ public class ScalarCatalogProvider implements CatalogProvider { try { HttpHeaders headers = buildHeaders(); HttpEntity entity = new HttpEntity<>(headers); + URI uri = URI.create(scalarProperties.getBaseUrl() + path); ResponseEntity> response = restTemplate.exchange( - path, HttpMethod.GET, entity, typeRef); + uri, HttpMethod.GET, entity, typeRef); ScalarSingleItemResponse body = response.getBody(); if (body == null) { @@ -498,6 +516,242 @@ public class ScalarCatalogProvider implements CatalogProvider { .build(); } + + // ========== QuestionCatalogProvider implementation ========== + + /** Max page size to prevent unbounded upstream fetches. */ + private static final int MAX_PAGE_SIZE = 100; + + @Override + public CatalogQuestionPageResult listQuestions(String collectionId, String nodeId, + String type, String difficulty, + int pageNo, int pageSize) { + int boundedPageSize = clampPageSize(pageSize); + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/questions"); + addParam(builder, "collectionId", collectionId); + addParam(builder, "nodeId", nodeId); + addParam(builder, "type", type); + addParam(builder, "difficulty", difficulty); + builder.queryParam("published", "true"); + builder.queryParam("hidden", "false"); + builder.queryParam("page", pageNo); + builder.queryParam("pageSize", boundedPageSize); + + return callQuestionPageList(builder.toUriString()); + } + + @Override + public CatalogPracticeBlueprintDTO getPracticeBlueprint(String collectionId, String nodeId, + String type, String difficulty) { + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/practice-blueprints"); + addParam(builder, "collectionId", collectionId); + addParam(builder, "nodeId", nodeId); + addParam(builder, "type", type); + addParam(builder, "difficulty", difficulty); + + ScalarPracticeBlueprintResponseDto item = callSingleItem(builder.toUriString(), BLUEPRINT_SINGLE_TYPE); + return toPracticeBlueprintDTO(item); + } + + @Override + public CatalogQuestionDTO getQuestion(String questionId) { + String encodedId = encodePathSegmentSafe(questionId); + String path = UriComponentsBuilder.newInstance() + .path("/api/catalog/questions/" + encodedId) + .build(true).toUriString(); + ScalarQuestionResponseDto item = callSingleItem(path, QUESTION_SINGLE_TYPE); + return toQuestionDTO(item); + } + + @Override + public CatalogQuestionPageResult listCollectionQuestions(String collectionId, String type, String difficulty, + int pageNo, int pageSize) { + int boundedPageSize = clampPageSize(pageSize); + String encodedColl = encodePathSegmentSafe(collectionId); + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .path("/api/catalog/question-collections/" + encodedColl + "/questions"); + addParam(builder, "type", type); + addParam(builder, "difficulty", difficulty); + builder.queryParam("published", "true"); + builder.queryParam("hidden", "false"); + builder.queryParam("page", pageNo); + builder.queryParam("pageSize", boundedPageSize); + + String path = builder.build(true).toUriString(); + return callQuestionPageList(path); + } + + // ========== Question page list helper ========== + + /** + * 调用 Scalar 列表端点并构造 CatalogQuestionPageResult。 + * items 中的 null 元素会被检测并映射为 CATALOG_UPSTREAM_MALFORMED。 + */ + private CatalogQuestionPageResult callQuestionPageList(String path) { + Instant start = Instant.now(); + String sanitizedPath = sanitizedPath(path); + Long tenantId = getTenantId(); + try { + HttpHeaders headers = buildHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + URI uri = URI.create(scalarProperties.getBaseUrl() + path); + ResponseEntity> response = restTemplate.exchange( + uri, HttpMethod.GET, entity, QUESTION_LIST_TYPE); + + ScalarListResponse body = response.getBody(); + if (body == null) { + log.warn("[Scalar] malformed-response path={} tenant={} reason=null-body (page-list)", + sanitizedPath, tenantId); + throw exception(CATALOG_UPSTREAM_MALFORMED); + } + List items = body.getItems(); + if (items == null) { + log.warn("[Scalar] malformed-response path={} tenant={} reason=null-items (page-list)", + sanitizedPath, tenantId); + throw exception(CATALOG_UPSTREAM_MALFORMED); + } + + // Validate individual items: null elements are rejected + List mapped = new java.util.ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + ScalarQuestionResponseDto item = items.get(i); + if (item == null) { + log.warn("[Scalar] malformed-response path={} tenant={} reason=null-element idx={}", + sanitizedPath, tenantId, i); + throw exception(CATALOG_UPSTREAM_MALFORMED); + } + mapped.add(toQuestionDTO(item)); + } + + // Fail-closed: provider contract guarantees visible-only items (published=true, not hidden/inactive). + // Any violation → upstream data integrity error. + for (int i = 0; i < mapped.size(); i++) { + CatalogQuestionDTO q = mapped.get(i); + if (q == null) continue; + if (q.getIsPublished() == null || !q.getIsPublished()) { + log.warn("[Scalar] unsafe-payload path={} tenant={} reason=not-published id={}", + sanitizedPath, tenantId, q.getId()); + throw exception(UNSAFE_PROVIDER_PAYLOAD); + } + if (q.getStatus() != null && + ("hidden".equalsIgnoreCase(q.getStatus()) || "inactive".equalsIgnoreCase(q.getStatus()))) { + log.warn("[Scalar] unsafe-payload path={} tenant={} reason={} id={}", + sanitizedPath, tenantId, q.getStatus(), q.getId()); + throw exception(UNSAFE_PROVIDER_PAYLOAD); + } + } + + if (body.getTotal() == null) { + log.warn("[Scalar] path={} tenant={} total-missing items={}; rejecting as malformed", + sanitizedPath, tenantId, mapped.size()); + throw exception(CATALOG_UPSTREAM_MALFORMED); + } + long total = body.getTotal(); + String requestId = body.getMeta() != null ? body.getMeta().getRequestId() : null; + + long elapsed = Duration.between(start, Instant.now()).toMillis(); + log.info("[Scalar] ok path={} tenant={} requestId={} items={} total={} elapsed={}ms", + sanitizedPath, tenantId, requestId, mapped.size(), total, elapsed); + + return CatalogQuestionPageResult.builder() + .items(mapped) + .total(total) + .upstreamRequestId(requestId) + .build(); + } catch (HttpClientErrorException e) { + return handleClientError(path, sanitizedPath, start, e); + } catch (HttpServerErrorException e) { + return handleServerError(path, sanitizedPath, start, e); + } catch (ResourceAccessException e) { + long elapsed = Duration.between(start, Instant.now()).toMillis(); + String category = classifyResourceError(e); + log.error("[Scalar] io-error path={} tenant={} category={} elapsed={}ms", + sanitizedPath, tenantId, category, elapsed); + throw exception(mapResourceCategory(category)); + } catch (RestClientException e) { + long elapsed = Duration.between(start, Instant.now()).toMillis(); + log.error("[Scalar] conversion-error path={} tenant={} type={} elapsed={}ms", + sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed); + throw exception(CATALOG_UPSTREAM_MALFORMED); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + long elapsed = Duration.between(start, Instant.now()).toMillis(); + log.error("[Scalar] unexpected path={} tenant={} category={} elapsed={}ms", + sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed); + throw exception(CATALOG_UPSTREAM_ERROR); + } + } + + private static int clampPageSize(int pageSize) { + if (pageSize <= 0) return 20; + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + /** + * Encode a path segment value, including sub-delimiters like & that encodePathSegment leaves alone. + * Uses URLEncoder (application/x-www-form-urlencoded rules) with +→%20 replacement. + */ + private static String encodePathSegmentSafe(String value) { + String encoded = java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + return encoded.replace("+", "%20"); + } + + // ========== Question DTO mapping: Scalar → domain ========== + + static CatalogQuestionDTO toQuestionDTO(ScalarQuestionResponseDto dto) { + if (dto == null) return null; + return CatalogQuestionDTO.builder() + .id(dto.getId()) + .contentVersion(dto.getContentVersion() != null ? String.valueOf(dto.getContentVersion()) : null) + .stem(dto.getStem()) + .type(dto.getType()) + .difficulty(dto.getDifficulty()) + .options(mapOptions(dto.getOptions())) + .correctAnswer(dto.getCorrectAnswer()) + .answer(dto.getAnswer()) + .explanation(dto.getExplanation()) + .analysis(dto.getAnalysis()) + .status(dto.getStatus()) + .isPublished(dto.getIsPublished()) + .collectionId(dto.getCollectionId()) + .subjectId(dto.getSubjectId()) + .nodeId(dto.getNodeId()) + .tags(dto.getTags()) + .order(dto.getOrder()) + .build(); + } + + private static List mapOptions(List scalarOptions) { + if (scalarOptions == null) return null; + List result = new java.util.ArrayList<>(scalarOptions.size()); + for (int i = 0; i < scalarOptions.size(); i++) { + ScalarQuestionOptionDto o = scalarOptions.get(i); + if (o == null) { + throw exception(CATALOG_UPSTREAM_MALFORMED); + } + result.add(CatalogQuestionDTO.QuestionOptionDTO.builder() + .label(o.getLabel()) + .content(o.getContent()) + .isCorrect(o.getIsCorrect()) + .order(o.getOrder()) + .build()); + } + return result; + } + + static CatalogPracticeBlueprintDTO toPracticeBlueprintDTO(ScalarPracticeBlueprintResponseDto dto) { + if (dto == null) return null; + return CatalogPracticeBlueprintDTO.builder() + .eligibleCount(dto.getEligibleCount()) + .totalCount(dto.getTotalCount()) + .availableTypes(dto.getAvailableTypes()) + .availableDifficulties(dto.getAvailableDifficulties()) + .minQuestions(dto.getMinQuestions()) + .maxQuestions(dto.getMaxQuestions()) + .suggestedCount(dto.getSuggestedCount()) + .build(); + } // ========== interceptor ========== /** diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogProvider.java new file mode 100644 index 00000000..5d5ece63 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogProvider.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; + +/** + * 题目数据提供者接口。 + * 实现类隔离数据源差异,调用方只依赖此接口和领域 DTO。 + * + * @author 恭学教育 + */ +public interface QuestionCatalogProvider { + + /** + * 数据源是否可用。 + */ + boolean isEnabled(); + + /** + * 分页查询题目列表,支持筛选条件。 + * + *

合同要求:返回的题目必须全部对学生可见(isPublished=true,status 非 hidden/inactive)。 + * total 必须是可见题目的总数,不含不可见题目。实现类必须请求上游可见性过滤参数, + * 并拒绝任何违反合同的返回项。

+ * + * @param collectionId 题集 ID(可选) + * @param nodeId 目录节点 ID(可选) + * @param type 题型(可选) + * @param difficulty 难度(可选) + * @param pageNo 页码(从 1 开始) + * @param pageSize 每页条数(最大 100,由 provider 内部 clamp) + * @return 分页结果(items 均为可见题目 + visible total) + */ + CatalogQuestionPageResult listQuestions(String collectionId, String nodeId, + String type, String difficulty, + int pageNo, int pageSize); + + /** + * 根据 ID 获取单个题目。 + * + * @param questionId 题目 ID + * @return 题目(未找到时抛 CATALOG_UPSTREAM_NOT_FOUND) + */ + CatalogQuestionDTO getQuestion(String questionId); + + /** + * 查询题集中的题目列表。 + * + *

合同要求:同 {@link #listQuestions}。返回的题目必须全部可见, + * total 必须是可见题目的总数。

+ * + * @param collectionId 题集 ID + * @param type 题型(可选) + * @param difficulty 难度(可选) + * @param pageNo 页码 + * @param pageSize 每页条数(最大 100,由 provider 内部 clamp) + * @return 分页结果(items 均为可见题目 + visible total) + */ + CatalogQuestionPageResult listCollectionQuestions(String collectionId, String type, String difficulty, + int pageNo, int pageSize); + + /** + * 获取练习蓝图预览 — 不创建持久化会话。 + * + * @param collectionId 题集 ID + * @param nodeId 目录节点 ID(可选) + * @param type 题型(可选) + * @param difficulty 难度(可选) + * @return 蓝图预览 + */ + CatalogPracticeBlueprintDTO getPracticeBlueprint(String collectionId, String nodeId, + String type, String difficulty); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogService.java new file mode 100644 index 00000000..2c695bef --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogService.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.education.controller.app.question.vo.*; + +/** + * 题目目录服务接口。 + * 负责题目查询、安全过滤、练习预览等业务逻辑。 + * + * @author 恭学教育 + */ +public interface QuestionCatalogService { + + /** + * 分页查询安全题目列表。 + * + * @param reqVO 查询请求 + * @return 分页结果(仅含安全字段) + */ + PageResult pageQuestions(QuestionPageReqVO reqVO); + + /** + * 获取单个安全题目。 + * + * @param questionId 题目 ID + * @return 安全题目(不存在或不可见时抛异常) + */ + SafeQuestionRespVO getQuestion(String questionId); + + /** + * 查询题集中的安全题目列表。 + * + * @param collectionId 题集 ID + * @param type 题型(可选) + * @param difficulty 难度(可选) + * @param pageNo 页码 + * @param pageSize 每页条数(最大 100) + * @return 分页结果(仅含安全字段) + */ + PageResult listCollectionQuestions(String collectionId, String type, String difficulty, + int pageNo, int pageSize); + + /** + * 预览练习配置 — 验证筛选条件、题量范围,返回标准化配置。 + * 不创建数据库会话。 + * + * @param reqVO 预览请求 + * @return 标准化配置预览 + */ + PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java new file mode 100644 index 00000000..492ed9d4 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java @@ -0,0 +1,201 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.education.controller.app.question.vo.*; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +/** + * 题目目录服务实现。 + * 负责安全过滤(剥离答案字段)、可见性验证(fail-closed)、练习配置验证。 + * + * @author 恭学教育 + */ +@Service +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class QuestionCatalogServiceImpl implements QuestionCatalogService { + + private final QuestionCatalogProvider provider; + + public QuestionCatalogServiceImpl(QuestionCatalogProvider provider) { + this.provider = provider; + } + + @Override + public PageResult pageQuestions(QuestionPageReqVO reqVO) { + assertEnabled(); + + int pageNo = reqVO.getPageNo() != null ? reqVO.getPageNo() : 1; + int pageSize = reqVO.getPageSize() != null ? reqVO.getPageSize() : 20; + + CatalogQuestionPageResult pageResult = provider.listQuestions( + reqVO.getCollectionId(), reqVO.getNodeId(), + reqVO.getType(), reqVO.getDifficulty(), + pageNo, pageSize); + + if (pageResult == null || pageResult.getItems() == null) { + return new PageResult<>(Collections.emptyList(), 0L); + } + + // Fail-closed: provider contract guarantees visible-only items. + // Any invisible item → reject the entire response. + List safeList = new ArrayList<>(pageResult.getItems().size()); + for (CatalogQuestionDTO item : pageResult.getItems()) { + if (!isVisible(item)) { + throw exception(QUESTION_NOT_VISIBLE); + } + safeList.add(toSafeVO(item)); + } + + // Provider total is truthful — provider filters visibility upstream. + return new PageResult<>(safeList, pageResult.getTotal()); + } + + @Override + public SafeQuestionRespVO getQuestion(String questionId) { + assertEnabled(); + + CatalogQuestionDTO question = provider.getQuestion(questionId); + if (question == null) { + throw exception(QUESTION_NOT_FOUND); + } + + if (!isVisible(question)) { + throw exception(QUESTION_NOT_FOUND); + } + + return toSafeVO(question); + } + + @Override + public PageResult listCollectionQuestions(String collectionId, String type, String difficulty, + int pageNo, int pageSize) { + assertEnabled(); + + CatalogQuestionPageResult pageResult = provider.listCollectionQuestions( + collectionId, type, difficulty, pageNo, pageSize); + + if (pageResult == null || pageResult.getItems() == null) { + return new PageResult<>(Collections.emptyList(), 0L); + } + + // Fail-closed: provider contract guarantees visible-only items. + List safeList = new ArrayList<>(pageResult.getItems().size()); + for (CatalogQuestionDTO item : pageResult.getItems()) { + if (!isVisible(item)) { + throw exception(QUESTION_NOT_VISIBLE); + } + safeList.add(toSafeVO(item)); + } + + return new PageResult<>(safeList, pageResult.getTotal()); + } + + @Override + public PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO) { + assertEnabled(); + + int requestedCount = reqVO.getQuestionCount() != null ? reqVO.getQuestionCount() : 10; + + CatalogPracticeBlueprintDTO blueprint = provider.getPracticeBlueprint( + reqVO.getCollectionId(), reqVO.getNodeId(), + reqVO.getType(), reqVO.getDifficulty()); + + if (blueprint == null) { + throw exception(QUESTION_NOT_FOUND); + } + + int eligible = blueprint.getEligibleCount() != null ? blueprint.getEligibleCount() : 0; + int maxQ = blueprint.getMaxQuestions() != null ? blueprint.getMaxQuestions() : 0; + int minQ = blueprint.getMinQuestions() != null ? blueprint.getMinQuestions() : 1; + + // If requested exceeds eligible, reject with INSUFFICIENT + if (eligible == 0 || requestedCount > eligible) { + throw exception(INSUFFICIENT_ELIGIBLE_QUESTIONS, eligible, requestedCount); + } + + // Normalize within min/max bounds + int normalized = Math.max(minQ, Math.min(requestedCount, maxQ)); + + // countWithinRange is true only if requested fits within [minQ, maxQ] AND <= eligible + boolean withinRange = requestedCount >= minQ + && requestedCount <= maxQ + && requestedCount <= eligible; + + return PracticeConfigPreviewRespVO.builder() + .eligibleCount(eligible) + .totalCount(blueprint.getTotalCount()) + .availableTypes(blueprint.getAvailableTypes()) + .availableDifficulties(blueprint.getAvailableDifficulties()) + .minQuestions(minQ) + .maxQuestions(maxQ) + .suggestedCount(blueprint.getSuggestedCount()) + .normalizedCount(normalized) + .countWithinRange(withinRange) + .build(); + } + + // ========== internal helpers ========== + + private void assertEnabled() { + if (!provider.isEnabled()) { + throw exception(CATALOG_DATA_SOURCE_DISABLED); + } + } + + /** + * 判断题目是否对学生可见。 + * 只有已发布(isPublished=true)且非隐藏状态(status 不为 "hidden"/"inactive")的题目才可见。 + * 如果上游未提供 isPublished 字段,默认不可见(fail closed)。 + */ + private boolean isVisible(CatalogQuestionDTO q) { + if (q == null) return false; + if (q.getIsPublished() == null || !q.getIsPublished()) return false; + if (q.getStatus() == null) return true; + return !"hidden".equalsIgnoreCase(q.getStatus()) + && !"inactive".equalsIgnoreCase(q.getStatus()); + } + + // ========== DTO → safe VO conversion ========== + + /** + * 将领域 DTO 转换为安全 VO — 仅白名单字段被映射。 + * 正确性答案字段(correctAnswer、answer、explanation、analysis)和选项的 isCorrect 绝不出现在输出中。 + */ + static SafeQuestionRespVO toSafeVO(CatalogQuestionDTO dto) { + if (dto == null) return null; + SafeQuestionRespVO vo = SafeQuestionRespVO.builder() + .id(dto.getId()) + .contentVersion(dto.getContentVersion()) + .stem(dto.getStem()) + .type(dto.getType()) + .difficulty(dto.getDifficulty()) + .build(); + + if (dto.getOptions() != null) { + vo.setOptions(dto.getOptions().stream() + .map(opt -> SafeQuestionRespVO.SafeOptionVO.builder() + .label(opt.getLabel()) + .content(opt.getContent()) + .order(opt.getOrder()) + .build()) + .collect(Collectors.toList())); + } + + return vo; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/UnsupportedModeQuestionCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/UnsupportedModeQuestionCatalogProvider.java new file mode 100644 index 00000000..53675bf0 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/UnsupportedModeQuestionCatalogProvider.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_PROVIDER_MODE_INVALID; + +/** + * 不支持的题目数据源模式占位提供者。 + * 所有业务方法直接抛出 CATALOG_PROVIDER_MODE_INVALID。 + * + * @author 恭学教育 + */ +public class UnsupportedModeQuestionCatalogProvider implements QuestionCatalogProvider { + + private final CatalogProviderMode mode; + + public UnsupportedModeQuestionCatalogProvider(CatalogProviderMode mode) { + this.mode = mode; + } + + @Override + public boolean isEnabled() { + return false; + } + + @Override + public CatalogQuestionPageResult listQuestions(String collectionId, String nodeId, + String type, String difficulty, + int pageNo, int pageSize) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public CatalogQuestionDTO getQuestion(String questionId) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public CatalogQuestionPageResult listCollectionQuestions(String collectionId, String type, String difficulty, + int pageNo, int pageSize) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public CatalogPracticeBlueprintDTO getPracticeBlueprint(String collectionId, String nodeId, + String type, String difficulty) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogPracticeBlueprintDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogPracticeBlueprintDTO.java new file mode 100644 index 00000000..e93c7cbb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogPracticeBlueprintDTO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.education.service.question.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 练习蓝图领域 DTO — 练习配置预览的内部表示。 + * 仅在 provider/service 内部使用。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CatalogPracticeBlueprintDTO { + + /** 符合条件的题目数 */ + private Integer eligibleCount; + + /** 总题目数 */ + private Integer totalCount; + + /** 可用题型列表 */ + private List availableTypes; + + /** 可用难度列表 */ + private List availableDifficulties; + + /** 最少题量 */ + private Integer minQuestions; + + /** 最多题量 */ + private Integer maxQuestions; + + /** 建议题量 */ + private Integer suggestedCount; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionDTO.java new file mode 100644 index 00000000..f76397f8 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionDTO.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.education.service.question.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 题目领域 DTO — 内部题目数据的完整表示。 + * 此 DTO 包含所有字段(含答案),仅在 provider/service 内部使用。 + * 绝不直接序列化到前端;通过 SafeQuestionRespVO 的 allow-list 控制暴露字段。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CatalogQuestionDTO { + + /** 题目 ID */ + private String id; + + /** 内容版本 */ + private String contentVersion; + + /** 题干 */ + private String stem; + + /** 题型 */ + private String type; + + /** 难度 */ + private String difficulty; + + /** 选项列表(含正确性标记) */ + private List options; + + /** 正确答案 — 不可暴露到前端 */ + private Object correctAnswer; + + /** 答案 — 不可暴露到前端 */ + private Object answer; + + /** 解析 — 不可暴露到前端 */ + private String explanation; + + /** 分析 — 不可暴露到前端 */ + private String analysis; + + /** 发布状态 */ + private String status; + + /** 是否已发布 */ + private Boolean isPublished; + + /** 所属题集 ID */ + private String collectionId; + + /** 所属科目 ID */ + private String subjectId; + + /** 所属节点 ID */ + private String nodeId; + + /** 标签 */ + private List tags; + + /** 排序 */ + private Double order; + + /** + * 题目选项领域 DTO。 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class QuestionOptionDTO { + /** 选项标签(如 A/B/C/D) */ + private String label; + /** 选项内容 */ + private String content; + /** 是否为正确答案 — 不可暴露到前端 */ + private Boolean isCorrect; + /** 排序 */ + private Double order; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionPageResult.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionPageResult.java new file mode 100644 index 00000000..2c2c9dbd --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/dto/CatalogQuestionPageResult.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.education.service.question.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.List; + +/** + * Provider 分页结果 — 携带 items 和上游 total,供服务层做可见性过滤后使用。 + * 绝不直接序列化到前端。 + * + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CatalogQuestionPageResult { + + /** 题目列表(可能包含不可见题目,由服务层过滤) */ + @Builder.Default + private List items = Collections.emptyList(); + + /** 上游返回的符合条件的题目总数(可能包含不可见题目) */ + private long total; + + /** 上游元数据请求 ID(可选,用于问题追踪) */ + private String upstreamRequestId; + + /** + * 创建一个空的 safe result。 + */ + public static CatalogQuestionPageResult empty() { + return new CatalogQuestionPageResult(Collections.emptyList(), 0L, null); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java new file mode 100644 index 00000000..8e108e5a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java @@ -0,0 +1,330 @@ +package cn.iocoder.yudao.module.education.controller.app.question; + +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.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.question.QuestionCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService; +import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +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.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.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Collections; +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.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * QuestionController HTTP seam test — uses standalone MockMvc with real controller + * wiring to prove route mapping, authentication, query param forwarding, answer-field + * absence in JSON, and error handling through the HTTP layer. + * + * @author 恭学教育 + */ +class QuestionControllerHttpTest { + + private MockMvc mockMvc; + private QuestionCatalogProvider provider; + + @BeforeEach + void setUp() { + provider = mock(QuestionCatalogProvider.class); + when(provider.isEnabled()).thenReturn(true); + QuestionCatalogService service = new QuestionCatalogServiceImpl(provider); + QuestionController controller = new QuestionController(); + try { + var field = QuestionController.class.getDeclaredField("questionCatalogService"); + field.setAccessible(true); + field.set(controller, service); + } catch (Exception e) { + throw new RuntimeException(e); + } + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ========== Route mapping + authenticated ========== + + @Test + void shouldReturn200ForQuestionPageWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(provider.listQuestions(null, null, null, null, 1, 20)) + .thenReturn(CatalogQuestionPageResult.builder() + .items(Collections.emptyList()) + .total(0L) + .build()); + + MvcResult result = mockMvc.perform(get("/education/questions/page")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(0)) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + assertFalse(json.contains("correctAnswer"), "HTTP response must not contain correctAnswer"); + assertFalse(json.contains("\"answer\""), "HTTP response must not contain answer"); + assertFalse(json.contains("explanation"), "HTTP response must not contain explanation"); + assertFalse(json.contains("analysis"), "HTTP response must not contain analysis"); + assertFalse(json.contains("isCorrect"), "HTTP response must not contain isCorrect"); + } + + @Test + void shouldReturn200ForQuestionGetWhenAuthenticated() throws Exception { + setLoginUser(100L); + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO dto = + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.builder() + .id("q1") + .stem("Test?") + .type("choice") + .difficulty("easy") + .isPublished(true) + .build(); + when(provider.getQuestion("q1")).thenReturn(dto); + + MvcResult result = mockMvc.perform(get("/education/questions/get") + .param("id", "q1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value("q1")) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + assertFalse(json.contains("correctAnswer"), "HTTP get response must not contain correctAnswer"); + assertFalse(json.contains("\"answer\""), "HTTP get response must not contain answer"); + } + + @Test + void shouldReturn200ForCollectionQuestionsWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(provider.listCollectionQuestions("col1", null, null, 1, 20)) + .thenReturn(CatalogQuestionPageResult.builder() + .items(Collections.emptyList()) + .total(0L) + .build()); + + mockMvc.perform(get("/education/questions/collection-questions") + .param("collectionId", "col1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.total").value(0)); + } + + @Test + void shouldReturn200ForPracticePreviewWhenAuthenticated() throws Exception { + setLoginUser(100L); + cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO bp = + cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO.builder() + .eligibleCount(50) + .totalCount(100) + .minQuestions(1) + .maxQuestions(50) + .suggestedCount(20) + .build(); + when(provider.getPracticeBlueprint("col1", null, null, null)).thenReturn(bp); + + mockMvc.perform(get("/education/practice-config/preview") + .param("collectionId", "col1") + .param("questionCount", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.eligibleCount").value(50)) + .andExpect(jsonPath("$.data.normalizedCount").value(10)); + } + + @Test + void shouldForwardQueryParamsForPage() throws Exception { + setLoginUser(100L); + when(provider.listQuestions("col1", "node1", "choice", "easy", 2, 10)) + .thenReturn(CatalogQuestionPageResult.builder() + .items(Collections.emptyList()) + .total(0L) + .build()); + + mockMvc.perform(get("/education/questions/page") + .param("collectionId", "col1") + .param("nodeId", "node1") + .param("type", "choice") + .param("difficulty", "easy") + .param("pageNo", "2") + .param("pageSize", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + // ========== Anonymous → 401 ========== + + @Test + void shouldReturn401ForQuestionPageWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/questions/page")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401ForQuestionGetWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/questions/get") + .param("id", "q1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401ForCollectionQuestionsWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/questions/collection-questions") + .param("collectionId", "col1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401ForPracticePreviewWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/practice-config/preview") + .param("collectionId", "col1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + // ========== Feature disabled ========== + + @Test + void shouldReturnErrorWhenProviderDisabled() throws Exception { + setLoginUser(100L); + when(provider.isEnabled()).thenReturn(false); + + mockMvc.perform(get("/education/questions/get") + .param("id", "q1")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(CATALOG_DATA_SOURCE_DISABLED.getCode())); + } + + // ========== Answer field negative assertions across all endpoints ========== + + @Test + void shouldNeverLeakAnswerInAnyEndpoint() throws Exception { + setLoginUser(100L); + + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO fullDto = + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.builder() + .id("q-full") + .stem("Full question") + .type("choice") + .isPublished(true) + .correctAnswer("A") + .answer("The answer is A") + .explanation("Detailed explanation") + .analysis("Deep analysis") + .options(List.of( + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO.builder() + .label("A").content("Right answer").isCorrect(true).build(), + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO.builder() + .label("B").content("Wrong answer").isCorrect(false).build() + )) + .build(); + when(provider.getQuestion("q-full")).thenReturn(fullDto); + + MvcResult getResult = mockMvc.perform(get("/education/questions/get") + .param("id", "q-full")) + .andExpect(status().isOk()) + .andReturn(); + + String getJson = getResult.getResponse().getContentAsString(); + assertFalse(getJson.contains("correctAnswer"), "get: must not leak correctAnswer"); + assertFalse(getJson.contains("\"answer\""), "get: must not leak answer"); + assertFalse(getJson.contains("explanation"), "get: must not leak explanation"); + assertFalse(getJson.contains("analysis"), "get: must not leak analysis"); + assertFalse(getJson.contains("isCorrect"), "get: must not leak isCorrect"); + assertTrue(getJson.contains("\"stem\":\"Full question\""), "get: should contain stem"); + assertTrue(getJson.contains("\"label\":\"A\""), "get: should contain option label"); + } + + // ========== Practice preview INSUFFICIENT error through HTTP ========== + + @Test + void shouldReturnInsufficientForTooManyRequestedQuestions() throws Exception { + setLoginUser(100L); + cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO bp = + cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO.builder() + .eligibleCount(5) + .totalCount(50) + .minQuestions(1) + .maxQuestions(50) + .build(); + when(provider.getPracticeBlueprint("col1", null, null, null)).thenReturn(bp); + + mockMvc.perform(get("/education/practice-config/preview") + .param("collectionId", "col1") + .param("questionCount", "20")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode())); + } + + // ========== Error scenarios ========== + + @Test + void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception { + setLoginUser(100L); + when(provider.getQuestion("q-nonexistent")) + .thenThrow(new ServiceException(CATALOG_UPSTREAM_NOT_FOUND.getCode(), + CATALOG_UPSTREAM_NOT_FOUND.getMsg())); + + mockMvc.perform(get("/education/questions/get") + .param("id", "q-nonexistent")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(CATALOG_UPSTREAM_NOT_FOUND.getCode())); + } + + // ========== helpers ========== + + private void setLoginUser(Long userId) { + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setTenantId(1L); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + /** + * Minimal exception handler for standalone MockMvc. + */ + @RestControllerAdvice + static class TestExceptionHandler { + + @ExceptionHandler(ServiceException.class) + public CommonResult handleServiceException(ServiceException ex, + jakarta.servlet.http.HttpServletResponse response) { + if (ex.getCode() == UNAUTHORIZED.getCode()) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return CommonResult.error(UNAUTHORIZED); + } + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + return CommonResult.error(ex.getCode(), ex.getMessage()); + } + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java index 31def517..8a51185c 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java @@ -5,6 +5,9 @@ import cn.iocoder.yudao.module.education.enums.ErrorCodeConstants; import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider; import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider; import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider; +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.UnsupportedModeQuestionCatalogProvider; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -13,7 +16,8 @@ import static org.junit.jupiter.api.Assertions.*; /** * ScalarAutoConfiguration 启动上下文测试。 - * 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景。 + * 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景, + * 以及 QuestionCatalogProvider bean 注入和服务启动。 * * @author 恭学教育 */ @@ -127,6 +131,50 @@ class ScalarAutoConfigurationTest { }); } + // ========== Fix #1: QuestionCatalogProvider bean wiring ========== + + @Test + void shouldExposeScalarAsQuestionCatalogProvider() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=SCALAR_READ", + "yudao.education.scalar.enabled=true", + "yudao.education.scalar.base-url=http://localhost", + "yudao.education.scalar.token=test-token") + .run(context -> { + assertThat(context).hasSingleBean(QuestionCatalogProvider.class); + assertThat(context).hasSingleBean(ScalarCatalogProvider.class); + assertThat(context).hasSingleBean(CatalogProvider.class); + }); + } + + @Test + void shouldNotExposeQuestionCatalogProviderWhenDisabled() { + // Default: yudao.education.enabled=false — no QuestionCatalogProvider bean + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class); + }); + } + + @Test + void shouldStartQuestionCatalogServiceWithScalarProvider() { + contextRunner + .withUserConfiguration( + cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class) + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=SCALAR_READ", + "yudao.education.scalar.enabled=true", + "yudao.education.scalar.base-url=http://localhost", + "yudao.education.scalar.token=test-token") + .run(context -> { + assertThat(context).hasSingleBean( + cn.iocoder.yudao.module.education.service.question.QuestionCatalogService.class); + assertThat(context).hasSingleBean(QuestionCatalogProvider.class); + }); + } + // ========== Default: SCALAR_READ (matchIfMissing) ========== @Test @@ -139,4 +187,64 @@ class ScalarAutoConfigurationTest { }); } + // ========== Fix #5: QuestionCatalogProvider wiring for JAVA_READ ========== + + @Test + void shouldExposeUnsupportedQuestionCatalogProviderForJavaRead() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=JAVA_READ") + .run(context -> { + assertThat(context).hasSingleBean(QuestionCatalogProvider.class); + QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class); + assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class); + assertFalse(provider.isEnabled()); + }); + } + + @Test + void shouldStartQuestionCatalogServiceInJavaReadMode() { + contextRunner + .withUserConfiguration( + cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class) + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=JAVA_READ") + .run(context -> { + assertThat(context).hasSingleBean(QuestionCatalogService.class); + assertThat(context).hasSingleBean(QuestionCatalogProvider.class); + QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class); + assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class); + }); + } + + @Test + void shouldStartQuestionCatalogServiceWithScalarDisabled() { + contextRunner + .withUserConfiguration( + cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class) + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=SCALAR_READ") + .run(context -> { + assertThat(context).hasSingleBean(QuestionCatalogService.class); + assertThat(context).hasSingleBean(QuestionCatalogProvider.class); + QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class); + assertFalse(provider.isEnabled()); + }); + } + + @Test + void shouldNotLoadQuestionCatalogServiceWhenEducationDisabled() { + // Default: yudao.education.enabled=false — service is not created + contextRunner + .withUserConfiguration( + cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class) + .run(context -> { + assertThat(context).doesNotHaveBean(QuestionCatalogService.class); + assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class); + }); + } + } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java new file mode 100644 index 00000000..74962eef --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java @@ -0,0 +1,555 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.education.controller.app.question.vo.*; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +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.when; + +/** + * QuestionCatalogServiceImpl 单元测试 — 测试安全字段剥离、fail-closed 可见性验证、真实总数。 + * + * @author 恭学教育 + */ +@ExtendWith(MockitoExtension.class) +class QuestionCatalogServiceImplTest { + + @Mock + private QuestionCatalogProvider provider; + + private QuestionCatalogServiceImpl service; + + @BeforeEach + void setUp() { + service = new QuestionCatalogServiceImpl(provider); + when(provider.isEnabled()).thenReturn(true); + } + + // ========== Safety field stripping ========== + + @Test + void shouldStripCorrectAnswerAndExplanationFields() { + CatalogQuestionDTO dto = questionDto("q1", "What is 2+2?", "choice", "easy", true, + List.of(optionDto("A", "4", true), optionDto("B", "5", false)), + "A", "A", "Basic math explanation", "Deep analysis"); + + when(provider.getQuestion("q1")).thenReturn(dto); + + SafeQuestionRespVO result = service.getQuestion("q1"); + + assertNotNull(result); + assertEquals("q1", result.getId()); + assertEquals("What is 2+2?", result.getStem()); + assertEquals("choice", result.getType()); + assertEquals("easy", result.getDifficulty()); + + assertNotNull(result.getOptions()); + assertEquals(2, result.getOptions().size()); + assertEquals("A", result.getOptions().get(0).getLabel()); + assertEquals("4", result.getOptions().get(0).getContent()); + assertEquals("B", result.getOptions().get(1).getLabel()); + assertEquals("5", result.getOptions().get(1).getContent()); + + String json = JsonUtils.toJsonString(result); + assertFalse(json.contains("correctAnswer"), "JSON must not contain correctAnswer"); + assertFalse(json.contains("\"answer\""), "JSON must not contain answer"); + assertFalse(json.contains("explanation"), "JSON must not contain explanation"); + assertFalse(json.contains("analysis"), "JSON must not contain analysis"); + assertFalse(json.contains("isCorrect"), "JSON must not contain isCorrect"); + } + + @Test + void shouldStripAnswerFieldsInPageResponse() { + CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true, + List.of(optionDto("A", "Yes", true)), + "A", "A", "Explain", "Analyze"); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(dto)) + .total(1L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + + assertEquals(1, result.getList().size()); + + String json = JsonUtils.toJsonString(result); + assertFalse(json.contains("correctAnswer"), "Page JSON must not contain correctAnswer"); + assertFalse(json.contains("\"answer\""), "Page JSON must not contain answer"); + assertFalse(json.contains("explanation"), "Page JSON must not contain explanation"); + assertFalse(json.contains("analysis"), "Page JSON must not contain analysis"); + assertFalse(json.contains("isCorrect"), "Page JSON must not contain isCorrect"); + } + + // ========== Fail-closed visibility: reject invisible items ========== + + @Test + void shouldRejectUnpublishedQuestion() { + CatalogQuestionDTO dto = questionDto("q-unpub", "Q", "choice", "easy", false, null, + null, null, null, null); + + when(provider.getQuestion("q-unpub")).thenReturn(dto); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("q-unpub")); + assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); + } + + @Test + void shouldRejectQuestionWithNullIsPublished() { + CatalogQuestionDTO dto = CatalogQuestionDTO.builder() + .id("q-null-pub") + .stem("Q?") + .isPublished(null) + .build(); + + when(provider.getQuestion("q-null-pub")).thenReturn(dto); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("q-null-pub")); + assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); + } + + @Test + void shouldRejectHiddenQuestion() { + CatalogQuestionDTO dto = CatalogQuestionDTO.builder() + .id("q-hidden") + .stem("Secret") + .isPublished(true) + .status("hidden") + .build(); + + when(provider.getQuestion("q-hidden")).thenReturn(dto); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("q-hidden")); + assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); + } + + @Test + void shouldRejectInactiveQuestion() { + CatalogQuestionDTO dto = CatalogQuestionDTO.builder() + .id("q-inactive") + .stem("Old") + .isPublished(true) + .status("inactive") + .build(); + + when(provider.getQuestion("q-inactive")).thenReturn(dto); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("q-inactive")); + assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); + } + + // ========== Fix #5: fail-closed page responses ========== + + @Test + void shouldRejectUnpublishedItemInPageResponse() { + // Provider contract guarantees visible-only items. An unpublished item + // in the page → QUESTION_NOT_VISIBLE (fail-closed), not silent filtering. + CatalogQuestionDTO published = questionDto("q1", "Published", "choice", "easy", true, null, + null, null, null, null); + CatalogQuestionDTO unpublished = questionDto("q2", "Unpublished", "choice", "easy", false, null, + null, null, null, null); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(published, unpublished)) + .total(2L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.pageQuestions(req)); + assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode()); + } + + @Test + void shouldRejectHiddenItemInPageResponse() { + // Provider returned a hidden item → fail-closed with QUESTION_NOT_VISIBLE. + CatalogQuestionDTO visible = questionDto("q1", "Visible", "choice", "easy", true, null, + null, null, null, null); + CatalogQuestionDTO hidden = CatalogQuestionDTO.builder() + .id("q2").stem("Hidden").isPublished(true).status("hidden").build(); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(visible, hidden)) + .total(2L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.pageQuestions(req)); + assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode()); + } + + @Test + void shouldRejectUnpublishedItemInCollectionQuestions() { + CatalogQuestionDTO pub = questionDto("q1", "Pub", "choice", "easy", true, null, null, null, null, null); + CatalogQuestionDTO hidden = CatalogQuestionDTO.builder() + .id("q2").stem("Hidden").isPublished(true).status("hidden").build(); + + when(provider.listCollectionQuestions("col1", null, null, 1, 20)) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(pub, hidden)) + .total(2L) + .build()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.listCollectionQuestions("col1", null, null, 1, 20)); + assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode()); + } + + // ========== Fix #5: truthful totals ========== + + @Test + void shouldUseProviderTotalAsTruthfulVisibleTotal() { + // Provider enforces visibility contract — all items are visible, + // total IS the visible total. Service uses both directly. + CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null); + CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "medium", true, null, null, null, null, null); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(q1, q2)) + .total(50L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + + // All items visible — no rejection + assertEquals(2, result.getList().size()); + assertEquals("q1", result.getList().get(0).getId()); + assertEquals("q2", result.getList().get(1).getId()); + // Total = 50, truthful because provider only returns visible items + assertEquals(50L, result.getTotal()); + } + + @Test + void shouldUseProviderTotalForCollectionQuestions() { + CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null); + + when(provider.listCollectionQuestions("col1", null, null, 1, 20)) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(dto)) + .total(42L) + .build()); + + PageResult result = service.listCollectionQuestions("col1", null, null, 1, 20); + + assertEquals(1, result.getList().size()); + assertEquals("q1", result.getList().get(0).getId()); + assertEquals(42L, result.getTotal()); + } + + @Test + void shouldAllowAllPublishedAndActiveItems() { + // Mixed types but all published and status != hidden/inactive → all pass + CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null); + CatalogQuestionDTO q2 = CatalogQuestionDTO.builder() + .id("q2").stem("Q2").type("fill").difficulty("medium") + .isPublished(true).status("active").build(); + CatalogQuestionDTO q3 = CatalogQuestionDTO.builder() + .id("q3").stem("Q3").type("essay").difficulty("hard") + .isPublished(true).status(null).build(); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(q1, q2, q3)) + .total(3L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + + assertEquals(3, result.getList().size()); + assertEquals(3L, result.getTotal()); + } + + // ========== Practice config validation ========== + + @Test + void shouldReturnNormalizedPracticePreview() { + CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder() + .eligibleCount(50) + .totalCount(100) + .availableTypes(List.of("choice", "fill")) + .availableDifficulties(List.of("easy", "medium", "hard")) + .minQuestions(1) + .maxQuestions(50) + .suggestedCount(20) + .build(); + + when(provider.getPracticeBlueprint(eq("col1"), eq("node1"), eq("choice"), eq("easy"))) + .thenReturn(bp); + + PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO(); + req.setCollectionId("col1"); + req.setNodeId("node1"); + req.setType("choice"); + req.setDifficulty("easy"); + req.setQuestionCount(10); + + PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req); + + assertNotNull(result); + assertEquals(50, result.getEligibleCount()); + assertEquals(100, result.getTotalCount()); + assertEquals(10, result.getNormalizedCount()); + assertTrue(result.getCountWithinRange()); + assertEquals(List.of("choice", "fill"), result.getAvailableTypes()); + } + + @Test + void shouldClampNormalizedCountToMax() { + CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder() + .eligibleCount(20) + .totalCount(50) + .minQuestions(1) + .maxQuestions(10) + .suggestedCount(5) + .build(); + + when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())) + .thenReturn(bp); + + PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO(); + req.setCollectionId("col1"); + req.setQuestionCount(15); // 15 > max (10) so gets clamped; 15 <= eligible (20) so allowed + + PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req); + + assertEquals(10, result.getNormalizedCount()); + assertFalse(result.getCountWithinRange()); + } + + // ========== Fix #4: INSUFFICIENT when requested > eligible ========== + + @Test + void shouldRejectWhenZeroEligibleQuestions() { + CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder() + .eligibleCount(0) + .totalCount(100) + .minQuestions(1) + .maxQuestions(50) + .build(); + + when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())) + .thenReturn(bp); + + PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO(); + req.setCollectionId("col1"); + req.setQuestionCount(10); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.previewPracticeConfig(req)); + assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode()); + } + + @Test + void shouldRejectWhenRequestedExceedsEligible() { + // eligible=5 but requested=10 → INSUFFICIENT + CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder() + .eligibleCount(5) + .totalCount(100) + .minQuestions(1) + .maxQuestions(50) + .build(); + + when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())) + .thenReturn(bp); + + PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO(); + req.setCollectionId("col1"); + req.setQuestionCount(10); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.previewPracticeConfig(req)); + assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode()); + } + + @Test + void shouldAllowWhenRequestedEqualsEligible() { + CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder() + .eligibleCount(10) + .totalCount(50) + .minQuestions(1) + .maxQuestions(100) + .suggestedCount(10) + .build(); + + when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())) + .thenReturn(bp); + + PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO(); + req.setCollectionId("col1"); + req.setQuestionCount(10); + + PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req); + assertTrue(result.getCountWithinRange()); + assertEquals(10, result.getNormalizedCount()); + } + + // ========== Feature toggle ========== + + @Test + void shouldThrowWhenProviderDisabled() { + when(provider.isEnabled()).thenReturn(false); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("any")); + assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode()); + } + + // ========== Null safety ========== + + @Test + void shouldHandleNullProviderQuestion() { + when(provider.getQuestion("q-null")).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.getQuestion("q-null")); + assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); + } + + // ========== Fix #8: null list/page result handling ========== + + @Test + void shouldHandleNullPageResult() { + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(null); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + assertNotNull(result); + assertTrue(result.getList().isEmpty()); + assertEquals(0L, result.getTotal()); + } + + @Test + void shouldHandleNullPageResultItems() { + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(null) + .total(0L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + assertNotNull(result); + assertTrue(result.getList().isEmpty()); + } + + // ========== Two tenant contexts ========== + + @Test + void shouldNotLeakVisibilityChecksAcrossCalls() { + CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "choice", "easy", true, null, + null, null, null, null); + + when(provider.getQuestion("q-visible")).thenReturn(visible); + + SafeQuestionRespVO result1 = service.getQuestion("q-visible"); + assertNotNull(result1); + + SafeQuestionRespVO result2 = service.getQuestion("q-visible"); + assertNotNull(result2); + assertEquals(result1.getId(), result2.getId()); + } + + // ========== Fix #5: single call assertion (no duplicate count call) ========== + + @Test + void shouldMakeSingleProviderCallForPage() { + // The provider's listQuestions returns CatalogQuestionPageResult with total, + // so the service only makes one call. Total is used directly — no extra count call. + CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null); + CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "easy", true, null, null, null, null, null); + + when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20))) + .thenReturn(CatalogQuestionPageResult.builder() + .items(List.of(q1, q2)) + .total(50L) + .build()); + + QuestionPageReqVO req = new QuestionPageReqVO(); + req.setPageNo(1); + req.setPageSize(20); + + PageResult result = service.pageQuestions(req); + assertEquals(2, result.getList().size()); + assertEquals(50L, result.getTotal()); + // No countQuestions call needed — total comes from the page result + } + + // ========== helpers ========== + + private static CatalogQuestionDTO questionDto(String id, String stem, String type, String difficulty, + Boolean isPublished, + List options, + Object correctAnswer, Object answer, + String explanation, String analysis) { + return CatalogQuestionDTO.builder() + .id(id) + .contentVersion("v1") + .stem(stem) + .type(type) + .difficulty(difficulty) + .isPublished(isPublished) + .options(options) + .correctAnswer(correctAnswer) + .answer(answer) + .explanation(explanation) + .analysis(analysis) + .build(); + } + + private static CatalogQuestionDTO.QuestionOptionDTO optionDto(String label, String content, Boolean isCorrect) { + return CatalogQuestionDTO.QuestionOptionDTO.builder() + .label(label) + .content(content) + .isCorrect(isCorrect) + .build(); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/ScalarQuestionContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/ScalarQuestionContractTest.java new file mode 100644 index 00000000..01e9bbad --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/ScalarQuestionContractTest.java @@ -0,0 +1,507 @@ +package cn.iocoder.yudao.module.education.service.question; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties; +import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; +import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.List; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * ScalarCatalogProvider 题目契约测试 — 使用 MockRestServiceServer 模拟 Scalar API。 + * + * 覆盖:单题获取、题目列表、题集题目、蓝图预览、null body、null item、 + * 答案字段反序列化、缺失字段、额外字段、版本变化、404/5xx、 + * Fix #3 URL encoding、Fix #8 null elements、Fix #2 totals。 + * + * @author 恭学教育 + */ +class ScalarQuestionContractTest { + + private ScalarCatalogProvider provider; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + ScalarProperties props = new ScalarProperties(); + props.setEnabled(true); + props.setBaseUrl("http://scalar.test"); + props.setToken("test-token"); + props.setConnectTimeout(Duration.ofSeconds(2)); + props.setReadTimeout(Duration.ofSeconds(2)); + + provider = new ScalarCatalogProvider(props); + + try { + var field = ScalarCatalogProvider.class.getDeclaredField("restTemplate"); + field.setAccessible(true); + RestTemplate rt = (RestTemplate) field.get(provider); + mockServer = MockRestServiceServer.createServer(rt); + } catch (Exception e) { + throw new RuntimeException("Failed to access RestTemplate", e); + } + + TenantContextHolder.setTenantId(1L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + if (mockServer != null) { + mockServer.verify(); + } + } + + // ========== Single question ========== + + @Test + void shouldGetSingleQuestion() { + String json = """ + {"item": {"id":"q1","stem":"What is 2+2?","type":"choice","difficulty":"easy", + "options":[{"label":"A","content":"4","isCorrect":true}], + "correctAnswer":"A","explanation":"Basic math","isPublished":true, + "contentVersion":"v1"},"meta":{"requestId":"req-1"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q1"); + + assertNotNull(q); + assertEquals("q1", q.getId()); + assertEquals("What is 2+2?", q.getStem()); + assertEquals("choice", q.getType()); + assertEquals("easy", q.getDifficulty()); + assertEquals("v1", q.getContentVersion()); + assertTrue(q.getIsPublished()); + assertEquals("A", q.getCorrectAnswer()); + assertEquals("Basic math", q.getExplanation()); + assertNotNull(q.getOptions()); + assertEquals(1, q.getOptions().size()); + assertEquals("A", q.getOptions().get(0).getLabel()); + assertTrue(q.getOptions().get(0).getIsCorrect()); + } + + // ========== Fix #3: URL encoding ========== + + @Test + void shouldEncodeQuestionIdWithSlash() { + String json = """ + {"item": {"id":"q%2F1","stem":"Q?","isPublished":true}, + "meta":{"requestId":"req-enc-1"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + String path = request.getURI().getPath(); + // pathSegment encoding ensures slash becomes %2F + assertTrue(path.equals("/api/catalog/questions/q%2F1") + || path.equals("/api/catalog/questions/q/1"), + "path should be encoded: " + path); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + // The questionId "q/1" gets encoded via buildAndExpand + CatalogQuestionDTO q = provider.getQuestion("q/1"); + assertNotNull(q); + } + + @Test + void shouldEncodeQuestionIdWithSpecialChars() { + String json = """ + {"item": {"id":"q-special","stem":"Q","isPublished":true}, + "meta":{"requestId":"req-spec"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + String rawPath = request.getURI().getRawPath(); + // ?, %, &, non-ASCII must be encoded + assertTrue(rawPath.contains("%3F") || rawPath.contains("%3f"), "? must be encoded"); + assertTrue(rawPath.contains("%25"), "% must be encoded"); + assertTrue(rawPath.contains("%26"), "& must be encoded"); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q?%&special"); + assertNotNull(q); + } + + @Test + void shouldEncodeCollectionIdInUrl() { + String json = """ + {"items": [{"id":"q1","stem":"Q1","isPublished":true}], + "meta":{"requestId":"req-col-enc"},"total":1}"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + String rawPath = request.getURI().getRawPath(); + // collectionId with slash must be encoded + assertTrue(rawPath.contains("%2F"), "collectionId / must be encoded"); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionPageResult result = provider.listCollectionQuestions("col/with/slash", null, null, 1, 20); + assertNotNull(result); + assertEquals(1, result.getItems().size()); + } + + // ========== Fix #8: null body/item/elements ========== + + @Test + void shouldThrowWhenSingleQuestionNullBody() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q-null", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess("", MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getQuestion("q-null")); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowWhenSingleQuestionNullItem() { + String json = """ + {"item":null,"meta":{"requestId":"req-1"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q-null-item", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getQuestion("q-null-item")); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowWhenQuestionNotFound() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q-missing", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.NOT_FOUND) + .body("{\"error\":\"not found\"}") + .contentType(MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getQuestion("q-missing")); + assertEquals(CATALOG_UPSTREAM_NOT_FOUND.getCode(), ex.getCode()); + } + + // ========== Fix #8: null elements in list items ========== + + @Test + void shouldThrowWhenListHasNullElement() { + String json = """ + {"items": [ + {"id":"q1","stem":"Q1","isPublished":true}, + null + ],"meta":{"requestId":"req-null-elem"},"total":2}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.listQuestions(null, null, null, null, 1, 20)); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowWhenListOptionsHasNullElement() { + String json = """ + {"item": {"id":"q1","stem":"Q?","isPublished":true, + "options":[{"label":"A","content":"OptA","isCorrect":true},null]}, + "meta":{"requestId":"req-null-opt"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getQuestion("q1")); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + // ========== Missing / extra fields ========== + + @Test + void shouldHandleMissingFields() { + String json = """ + {"item": {"id":"q1","stem":"Question?"},"meta":{"requestId":"req-1"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q1"); + + assertNotNull(q); + assertEquals("q1", q.getId()); + assertEquals("Question?", q.getStem()); + assertNull(q.getType()); + assertNull(q.getIsPublished()); + assertNull(q.getCorrectAnswer()); + assertNull(q.getExplanation()); + } + + @Test + void shouldTolerateExtraFields() { + String json = """ + {"item": {"id":"q2","stem":"Q","extraField":"unexpected", + "nestedExtra":{"deep":"value"}},"meta":{"requestId":"req-2"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q2", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q2"); + assertEquals("q2", q.getId()); + assertEquals("Q", q.getStem()); + } + + @Test + void shouldHandleVersionChangeInResponse() { + String json = """ + {"item": {"id":"q3","stem":"Q","contentVersion":3},"meta":{"requestId":"req-3"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q3", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q3"); + assertEquals("q3", q.getId()); + assertNotNull(q.getContentVersion()); + } + + @Test + void shouldDeserializeCorrectAnswerField() { + String json = """ + {"item": {"id":"q4","stem":"Q","correctAnswer":"A", + "answer":"A","explanation":"Explanation","analysis":"Analysis"}, + "meta":{"requestId":"req-4"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/q4", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionDTO q = provider.getQuestion("q4"); + assertEquals("A", q.getCorrectAnswer()); + assertEquals("A", q.getAnswer()); + assertEquals("Explanation", q.getExplanation()); + assertEquals("Analysis", q.getAnalysis()); + } + + // ========== Fix #2: list questions with total ========== + + @Test + void shouldListQuestionsWithTotal() { + String json = """ + {"items": [ + {"id":"q1","stem":"Q1","type":"choice","difficulty":"easy","isPublished":true}, + {"id":"q2","stem":"Q2","type":"choice","difficulty":"medium","isPublished":true} + ],"meta":{"requestId":"req-list-1"},"total":42}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionPageResult result = provider.listQuestions( + "col1", "node1", "choice", "easy", 1, 20); + + assertEquals(2, result.getItems().size()); + assertEquals("q1", result.getItems().get(0).getId()); + assertEquals("Q1", result.getItems().get(0).getStem()); + assertEquals("q2", result.getItems().get(1).getId()); + assertEquals(42L, result.getTotal()); + assertEquals("req-list-1", result.getUpstreamRequestId()); + } + + @Test + void shouldRejectMissingTotalAsMalformed() { + String json = """ + {"items": [ + {"id":"q1","stem":"Q1","isPublished":true} + ],"meta":{"requestId":"req-no-total"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + assertThrows(ServiceException.class, () -> provider.listQuestions(null, null, null, null, 1, 20)); + } + + // ========== Collection questions ========== + + @Test + void shouldListCollectionQuestions() { + String json = """ + {"items": [ + {"id":"q1","stem":"CQ1","collectionId":"col1","isPublished":true} + ],"meta":{"requestId":"req-colq-1"},"total":1}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/question-collections/col1/questions", + request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionPageResult result = provider.listCollectionQuestions( + "col1", null, null, 1, 20); + + assertEquals(1, result.getItems().size()); + assertEquals("q1", result.getItems().get(0).getId()); + assertEquals("col1", result.getItems().get(0).getCollectionId()); + assertEquals(1L, result.getTotal()); + } + + // ========== Practice blueprint ========== + + @Test + void shouldGetPracticeBlueprint() { + String json = """ + {"item": {"eligibleCount":50,"totalCount":100, + "availableTypes":["choice","fill"], + "availableDifficulties":["easy","medium","hard"], + "minQuestions":1,"maxQuestions":50,"suggestedCount":20}, + "meta":{"requestId":"req-blueprint-1"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/practice-blueprints", + request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogPracticeBlueprintDTO bp = provider.getPracticeBlueprint( + "col1", "node1", "choice", "easy"); + + assertNotNull(bp); + assertEquals(50, bp.getEligibleCount()); + assertEquals(100, bp.getTotalCount()); + assertEquals(List.of("choice", "fill"), bp.getAvailableTypes()); + assertEquals(List.of("easy", "medium", "hard"), bp.getAvailableDifficulties()); + assertEquals(1, bp.getMinQuestions()); + assertEquals(50, bp.getMaxQuestions()); + assertEquals(20, bp.getSuggestedCount()); + } + + @Test + void shouldThrowWhenBlueprintNullItem() { + String json = """ + {"item":null,"meta":{"requestId":"req-bp-null"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/practice-blueprints", + request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getPracticeBlueprint("col1", null, null, null)); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + // ========== Server error ========== + + @Test + void shouldThrowOnServerErrorForQuestions() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions/err", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .body("{\"error\":\"boom\"}") + .contentType(MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.getQuestion("err")); + assertEquals(CATALOG_UPSTREAM_ERROR.getCode(), ex.getCode()); + } + + // ========== Null list items (old test, still relevant) ========== + + @Test + void shouldThrowWhenListQuestionsNullItems() { + String json = """ + {"meta":{"requestId":"req-null-items"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/questions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> provider.listQuestions(null, null, null, null, 1, 20)); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + // ========== Fix #7: bounded pagination ========== + + @Test + void shouldClampPageSizeToMax() { + String json = """ + {"items": [{"id":"q1","stem":"Q1","isPublished":true}], + "meta":{"requestId":"req-clamp"},"total":1}"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + String query = request.getURI().getQuery(); + assert query != null; + // pageSize should be clamped to 100, not 9999 + assertTrue(query.contains("pageSize=100"), "pageSize should be clamped to 100: " + query); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionPageResult result = provider.listQuestions(null, null, null, null, 1, 9999); + assertNotNull(result); + assertEquals(1, result.getItems().size()); + } + + @Test + void shouldDefaultPageSizeTo20WhenZero() { + String json = """ + {"items": [],"meta":{"requestId":"req-zero"},"total":0}"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + String query = request.getURI().getQuery(); + assert query != null; + assertTrue(query.contains("pageSize=20"), "pageSize=0 should default to 20: " + query); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(json, MediaType.APPLICATION_JSON)); + + CatalogQuestionPageResult result = provider.listQuestions(null, null, null, null, 1, 0); + assertNotNull(result); + assertTrue(result.getItems().isEmpty()); + } + +}