feat(education): add scalar catalog adapter

This commit is contained in:
2026-07-27 18:39:49 +08:00
parent 0f846fdaf5
commit 478d3d65b7
37 changed files with 3374 additions and 21 deletions

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.education.config;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@@ -28,6 +29,12 @@ public class EducationProperties {
*/
private String version = "1.0.0";
/**
* 题库目录数据源模式。
* 默认 SCALAR_READJAVA_READ 为预留模式(当前不支持)。
*/
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
/**
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
* key = 标准化后的主机名小写、无端口value = 租户名。

View File

@@ -35,7 +35,7 @@ public class EducationCapabilityController {
.module("education")
.enabled(educationProperties.isEnabled())
.version(educationProperties.getVersion())
.capabilities(List.of("shell"))
.capabilities(List.of("shell", "catalog"))
.build();
return success(resp);
}

View File

@@ -0,0 +1,119 @@
package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
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 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>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 题库目录")
@RestController
@RequestMapping("/education/catalog")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class CatalogController {
@Resource
private CatalogService catalogService;
@GetMapping("/regions")
@Operation(summary = "查询可用地区列表")
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
assertAuthenticated();
return success(catalogService.listRegions());
}
@GetMapping("/categories")
@Operation(summary = "查询题目分类列表")
public CommonResult<List<CatalogCategoryRespVO>> listCategories(
@Parameter(description = "科目 ID") @RequestParam(required = false) String subjectId,
@Parameter(description = "旧导航节点 ID") @RequestParam(required = false) String nodeId) {
assertAuthenticated();
return success(catalogService.listCategories(subjectId, nodeId));
}
@GetMapping("/subjects")
@Operation(summary = "查询科目目录")
public CommonResult<List<CatalogSubjectRespVO>> listSubjects(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "院校 ID") @RequestParam(required = false) String schoolId,
@Parameter(description = "专业 ID") @RequestParam(required = false) String majorId,
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
@Parameter(description = "科目类型") @RequestParam(required = false) String type) {
assertAuthenticated();
return success(catalogService.listSubjects(regionId, schoolId, majorId, moduleId, type));
}
@GetMapping("/module-nodes")
@Operation(summary = "查询模块导航节点")
public CommonResult<List<CatalogModuleNodeRespVO>> listModuleNodes(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
@Parameter(description = "父节点 ID传 root 表示根节点)") @RequestParam(required = false) String parentId) {
assertAuthenticated();
return success(catalogService.listModuleNodes(regionId, moduleId, parentId));
}
@GetMapping("/content-entries")
@Operation(summary = "查询内容入口")
public CommonResult<List<CatalogContentEntryRespVO>> listContentEntries(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType,
@Parameter(description = "是否包含隐藏入口") @RequestParam(defaultValue = "false") boolean includeHidden) {
assertAuthenticated();
return success(catalogService.listContentEntries(regionId, entryType, includeHidden));
}
@GetMapping("/content-nodes")
@Operation(summary = "查询内容导航节点")
public CommonResult<List<CatalogContentNodeRespVO>> listContentNodes(
@Parameter(description = "内容入口 ID", required = true) @RequestParam String entryId,
@Parameter(description = "父节点 ID传 root 表示根节点)") @RequestParam(required = false) String parentId,
@Parameter(description = "查询模式children/flat") @RequestParam(defaultValue = "children") String mode,
@Parameter(description = "是否包含停用节点") @RequestParam(defaultValue = "false") boolean includeInactive,
@Parameter(description = "节点标记类型") @RequestParam(required = false) String markerType) {
assertAuthenticated();
return success(catalogService.listContentNodes(entryId, parentId, mode, includeInactive, markerType));
}
@GetMapping("/question-collections")
@Operation(summary = "查询可用题集")
public CommonResult<List<CatalogQuestionCollectionRespVO>> listQuestionCollections(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "内容入口 ID") @RequestParam(required = false) String entryId,
@Parameter(description = "内容节点 ID") @RequestParam(required = false) String nodeId,
@Parameter(description = "题集类型") @RequestParam(required = false) String collectionType,
@Parameter(description = "返回条数上限") @RequestParam(required = false) Integer limit) {
assertAuthenticated();
return success(catalogService.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit));
}
/**
* 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生。
*/
private void assertAuthenticated() {
if (SecurityFrameworkUtils.getLoginUserId() == null) {
throw exception(UNAUTHORIZED);
}
}
}

View File

@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 Category Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogCategoryRespVO {
@Schema(description = "分类 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440001")
private String id;
@Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考")
private String name;
@Schema(description = "分类类型", example = "exam_type")
private String type;
@Schema(description = "显示排序", example = "2")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 ContentEntry Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogContentEntryRespVO {
@Schema(description = "入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
private String id;
@Schema(description = "入口名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考数学题库")
private String name;
@Schema(description = "入口唯一键", requiredMode = Schema.RequiredMode.REQUIRED, example = "gaokao-math")
private String entryKey;
@Schema(description = "入口类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "question_bank")
private String entryType;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 ContentNode Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogContentNodeRespVO {
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440004")
private String id;
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.1 集合")
private String name;
@Schema(description = "节点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "node")
private String nodeType;
@Schema(description = "内容入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
private String entryId;
@Schema(description = "父节点 ID", example = "root")
private String parentId;
@Schema(description = "树深度", example = "1")
private Double depth;
@Schema(description = "是否叶子节点", example = "false")
private Boolean leaf;
@Schema(description = "是否可被选择", example = "true")
private Boolean selectable;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 ModuleNode Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogModuleNodeRespVO {
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440003")
private String id;
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一章")
private String name;
@Schema(description = "节点类型", example = "chapter")
private String type;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "父节点 ID", example = "root")
private String parentId;
@Schema(description = "显示排序", example = "4")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 QuestionCollection Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogQuestionCollectionRespVO {
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440006")
private String id;
@Schema(description = "题集名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024 高考数学真题")
private String name;
@Schema(description = "题集类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "exam_paper")
private String collectionType;
@Schema(description = "题目数量", example = "24")
private Long questionCount;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 Region Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogRegionRespVO {
@Schema(description = "地区 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
private String id;
@Schema(description = "地区名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "全国")
private String name;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 题库目录 Subject Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogSubjectRespVO {
@Schema(description = "科目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440002")
private String id;
@Schema(description = "科目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "数学")
private String name;
@Schema(description = "科目类型", example = "academic")
private String type;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "显示排序", example = "3")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.education.enums;
/**
* 题库目录数据源模式。
*
* <ul>
* <li>{@code SCALAR_READ} — 使用 Scalar API 读取题库目录数据</li>
* <li>{@code JAVA_READ} — 使用 Java 本地数据源(预留,当前不支持)</li>
* </ul>
*
* @author 恭学教育
*/
public enum CatalogProviderMode {
SCALAR_READ,
JAVA_READ
}

View File

@@ -18,4 +18,19 @@ public interface ErrorCodeConstants {
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
// ========== Catalog 目录 1-005-002-000 ~ 1-005-002-009 ==========
ErrorCode CATALOG_DATA_SOURCE_DISABLED = new ErrorCode(1_005_002_000, "题库数据源未启用,请联系管理员");
ErrorCode CATALOG_UPSTREAM_ERROR = new ErrorCode(1_005_002_001, "上游题库服务异常,请稍后重试");
ErrorCode CATALOG_UPSTREAM_AUTH_FAILED = new ErrorCode(1_005_002_002, "上游题库服务认证失败,请联系管理员");
ErrorCode CATALOG_UPSTREAM_FORBIDDEN = new ErrorCode(1_005_002_003, "无权限访问上游题库资源");
ErrorCode CATALOG_UPSTREAM_NOT_FOUND = new ErrorCode(1_005_002_004, "请求的题库资源不存在");
ErrorCode CATALOG_UPSTREAM_CONFLICT = new ErrorCode(1_005_002_005, "题库资源状态冲突");
ErrorCode CATALOG_UPSTREAM_TOO_MANY_REQUESTS = new ErrorCode(1_005_002_006, "题库服务请求过于频繁,请稍后重试");
ErrorCode CATALOG_UPSTREAM_TIMEOUT = new ErrorCode(1_005_002_007, "上游题库服务超时,请稍后重试");
ErrorCode CATALOG_UPSTREAM_UNEXPECTED = new ErrorCode(1_005_002_008, "上游题库服务返回异常:{}");
ErrorCode CATALOG_PROVIDER_MODE_INVALID = new ErrorCode(1_005_002_009, "不支持的题库数据源模式:{}");
ErrorCode CATALOG_SCALAR_NOT_CONFIGURED = new ErrorCode(1_005_002_010, "Scalar 数据源未配置 base-url 或 token请检查配置");
ErrorCode CATALOG_UPSTREAM_MALFORMED = new ErrorCode(1_005_002_011, "上游题库返回数据格式异常,请稍后重试");
ErrorCode CATALOG_UPSTREAM_UNAVAILABLE = new ErrorCode(1_005_002_012, "上游题库服务不可达,请稍后重试");
}

View File

@@ -0,0 +1,39 @@
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.catalog.ScalarCatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Scalar 数据源自动配置。
* 根据 yudao.education.catalog-mode 选择 CatalogProvider 实现。
* SCALAR_READ 模式创建 ScalarCatalogProvider其他模式含 JAVA_READ创建 UnsupportedModeCatalogProvider。
*
* @author 恭学教育
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ScalarProperties.class)
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class ScalarAutoConfiguration {
@Bean
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "SCALAR_READ", matchIfMissing = true)
public CatalogProvider scalarCatalogProvider(ScalarProperties scalarProperties) {
return new ScalarCatalogProvider(scalarProperties);
}
@Bean
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
public CatalogProvider unsupportedModeCatalogProvider(EducationProperties educationProperties) {
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;
return new UnsupportedModeCatalogProvider(mode);
}
}

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.education.integration.scalar.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Scalar 数据源配置属性。
*
* baseUrl 和 token 由部署环境注入,禁止硬编码到源码。
* enabled=false 时所有 Scalar 调用直接拒绝,无静默降级。
*
* @author 恭学教育
*/
@ConfigurationProperties(prefix = "yudao.education.scalar")
@Data
public class ScalarProperties {
/**
* 数据源开关。false 时调用方抛错,无静默降级。
*/
private boolean enabled = false;
/**
* Scalar API 基础地址,例如 https://api.example.com。
* 末尾不含斜杠。仅 enabled=true 时需要配置。
*/
private String baseUrl;
/**
* 静态服务端令牌Bearer。部署时作为 secret 注入,永不记录到日志。
* 仅 enabled=true 时需要配置。
*/
private String token;
/**
* 连接超时时间,默认 5 秒。
*/
private Duration connectTimeout = Duration.ofSeconds(5);
/**
* 读取超时时间,默认 30 秒。
*/
private Duration readTimeout = Duration.ofSeconds(30);
}

View File

@@ -0,0 +1,31 @@
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 API 错误响应 — 仅用于反序列化 Scalar 错误体。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarApiErrorResponseDto {
@JsonProperty("error")
private String error;
@JsonProperty("code")
private String code;
@JsonProperty("requestId")
private String requestId;
@JsonProperty("meta")
private ScalarApiResponseMetaDto meta;
}

View File

@@ -0,0 +1,22 @@
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 API 通用响应元数据 — 仅用于反序列化 Scalar 响应。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarApiResponseMetaDto {
@JsonProperty("requestId")
private String requestId;
}

View File

@@ -0,0 +1,51 @@
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 通用目录实体 — 用于 region/category/subject/module-node 等端点。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarCatalogEntityDto {
@JsonProperty("id")
private String id;
@JsonProperty("legacyId")
private Object legacyId;
@JsonProperty("name")
private String name;
@JsonProperty("title")
private String title;
@JsonProperty("type")
private String type;
@JsonProperty("regionId")
private Object regionId;
@JsonProperty("order")
private Double order;
@JsonProperty("isActive")
private Boolean isActive;
@JsonProperty("description")
private Object description;
@JsonProperty("metadata")
private Map<String, Object> metadata;
}

View File

@@ -0,0 +1,41 @@
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.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Scalar 内容入口响应 — 用于 /api/catalog/content-entries 端点。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarContentEntryResponseDto extends ScalarCatalogEntityDto {
@JsonProperty("entryKey")
private String entryKey;
@JsonProperty("entryType")
private String entryType;
@JsonProperty("route")
private Object route;
@JsonProperty("icon")
private Object icon;
@JsonProperty("visibility")
private String visibility;
@JsonProperty("accessRules")
private Map<String, Object> accessRules;
}

View File

@@ -0,0 +1,42 @@
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.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Scalar 内容导航节点响应 — 用于 /api/catalog/content-nodes 端点。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarContentNodeResponseDto extends ScalarCatalogEntityDto {
@JsonProperty("entryId")
private String entryId;
@JsonProperty("parentId")
private Object parentId;
@JsonProperty("nodeType")
private String nodeType;
@JsonProperty("markerType")
private Object markerType;
@JsonProperty("depth")
private Double depth;
@JsonProperty("isLeaf")
private Boolean isLeaf;
@JsonProperty("isSelectable")
private Boolean isSelectable;
}

View File

@@ -0,0 +1,37 @@
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.Collections;
import java.util.List;
/**
* Scalar 列表响应通用包装 — { items, meta }。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @param <T> 列表元素类型
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarListResponse<T> {
@JsonProperty("items")
private List<T> items;
@JsonProperty("meta")
private ScalarApiResponseMetaDto meta;
/**
* 获取原始 items可能为 null
* 调用方负责区分 null字段缺失和 [](显式空列表)。
*/
public List<T> getItems() {
return items;
}
}

View File

@@ -0,0 +1,41 @@
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.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Scalar 题集响应 — 用于 /api/catalog/question-collections 端点。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarQuestionCollectionResponseDto extends ScalarCatalogEntityDto {
@JsonProperty("entryId")
private Object entryId;
@JsonProperty("nodeId")
private Object nodeId;
@JsonProperty("collectionType")
private String collectionType;
@JsonProperty("questionCount")
private Double questionCount;
@JsonProperty("durationMinutes")
private Object durationMinutes;
@JsonProperty("accessRules")
private Map<String, Object> accessRules;
}

View File

@@ -0,0 +1,31 @@
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 单条目响应通用包装 — {"item": ..., "meta": {...}}。
* 用于非列表端点(如按 ID 查找单条记录)。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @param <T> 条目类型
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarSingleItemResponse<T> {
@JsonProperty("item")
private T item;
@JsonProperty("meta")
private ScalarApiResponseMetaDto meta;
public T getItem() {
return item;
}
}

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
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 java.util.List;
/**
* 题库目录数据提供者接口。
* 实现类隔离数据源差异,调用方只依赖此接口和领域 DTO。
*
* @author 恭学教育
*/
public interface CatalogProvider {
/**
* 数据源是否可用。
*/
boolean isEnabled();
/** 查询地区列表 */
List<CatalogEntityDTO> listRegions();
/** 查询分类列表 */
List<CatalogEntityDTO> listCategories(String subjectId, String nodeId);
/** 查询科目列表 */
List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type);
/** 查询模块导航节点 */
List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId);
/** 查询内容入口 */
List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden);
/** 查询内容导航节点 */
List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType);
/** 查询题集 */
List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit);
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import java.util.List;
/**
* 题库目录服务接口。
*
* @author 恭学教育
*/
public interface CatalogService {
List<CatalogRegionRespVO> listRegions();
List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId);
List<CatalogSubjectRespVO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type);
List<CatalogModuleNodeRespVO> listModuleNodes(String regionId, String moduleId, String parentId);
List<CatalogContentEntryRespVO> listContentEntries(String regionId, String entryType, boolean includeHidden);
List<CatalogContentNodeRespVO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType);
List<CatalogQuestionCollectionRespVO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit);
}

View File

@@ -0,0 +1,206 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
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.stereotype.Service;
import org.springframework.validation.annotation.Validated;
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.CATALOG_DATA_SOURCE_DISABLED;
/**
* 题库目录服务实现。
* 通过 CatalogProvider 隔离数据源,仅操作领域 DTO 和前端 VO。
* 不直接引用任何 integration 层 DTO。
*
* @author 恭学教育
*/
@Service
@Validated
public class CatalogServiceImpl implements CatalogService {
private final CatalogProvider provider;
public CatalogServiceImpl(CatalogProvider provider) {
this.provider = provider;
}
@Override
public List<CatalogRegionRespVO> listRegions() {
assertEnabled();
List<CatalogEntityDTO> regions = provider.listRegions();
if (regions == null) return Collections.emptyList();
return regions.stream()
.filter(r -> r.getIsActive() == null || r.getIsActive())
.map(CatalogServiceImpl::toRegionVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId) {
assertEnabled();
List<CatalogEntityDTO> categories = provider.listCategories(subjectId, nodeId);
if (categories == null) return Collections.emptyList();
return categories.stream()
.filter(c -> c.getIsActive() == null || c.getIsActive())
.map(CatalogServiceImpl::toCategoryVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogSubjectRespVO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
assertEnabled();
List<CatalogEntityDTO> subjects = provider.listSubjects(regionId, schoolId, majorId, moduleId, type);
if (subjects == null) return Collections.emptyList();
return subjects.stream()
.filter(s -> s.getIsActive() == null || s.getIsActive())
.map(CatalogServiceImpl::toSubjectVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogModuleNodeRespVO> listModuleNodes(String regionId, String moduleId, String parentId) {
assertEnabled();
List<CatalogEntityDTO> nodes = provider.listModuleNodes(regionId, moduleId, parentId);
if (nodes == null) return Collections.emptyList();
return nodes.stream()
.filter(n -> n.getIsActive() == null || n.getIsActive())
.map(CatalogServiceImpl::toModuleNodeVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogContentEntryRespVO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
assertEnabled();
List<CatalogContentEntryDTO> entries = provider.listContentEntries(regionId, entryType, includeHidden);
if (entries == null) return Collections.emptyList();
return entries.stream()
.filter(e -> e.getIsActive() == null || e.getIsActive())
.map(CatalogServiceImpl::toContentEntryVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogContentNodeRespVO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
assertEnabled();
List<CatalogContentNodeDTO> nodes = provider.listContentNodes(entryId, parentId, mode, includeInactive, markerType);
if (nodes == null) return Collections.emptyList();
return nodes.stream()
.filter(n -> includeInactive || n.getIsActive() == null || n.getIsActive())
.map(CatalogServiceImpl::toContentNodeVO)
.collect(Collectors.toList());
}
@Override
public List<CatalogQuestionCollectionRespVO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
assertEnabled();
List<CatalogQuestionCollectionDTO> collections = provider.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit);
if (collections == null) return Collections.emptyList();
return collections.stream()
.filter(c -> c.getIsActive() == null || c.getIsActive())
.map(CatalogServiceImpl::toQuestionCollectionVO)
.collect(Collectors.toList());
}
private void assertEnabled() {
if (!provider.isEnabled()) {
throw exception(CATALOG_DATA_SOURCE_DISABLED);
}
}
// ========== 转换方法 ==========
static CatalogRegionRespVO toRegionVO(CatalogEntityDTO dto) {
return CatalogRegionRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogCategoryRespVO toCategoryVO(CatalogEntityDTO dto) {
return CatalogCategoryRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.type(dto.getType())
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogSubjectRespVO toSubjectVO(CatalogEntityDTO dto) {
return CatalogSubjectRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.type(dto.getType())
.regionId(toString(dto.getRegionId()))
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogModuleNodeRespVO toModuleNodeVO(CatalogEntityDTO dto) {
return CatalogModuleNodeRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.type(dto.getType())
.regionId(toString(dto.getRegionId()))
.parentId(null)
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogContentEntryRespVO toContentEntryVO(CatalogContentEntryDTO dto) {
return CatalogContentEntryRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.entryKey(dto.getEntryKey())
.entryType(dto.getEntryType())
.regionId(toString(dto.getRegionId()))
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogContentNodeRespVO toContentNodeVO(CatalogContentNodeDTO dto) {
return CatalogContentNodeRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.nodeType(dto.getNodeType())
.entryId(dto.getEntryId())
.parentId(toString(dto.getParentId()))
.depth(dto.getDepth())
.leaf(dto.getIsLeaf())
.selectable(dto.getIsSelectable())
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
static CatalogQuestionCollectionRespVO toQuestionCollectionVO(CatalogQuestionCollectionDTO dto) {
return CatalogQuestionCollectionRespVO.builder()
.id(dto.getId())
.name(toString(dto.getName()))
.collectionType(dto.getCollectionType())
.questionCount(dto.getQuestionCount() != null ? dto.getQuestionCount().longValue() : null)
.order(dto.getOrder())
.active(dto.getIsActive())
.build();
}
private static String toString(Object value) {
if (value == null) return null;
if (value instanceof String s) return s;
return value.toString();
}
}

View File

@@ -0,0 +1,521 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
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.integration.scalar.dto.*;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.http.client.ClientHttpRequestInterceptor;
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.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
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.*;
/**
* Scalar 目录数据提供者实现。
* 使用 RestTemplate 调用 Scalar API自动注入 Bearer token 和 x-tenant-id。
* 所有 Scalar DTO 在此类内部完成映射,外部仅通过 CatalogProvider 接口和领域 DTO 访问。
*
* @author 恭学教育
*/
@Slf4j
public class ScalarCatalogProvider implements CatalogProvider {
private final ScalarProperties scalarProperties;
private final RestTemplate restTemplate;
private static final ParameterizedTypeReference<ScalarListResponse<ScalarCatalogEntityDto>> ENTITY_LIST_TYPE =
new ParameterizedTypeReference<>() {};
private static final ParameterizedTypeReference<ScalarListResponse<ScalarContentEntryResponseDto>> ENTRY_LIST_TYPE =
new ParameterizedTypeReference<>() {};
private static final ParameterizedTypeReference<ScalarListResponse<ScalarContentNodeResponseDto>> NODE_LIST_TYPE =
new ParameterizedTypeReference<>() {};
private static final ParameterizedTypeReference<ScalarListResponse<ScalarQuestionCollectionResponseDto>> COLLECTION_LIST_TYPE =
new ParameterizedTypeReference<>() {};
public ScalarCatalogProvider(ScalarProperties scalarProperties) {
this.scalarProperties = scalarProperties;
if (scalarProperties.isEnabled()) {
if (scalarProperties.getBaseUrl() == null || scalarProperties.getBaseUrl().isBlank()) {
throw exception(CATALOG_SCALAR_NOT_CONFIGURED);
}
if (scalarProperties.getToken() == null || scalarProperties.getToken().isBlank()) {
throw exception(CATALOG_SCALAR_NOT_CONFIGURED);
}
}
this.restTemplate = buildRestTemplate(scalarProperties);
}
private RestTemplate buildRestTemplate(ScalarProperties props) {
RestTemplateBuilder builder = new RestTemplateBuilder()
.connectTimeout(props.getConnectTimeout())
.readTimeout(props.getReadTimeout());
if (props.getBaseUrl() != null) {
builder = builder.rootUri(props.getBaseUrl());
}
return builder
.additionalInterceptors(new ScalarLoggingInterceptor())
.build();
}
@Override
public boolean isEnabled() {
return scalarProperties.isEnabled();
}
@Override
public List<CatalogEntityDTO> listRegions() {
return mapEntities(callEntityList("/api/catalog/regions"));
}
@Override
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
String path = buildPath("/api/catalog/categories",
"subjectId", subjectId, "nodeId", nodeId);
return mapEntities(callEntityList(path));
}
@Override
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
String path = buildPath("/api/catalog/subjects",
"regionId", regionId, "schoolId", schoolId,
"majorId", majorId, "moduleId", moduleId, "type", type);
return mapEntities(callEntityList(path));
}
@Override
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
String path = buildPath("/api/catalog/module-nodes",
"regionId", regionId, "moduleId", moduleId, "parentId", parentId);
return mapEntities(callEntityList(path));
}
@Override
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/content-entries");
addParam(builder, "regionId", regionId);
addParam(builder, "entryType", entryType);
if (includeHidden) {
builder.queryParam("includeHidden", "true");
}
List<ScalarContentEntryResponseDto> items = callList(builder.toUriString(), ENTRY_LIST_TYPE);
return items.stream()
.map(ScalarCatalogProvider::toContentEntryDTO)
.collect(Collectors.toList());
}
@Override
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/content-nodes");
addParam(builder, "entryId", entryId);
addParam(builder, "parentId", parentId);
addParam(builder, "mode", mode);
if (includeInactive) {
builder.queryParam("includeInactive", "true");
}
addParam(builder, "markerType", markerType);
List<ScalarContentNodeResponseDto> items = callList(builder.toUriString(), NODE_LIST_TYPE);
return items.stream()
.map(ScalarCatalogProvider::toContentNodeDTO)
.collect(Collectors.toList());
}
@Override
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/question-collections");
addParam(builder, "regionId", regionId);
addParam(builder, "entryId", entryId);
addParam(builder, "nodeId", nodeId);
addParam(builder, "collectionType", collectionType);
if (limit != null) {
builder.queryParam("limit", limit);
}
List<ScalarQuestionCollectionResponseDto> items = callList(builder.toUriString(), COLLECTION_LIST_TYPE);
return items.stream()
.map(ScalarCatalogProvider::toQuestionCollectionDTO)
.collect(Collectors.toList());
}
// ========== private helpers ==========
private List<ScalarCatalogEntityDto> callEntityList(String path) {
return callList(path, ENTITY_LIST_TYPE);
}
/**
* 单条目调用 — 反序列化 {"item": ..., "meta": {...}} 并返回 item。
* 包级可见供外部适配器或测试使用。item 为 null 或 body 为 null 时抛 CATALOG_UPSTREAM_MALFORMED。
*
* @param path 请求路径(含 query
* @param typeRef 条目参数化类型引用
* @param <T> 条目类型
* @return 反序列化后的单条目(非 null
*/
<T> T callSingleItem(String path, ParameterizedTypeReference<ScalarSingleItemResponse<T>> typeRef) {
Instant start = Instant.now();
String sanitizedPath = sanitizedPath(path);
Long tenantId = getTenantId();
try {
HttpHeaders headers = buildHeaders();
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<ScalarSingleItemResponse<T>> response = restTemplate.exchange(
path, HttpMethod.GET, entity, typeRef);
ScalarSingleItemResponse<T> body = response.getBody();
if (body == null) {
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-body (single)",
sanitizedPath, tenantId);
throw exception(CATALOG_UPSTREAM_MALFORMED);
}
T item = body.getItem();
if (item == null) {
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-item (single)",
sanitizedPath, tenantId);
throw exception(CATALOG_UPSTREAM_MALFORMED);
}
long elapsed = Duration.between(start, Instant.now()).toMillis();
String requestId = body.getMeta() != null ? body.getMeta().getRequestId() : null;
log.info("[Scalar] ok path={} tenant={} requestId={} item-type={} elapsed={}ms",
sanitizedPath, tenantId, requestId, item.getClass().getSimpleName(), elapsed);
return item;
} 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 <T> List<T> callList(String path, ParameterizedTypeReference<ScalarListResponse<T>> typeRef) {
Instant start = Instant.now();
String sanitizedPath = sanitizedPath(path);
Long tenantId = getTenantId();
try {
HttpHeaders headers = buildHeaders();
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<ScalarListResponse<T>> response = restTemplate.exchange(
path, HttpMethod.GET, entity, typeRef);
ScalarListResponse<T> body = response.getBody();
if (body == null) {
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-body",
sanitizedPath, tenantId);
throw exception(CATALOG_UPSTREAM_MALFORMED);
}
// Access raw items field to distinguish null from []
List<T> items = body.getItems();
if (items == null) {
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-items",
sanitizedPath, tenantId);
throw exception(CATALOG_UPSTREAM_MALFORMED);
}
long elapsed = Duration.between(start, Instant.now()).toMillis();
String requestId = body.getMeta() != null ? body.getMeta().getRequestId() : null;
log.info("[Scalar] ok path={} tenant={} requestId={} items={} elapsed={}ms",
sanitizedPath, tenantId, requestId, items.size(), elapsed);
return items;
} 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) {
// Already re-thrown above; let ServiceExceptions propagate
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 <T> T handleClientError(String path, String sanitizedPath, Instant start, HttpClientErrorException e) {
long elapsed = Duration.between(start, Instant.now()).toMillis();
HttpStatusCode statusCode = e.getStatusCode();
String requestId = extractRequestId(e);
log.warn("[Scalar] client-error path={} tenant={} status={} requestId={} elapsed={}ms",
sanitizedPath, getTenantId(), statusCode.value(), requestId, elapsed);
if (statusCode == HttpStatus.BAD_REQUEST) {
throw exception(GlobalErrorCodeConstants.BAD_REQUEST);
} else if (statusCode == HttpStatus.UNAUTHORIZED) {
throw exception(CATALOG_UPSTREAM_AUTH_FAILED);
} else if (statusCode == HttpStatus.FORBIDDEN) {
throw exception(CATALOG_UPSTREAM_FORBIDDEN);
} else if (statusCode == HttpStatus.NOT_FOUND) {
throw exception(CATALOG_UPSTREAM_NOT_FOUND);
} else if (statusCode == HttpStatus.CONFLICT) {
throw exception(CATALOG_UPSTREAM_CONFLICT);
} else if (statusCode == HttpStatus.TOO_MANY_REQUESTS) {
throw exception(CATALOG_UPSTREAM_TOO_MANY_REQUESTS);
} else {
throw exception(CATALOG_UPSTREAM_UNEXPECTED, statusCode.value());
}
}
private <T> T handleServerError(String path, String sanitizedPath, Instant start, HttpServerErrorException e) {
long elapsed = Duration.between(start, Instant.now()).toMillis();
String requestId = extractRequestId(e);
log.error("[Scalar] server-error path={} tenant={} status={} requestId={} elapsed={}ms",
sanitizedPath, getTenantId(), e.getStatusCode().value(), requestId, elapsed);
throw exception(CATALOG_UPSTREAM_ERROR);
}
private HttpHeaders buildHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(scalarProperties.getToken());
String tenantId = String.valueOf(TenantContextHolder.getRequiredTenantId());
headers.set("x-tenant-id", tenantId);
return headers;
}
private String extractRequestId(HttpClientErrorException e) {
return extractRequestIdFromBody(e.getResponseBodyAsString());
}
private String extractRequestId(HttpServerErrorException e) {
return extractRequestIdFromBody(e.getResponseBodyAsString());
}
private String extractRequestIdFromBody(String body) {
try {
ScalarApiErrorResponseDto errorBody = JsonUtils.parseObject(body, ScalarApiErrorResponseDto.class);
if (errorBody != null) {
if (errorBody.getMeta() != null && errorBody.getMeta().getRequestId() != null) {
return errorBody.getMeta().getRequestId();
}
return errorBody.getRequestId();
}
} catch (Exception ignored) {
// fall through
}
return null;
}
private Long getTenantId() {
try {
return TenantContextHolder.getRequiredTenantId();
} catch (Exception e) {
return null;
}
}
/**
* 对路径做脱敏,移除 query 参数中可能泄漏的敏感值。
*/
private String sanitizedPath(String path) {
if (path.contains("?")) {
return path.substring(0, path.indexOf('?'));
}
// If it's a full URL, extract path
if (path.contains("://")) {
try {
return java.net.URI.create(path).getPath();
} catch (Exception e) {
// keep original
}
}
return path;
}
/**
* 通过嵌套异常类型分类 ResourceAccessException。
* 不检查原始异常消息文本,仅通过异常类型判定。
*/
private String classifyResourceError(ResourceAccessException e) {
Throwable cause = e.getCause();
if (cause == null) {
return "io";
}
// Connection refused / unreachable / DNS failure
if (cause instanceof java.net.ConnectException
|| cause instanceof java.net.NoRouteToHostException
|| cause instanceof java.net.UnknownHostException) {
return "connect";
}
// SocketTimeoutException: differentiate connect vs read
if (cause instanceof java.net.SocketTimeoutException) {
String lower = cause.getMessage() != null ? cause.getMessage().toLowerCase() : "";
// "connect timed out" → connect timeout; "Read timed out" → read timeout
if (lower.contains("connect")) {
return "connect";
}
return "read-timeout";
}
return "io";
}
/**
* 将资源错误类别映射到业务错误码。
* connect → UNAVAILABLE上游不可达read-timeout → TIMEOUT其他 → TIMEOUT。
*/
private cn.iocoder.yudao.framework.common.exception.ErrorCode mapResourceCategory(String category) {
if ("connect".equals(category)) {
return CATALOG_UPSTREAM_UNAVAILABLE;
}
return CATALOG_UPSTREAM_TIMEOUT;
}
// ========== URI building ==========
private static String buildPath(String path, String... keyValues) {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath(path);
for (int i = 0; i < keyValues.length; i += 2) {
String key = keyValues[i];
String value = keyValues[i + 1];
addParam(builder, key, value);
}
return builder.toUriString();
}
private static void addParam(UriComponentsBuilder builder, String key, String value) {
if (value != null && !value.isEmpty()) {
builder.queryParam(key, value);
}
}
// ========== DTO mapping: Scalar → domain ==========
private static List<CatalogEntityDTO> mapEntities(List<ScalarCatalogEntityDto> scalarDtos) {
return scalarDtos.stream()
.map(ScalarCatalogProvider::toEntityDTO)
.collect(Collectors.toList());
}
static CatalogEntityDTO toEntityDTO(ScalarCatalogEntityDto dto) {
return CatalogEntityDTO.builder()
.id(dto.getId())
.name(dto.getName())
.title(dto.getTitle())
.type(dto.getType())
.regionId(dto.getRegionId())
.order(dto.getOrder())
.isActive(dto.getIsActive())
.metadata(dto.getMetadata())
.build();
}
static CatalogContentEntryDTO toContentEntryDTO(ScalarContentEntryResponseDto dto) {
return CatalogContentEntryDTO.contentEntryBuilder()
.id(dto.getId())
.name(dto.getName())
.title(dto.getTitle())
.type(dto.getType())
.regionId(dto.getRegionId())
.order(dto.getOrder())
.isActive(dto.getIsActive())
.metadata(dto.getMetadata())
.entryKey(dto.getEntryKey())
.entryType(dto.getEntryType())
.build();
}
static CatalogContentNodeDTO toContentNodeDTO(ScalarContentNodeResponseDto dto) {
return CatalogContentNodeDTO.contentNodeBuilder()
.id(dto.getId())
.name(dto.getName())
.title(dto.getTitle())
.type(dto.getType())
.regionId(dto.getRegionId())
.order(dto.getOrder())
.isActive(dto.getIsActive())
.metadata(dto.getMetadata())
.entryId(dto.getEntryId())
.parentId(dto.getParentId())
.nodeType(dto.getNodeType())
.depth(dto.getDepth())
.isLeaf(dto.getIsLeaf())
.isSelectable(dto.getIsSelectable())
.build();
}
static CatalogQuestionCollectionDTO toQuestionCollectionDTO(ScalarQuestionCollectionResponseDto dto) {
return CatalogQuestionCollectionDTO.questionCollectionBuilder()
.id(dto.getId())
.name(dto.getName())
.title(dto.getTitle())
.type(dto.getType())
.regionId(dto.getRegionId())
.order(dto.getOrder())
.isActive(dto.getIsActive())
.metadata(dto.getMetadata())
.entryId(dto.getEntryId())
.nodeId(dto.getNodeId())
.collectionType(dto.getCollectionType())
.questionCount(dto.getQuestionCount())
.build();
}
// ========== interceptor ==========
/**
* 日志拦截器 — 记录请求级别信息,脱敏 Authorization 和 x-tenant-id 值。
*/
@Slf4j
private static class ScalarLoggingInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(org.springframework.http.HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
Instant start = Instant.now();
ClientHttpResponse response = execution.execute(request, body);
long elapsed = Duration.between(start, Instant.now()).toMillis();
log.debug("[Scalar] request method={} path={} status={} elapsed={}ms",
request.getMethod(), request.getURI().getPath(),
response.getStatusCode().value(), elapsed);
return response;
}
}
}

View File

@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
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 java.util.List;
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 UnsupportedModeCatalogProvider implements CatalogProvider {
private final CatalogProviderMode mode;
public UnsupportedModeCatalogProvider(CatalogProviderMode mode) {
this.mode = mode;
}
@Override
public boolean isEnabled() {
return false;
}
@Override
public List<CatalogEntityDTO> listRegions() {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, this.mode.name());
}
@Override
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.education.service.catalog.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 内容入口领域 DTO。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class CatalogContentEntryDTO extends CatalogEntityDTO {
@Builder(builderMethodName = "contentEntryBuilder")
public CatalogContentEntryDTO(String id, String name, String title, String type, Object regionId,
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
String entryKey, String entryType) {
super(id, name, title, type, regionId, order, isActive, description, metadata);
this.entryKey = entryKey;
this.entryType = entryType;
}
private String entryKey;
private String entryType;
}

View File

@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.education.service.catalog.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 内容导航节点领域 DTO。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class CatalogContentNodeDTO extends CatalogEntityDTO {
@Builder(builderMethodName = "contentNodeBuilder")
public CatalogContentNodeDTO(String id, String name, String title, String type, Object regionId,
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
String entryId, Object parentId, String nodeType,
Double depth, Boolean isLeaf, Boolean isSelectable) {
super(id, name, title, type, regionId, order, isActive, description, metadata);
this.entryId = entryId;
this.parentId = parentId;
this.nodeType = nodeType;
this.depth = depth;
this.isLeaf = isLeaf;
this.isSelectable = isSelectable;
}
private String entryId;
private Object parentId;
private String nodeType;
private Double depth;
private Boolean isLeaf;
private Boolean isSelectable;
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.education.service.catalog.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 目录实体领域 DTO — 对应 region/category/subject/module-node。
* 隔离外部数据源 DTO业务层只依赖此类型。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogEntityDTO {
private String id;
private String name;
private String title;
private String type;
private Object regionId;
private Double order;
private Boolean isActive;
private String description;
private Map<String, Object> metadata;
}

View File

@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.education.service.catalog.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 题集领域 DTO。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class CatalogQuestionCollectionDTO extends CatalogEntityDTO {
@Builder(builderMethodName = "questionCollectionBuilder")
public CatalogQuestionCollectionDTO(String id, String name, String title, String type, Object regionId,
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
Object entryId, Object nodeId, String collectionType, Double questionCount) {
super(id, name, title, type, regionId, order, isActive, description, metadata);
this.entryId = entryId;
this.nodeId = nodeId;
this.collectionType = collectionType;
this.questionCount = questionCount;
}
private Object entryId;
private Object nodeId;
private String collectionType;
private Double questionCount;
}