forked from wangziqi/ruoyi-vue-pro
feat(education): question browsing and practice configuration preview
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 — 学生端已认证接口。
|
||||
*
|
||||
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
|
||||
* <p>返回的题目数据经过白名单过滤,绝不包含答案、解析或正确性标记。</p>
|
||||
*
|
||||
* @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<PageResult<SafeQuestionRespVO>> 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<SafeQuestionRespVO> getQuestion(
|
||||
@Parameter(description = "题目 ID", required = true) @RequestParam String id) {
|
||||
assertAuthenticated();
|
||||
return success(questionCatalogService.getQuestion(id));
|
||||
}
|
||||
|
||||
@GetMapping("/questions/collection-questions")
|
||||
@Operation(summary = "查询题集中的安全题目",
|
||||
description = "获取指定题集中的题目列表,支持题型和难度筛选。")
|
||||
public CommonResult<PageResult<SafeQuestionRespVO>> 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<PracticeConfigPreviewRespVO> previewPracticeConfig(
|
||||
@Valid PracticeConfigPreviewReqVO reqVO) {
|
||||
assertAuthenticated();
|
||||
return success(questionCatalogService.previewPracticeConfig(reqVO));
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生。
|
||||
*/
|
||||
private void assertAuthenticated() {
|
||||
if (SecurityFrameworkUtils.getLoginUserId() == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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<String> availableTypes;
|
||||
|
||||
@Schema(description = "可用难度列表", example = "[\"easy\", \"medium\"]")
|
||||
private List<String> 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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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<SafeOptionVO> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, "题库数据源返回了不应出现的不可见题目");
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scalar 列表响应通用包装 — { items, meta }。
|
||||
* Scalar 列表响应通用包装 — { items, meta, total }。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @param <T> 列表元素类型
|
||||
@@ -27,11 +27,10 @@ public class ScalarListResponse<T> {
|
||||
private ScalarApiResponseMetaDto meta;
|
||||
|
||||
/**
|
||||
* 获取原始 items(可能为 null)。
|
||||
* 调用方负责区分 null(字段缺失)和 [](显式空列表)。
|
||||
* 符合条件的总数(上游分页响应中的 total 字段)。
|
||||
* 为 null 时表示上游未返回总数。
|
||||
*/
|
||||
public List<T> getItems() {
|
||||
return items;
|
||||
}
|
||||
@JsonProperty("total")
|
||||
private Long total;
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String> availableTypes;
|
||||
|
||||
@JsonProperty("availableDifficulties")
|
||||
private java.util.List<String> availableDifficulties;
|
||||
|
||||
@JsonProperty("minQuestions")
|
||||
private Integer minQuestions;
|
||||
|
||||
@JsonProperty("maxQuestions")
|
||||
private Integer maxQuestions;
|
||||
|
||||
@JsonProperty("suggestedCount")
|
||||
private Integer suggestedCount;
|
||||
|
||||
@JsonProperty("metadata")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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<ScalarQuestionOptionDto> 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<String> tags;
|
||||
|
||||
@JsonProperty("order")
|
||||
private Double order;
|
||||
|
||||
@JsonProperty("metadata")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ScalarListResponse<ScalarQuestionCollectionResponseDto>> COLLECTION_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
// ========== Question type references ==========
|
||||
private static final ParameterizedTypeReference<ScalarListResponse<ScalarQuestionResponseDto>> QUESTION_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<ScalarSingleItemResponse<ScalarQuestionResponseDto>> QUESTION_SINGLE_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<ScalarSingleItemResponse<ScalarPracticeBlueprintResponseDto>> 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<Void> entity = new HttpEntity<>(headers);
|
||||
URI uri = URI.create(scalarProperties.getBaseUrl() + path);
|
||||
ResponseEntity<ScalarSingleItemResponse<T>> response = restTemplate.exchange(
|
||||
path, HttpMethod.GET, entity, typeRef);
|
||||
uri, HttpMethod.GET, entity, typeRef);
|
||||
|
||||
ScalarSingleItemResponse<T> 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<Void> entity = new HttpEntity<>(headers);
|
||||
URI uri = URI.create(scalarProperties.getBaseUrl() + path);
|
||||
ResponseEntity<ScalarListResponse<ScalarQuestionResponseDto>> response = restTemplate.exchange(
|
||||
uri, HttpMethod.GET, entity, QUESTION_LIST_TYPE);
|
||||
|
||||
ScalarListResponse<ScalarQuestionResponseDto> 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<ScalarQuestionResponseDto> 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<CatalogQuestionDTO> 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<CatalogQuestionDTO.QuestionOptionDTO> mapOptions(List<ScalarQuestionOptionDto> scalarOptions) {
|
||||
if (scalarOptions == null) return null;
|
||||
List<CatalogQuestionDTO.QuestionOptionDTO> 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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
/**
|
||||
* 分页查询题目列表,支持筛选条件。
|
||||
*
|
||||
* <p><b>合同要求:</b>返回的题目必须全部对学生可见(isPublished=true,status 非 hidden/inactive)。
|
||||
* total 必须是可见题目的总数,不含不可见题目。实现类必须请求上游可见性过滤参数,
|
||||
* 并拒绝任何违反合同的返回项。</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* 查询题集中的题目列表。
|
||||
*
|
||||
* <p><b>合同要求:</b>同 {@link #listQuestions}。返回的题目必须全部可见,
|
||||
* total 必须是可见题目的总数。</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
}
|
||||
@@ -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<SafeQuestionRespVO> 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<SafeQuestionRespVO> listCollectionQuestions(String collectionId, String type, String difficulty,
|
||||
int pageNo, int pageSize);
|
||||
|
||||
/**
|
||||
* 预览练习配置 — 验证筛选条件、题量范围,返回标准化配置。
|
||||
* 不创建数据库会话。
|
||||
*
|
||||
* @param reqVO 预览请求
|
||||
* @return 标准化配置预览
|
||||
*/
|
||||
PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO);
|
||||
|
||||
}
|
||||
@@ -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<SafeQuestionRespVO> 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<SafeQuestionRespVO> 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<SafeQuestionRespVO> 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<SafeQuestionRespVO> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> availableTypes;
|
||||
|
||||
/** 可用难度列表 */
|
||||
private List<String> availableDifficulties;
|
||||
|
||||
/** 最少题量 */
|
||||
private Integer minQuestions;
|
||||
|
||||
/** 最多题量 */
|
||||
private Integer maxQuestions;
|
||||
|
||||
/** 建议题量 */
|
||||
private Integer suggestedCount;
|
||||
|
||||
}
|
||||
@@ -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<QuestionOptionDTO> 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<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<CatalogQuestionDTO> items = Collections.emptyList();
|
||||
|
||||
/** 上游返回的符合条件的题目总数(可能包含不可见题目) */
|
||||
private long total;
|
||||
|
||||
/** 上游元数据请求 ID(可选,用于问题追踪) */
|
||||
private String upstreamRequestId;
|
||||
|
||||
/**
|
||||
* 创建一个空的 safe result。
|
||||
*/
|
||||
public static CatalogQuestionPageResult empty() {
|
||||
return new CatalogQuestionPageResult(Collections.emptyList(), 0L, null);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user