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());
+ }
+
+}