forked from wangziqi/ruoyi-vue-pro
feat(education): add scalar catalog adapter
This commit is contained in:
@@ -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_READ;JAVA_READ 为预留模式(当前不支持)。
|
||||
*/
|
||||
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
|
||||
|
||||
/**
|
||||
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
|
||||
* key = 标准化后的主机名(小写、无端口),value = 租户名。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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, "上游题库服务不可达,请稍后重试");
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user