From 478d3d65b74703bfeea59e072d00faadfb7493b8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 27 Jul 2026 18:39:49 +0800 Subject: [PATCH] feat(education): add scalar catalog adapter --- yudao-module-education/README.md | 120 +++- .../education/config/EducationProperties.java | 7 + .../admin/EducationCapabilityController.java | 2 +- .../app/catalog/CatalogController.java | 119 ++++ .../app/catalog/vo/CatalogCategoryRespVO.java | 31 + .../catalog/vo/CatalogContentEntryRespVO.java | 37 ++ .../catalog/vo/CatalogContentNodeRespVO.java | 46 ++ .../catalog/vo/CatalogModuleNodeRespVO.java | 37 ++ .../vo/CatalogQuestionCollectionRespVO.java | 34 ++ .../app/catalog/vo/CatalogRegionRespVO.java | 28 + .../app/catalog/vo/CatalogSubjectRespVO.java | 34 ++ .../education/enums/CatalogProviderMode.java | 18 + .../education/enums/ErrorCodeConstants.java | 15 + .../config/ScalarAutoConfiguration.java | 39 ++ .../scalar/config/ScalarProperties.java | 47 ++ .../scalar/dto/ScalarApiErrorResponseDto.java | 31 + .../scalar/dto/ScalarApiResponseMetaDto.java | 22 + .../scalar/dto/ScalarCatalogEntityDto.java | 51 ++ .../dto/ScalarContentEntryResponseDto.java | 41 ++ .../dto/ScalarContentNodeResponseDto.java | 42 ++ .../scalar/dto/ScalarListResponse.java | 37 ++ .../ScalarQuestionCollectionResponseDto.java | 41 ++ .../scalar/dto/ScalarSingleItemResponse.java | 31 + .../service/catalog/CatalogProvider.java | 44 ++ .../service/catalog/CatalogService.java | 28 + .../service/catalog/CatalogServiceImpl.java | 206 +++++++ .../catalog/ScalarCatalogProvider.java | 521 ++++++++++++++++ .../UnsupportedModeCatalogProvider.java | 68 +++ .../catalog/dto/CatalogContentEntryDTO.java | 32 + .../catalog/dto/CatalogContentNodeDTO.java | 41 ++ .../service/catalog/dto/CatalogEntityDTO.java | 32 + .../dto/CatalogQuestionCollectionDTO.java | 36 ++ .../catalog/CatalogControllerHttpTest.java | 272 +++++++++ .../app/catalog/CatalogControllerTest.java | 224 +++++++ .../config/ScalarAutoConfigurationTest.java | 142 +++++ .../catalog/CatalogServiceImplTest.java | 262 ++++++++ .../catalog/ScalarCatalogProviderTest.java | 577 ++++++++++++++++++ 37 files changed, 3374 insertions(+), 21 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogController.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogCategoryRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentEntryRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentNodeRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogModuleNodeRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogQuestionCollectionRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogRegionRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogSubjectRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/CatalogProviderMode.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarProperties.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiErrorResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiResponseMetaDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarCatalogEntityDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentEntryResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentNodeResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionCollectionResponseDto.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarSingleItemResponse.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogProvider.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogService.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/UnsupportedModeCatalogProvider.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentEntryDTO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentNodeDTO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogEntityDTO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogQuestionCollectionDTO.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerHttpTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImplTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProviderTest.java diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 9e3f22b0..8941f204 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -2,35 +2,24 @@ 教育业务模块,提供课程、练习、题库、考试等教育业务功能。 -## 当前状态:应用外壳 (Shell) +## 当前状态 -此模块目前处于**应用外壳**阶段,提供: +此模块提供教育业务功能骨架和题库目录浏览 tracer bullet。 + +**已实现**: - 模块骨架与包结构 - 能力探测端点 (`/education/capability`) - 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用 - 教育上下文端点 (`/education/context`) — 学生端已认证状态 -- 独立的功能开关配置 -- 错误码常量 -- 权限与菜单种子数据(角色授权由管理员按租户完成) -- 增量 SQL 交付约定 - -无业务表,无虚假 CRUD。 +- 题库目录端点 (见下方 Catalog API) — 学生端已认证 +- 独立的功能开关配置 + Scalar 数据源配置 +- 错误码常量(通用 + 租户 + Catalog/Scalar) +- 权限与菜单种子数据 ## 功能配置 在 `application.yaml` 或对应 profile 中配置: -```yaml -yudao: - education: - enabled: true # 是否启用教育模块,默认 false - version: 1.0.0 # 模块版本号 - hostname-tenant-map: # authority 到租户名的精确映射(可选,键在读取时统一转为小写) - "staging.school.com": "demo-school" # DNS 与 websites 不一致时使用 - login-methods: [PASSWORD, SMS] # 当前部署全局启用的 Member 登录入口 -``` - -- `yudao.education.enabled=true`:启用模块(Controller 注册、Swagger 分组可见) ## API @@ -51,7 +40,7 @@ GET /admin-api/education/capability "module": "education", "enabled": true, "version": "1.0.0", - "capabilities": ["shell"] + "capabilities": ["shell", "catalog"] } } ``` @@ -217,3 +206,94 @@ mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-rollba 1. 在 Vue3 admin 的路由中添加 `/education` 路由项,绑定 Education 菜单组件 2. 添加 `src/api/education/` API 封装层(调用上述教育端点) 3. Student Web/H5 端如需要独立入口,需新建对应前端项目 + +### 用户 APP - 题库目录(Catalog) + +所有端点需要学生登录态(Bearer Token)。`userId` 和 `tenantId` 由安全上下文派生,不接受客户端传参。 +Scalar 代理层自动注入 `x-tenant-id` header(来自 `TenantContextHolder`),前端不发起任何直达 Scalar 的请求。 + +#### 架构边界 + +``` +Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvider → [Scalar] + ↑ 内部 DTO/VO ↑ Scalar DTO 仅此层 +``` +- **业务层**(Controller/Service):仅操作内部 Catalog VO(`CatalogRegionRespVO` 等) +- **集成层**(Scalar DTO + ScalarCatalogProvider):封装 Scalar 协议差异,DTO 不泄露到上层 + +#### 端点列表 + +| 端点 | 说明 | 参数 | +|------|------|------| +| `GET /app-api/education/catalog/regions` | 查询可用地区 | 无 | +| `GET /app-api/education/catalog/categories` | 查询题目分类 | `subjectId` (可选), `nodeId` (可选) | +| `GET /app-api/education/catalog/subjects` | 查询科目目录 | `regionId`, `schoolId`, `majorId`, `moduleId`, `type` (均可选) | +| `GET /app-api/education/catalog/module-nodes` | 查询模块导航节点 | `regionId`, `moduleId`, `parentId` (均可选) | +| `GET /app-api/education/catalog/content-entries` | 查询内容入口 | `regionId`, `entryType`, `includeHidden` (均可选) | +| `GET /app-api/education/catalog/content-nodes` | 查询内容导航节点 | `entryId` (必填), `parentId`, `mode`, `includeInactive`, `markerType` (可选) | +| `GET /app-api/education/catalog/question-collections` | 查询可用题集 | `regionId`, `entryId`, `nodeId`, `collectionType`, `limit` (均可选) | + +#### 响应格式 + +所有成功响应返回 `CommonResult>`: + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + {"id": "uuid", "name": "全国", "order": 1, "active": true} + ] +} +``` + +#### 错误响应 + +| HTTP 状态 | 错误码 | 说明 | +|-----------|--------|------| +| 401 | 1_016_000_002 | 未登录或会话过期 | +| 400 | 自定义 | 请求参数不合法 | +| 500 | 1_005_002_000 | 题库数据源未启用 | +| 500 | 1_005_002_001 | 上游题库服务异常 | +| 500 | 1_005_002_002 | 上游认证失败(配置问题) | +| 403 | 1_005_002_003 | 无权限访问上游资源 | +| 404 | 1_005_002_004 | 请求的题库资源不存在 | +| 409 | 1_005_002_005 | 资源状态冲突 | +| 429 | 1_005_002_006 | 请求过于频繁 | +| 500 | 1_005_002_007 | 上游超时 | +| 500 | 1_005_002_008 | 上游返回异常:{状态码} | +| 500 | 1_005_002_009 | 不支持的题库数据源模式 | + +- **上游错误不会被转换为空列表或成功响应** — 每个上游非 2xx 均映射为明确的 `ServiceException` +- 日志记录脱敏后的端点名、tenant、上游 requestId、耗时和结果 + +#### 前端集成提示 + +前端就位后,学生端学习首页应: +1. 获取上下文(`/education/context`)确认登录态 +2. 调用 `/education/catalog/regions` 获取地区筛选器 +3. 根据地区调用 `subjects` / `categories` 获取科目分类 +4. 调用 `content-entries` → `content-nodes` 构建目录树 +5. 叶子节点调用 `question-collections` 获取题集摘要 + +**当前阻塞**:完整前端源码不存在,后端目录接口已就绪可通过 Swagger/curl 验证。 + +### 更新后的错误码 + +| 错误码 | 说明 | +|--------|------| +| 1_005_001_000 | 教育模块未启用 | +| 1_005_001_001 | 租户不存在 | +| 1_005_001_002 | 租户已被禁用 | +| 1_005_001_003 | 租户识别失败:{原因} | +| 1_005_001_004 | 当前租户不可用 | +| 1_005_002_000 | 题库数据源未启用 | +| 1_005_002_001 | 上游题库服务异常 | +| 1_005_002_002 | 上游认证失败 | +| 1_005_002_003 | 无权限访问上游资源 | +| 1_005_002_004 | 题库资源不存在 | +| 1_005_002_005 | 资源状态冲突 | +| 1_005_002_006 | 请求过于频繁 | +| 1_005_002_007 | 上游超时 | +| 1_005_002_008 | 上游返回异常:{状态码} | +| 1_005_002_009 | 不支持的题库数据源模式 | diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java index db6116a0..5aec7db2 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java @@ -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 = 租户名。 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java index c5651511..37b064d1 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java @@ -35,7 +35,7 @@ public class EducationCapabilityController { .module("education") .enabled(educationProperties.isEnabled()) .version(educationProperties.getVersion()) - .capabilities(List.of("shell")) + .capabilities(List.of("shell", "catalog")) .build(); return success(resp); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogController.java new file mode 100644 index 00000000..7f51bf8a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogController.java @@ -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 — 学生端已认证接口。 + * + *

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

+ * + * @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> listRegions() { + assertAuthenticated(); + return success(catalogService.listRegions()); + } + + @GetMapping("/categories") + @Operation(summary = "查询题目分类列表") + public CommonResult> 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> 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> 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> 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> 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> 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); + } + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogCategoryRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogCategoryRespVO.java new file mode 100644 index 00000000..9f0d219f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogCategoryRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentEntryRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentEntryRespVO.java new file mode 100644 index 00000000..b7ca53d1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentEntryRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentNodeRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentNodeRespVO.java new file mode 100644 index 00000000..aee69e91 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogContentNodeRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogModuleNodeRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogModuleNodeRespVO.java new file mode 100644 index 00000000..abe56edd --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogModuleNodeRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogQuestionCollectionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogQuestionCollectionRespVO.java new file mode 100644 index 00000000..72543e38 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogQuestionCollectionRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogRegionRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogRegionRespVO.java new file mode 100644 index 00000000..91dbfed6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogRegionRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogSubjectRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogSubjectRespVO.java new file mode 100644 index 00000000..a53bda59 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/catalog/vo/CatalogSubjectRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/CatalogProviderMode.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/CatalogProviderMode.java new file mode 100644 index 00000000..3d27fbe9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/CatalogProviderMode.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.education.enums; + +/** + * 题库目录数据源模式。 + * + *
    + *
  • {@code SCALAR_READ} — 使用 Scalar API 读取题库目录数据
  • + *
  • {@code JAVA_READ} — 使用 Java 本地数据源(预留,当前不支持)
  • + *
+ * + * @author 恭学教育 + */ +public enum CatalogProviderMode { + + SCALAR_READ, + JAVA_READ + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 6b81d30d..182937c9 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "上游题库服务不可达,请稍后重试"); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java new file mode 100644 index 00000000..84149893 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java @@ -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); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarProperties.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarProperties.java new file mode 100644 index 00000000..2bf0d681 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarProperties.java @@ -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); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiErrorResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiErrorResponseDto.java new file mode 100644 index 00000000..1c5e2272 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiErrorResponseDto.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiResponseMetaDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiResponseMetaDto.java new file mode 100644 index 00000000..7f1b1cd3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarApiResponseMetaDto.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarCatalogEntityDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarCatalogEntityDto.java new file mode 100644 index 00000000..282fc1a8 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarCatalogEntityDto.java @@ -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 metadata; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentEntryResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentEntryResponseDto.java new file mode 100644 index 00000000..13fc9765 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentEntryResponseDto.java @@ -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 accessRules; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentNodeResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentNodeResponseDto.java new file mode 100644 index 00000000..2e1420b1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarContentNodeResponseDto.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java new file mode 100644 index 00000000..247d7687 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarListResponse.java @@ -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 列表元素类型 + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalarListResponse { + + @JsonProperty("items") + private List items; + + @JsonProperty("meta") + private ScalarApiResponseMetaDto meta; + + /** + * 获取原始 items(可能为 null)。 + * 调用方负责区分 null(字段缺失)和 [](显式空列表)。 + */ + public List getItems() { + return items; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionCollectionResponseDto.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionCollectionResponseDto.java new file mode 100644 index 00000000..2118e9f4 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarQuestionCollectionResponseDto.java @@ -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 accessRules; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarSingleItemResponse.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarSingleItemResponse.java new file mode 100644 index 00000000..324c0060 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/dto/ScalarSingleItemResponse.java @@ -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 条目类型 + * @author 恭学教育 + */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalarSingleItemResponse { + + @JsonProperty("item") + private T item; + + @JsonProperty("meta") + private ScalarApiResponseMetaDto meta; + + public T getItem() { + return item; + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogProvider.java new file mode 100644 index 00000000..6b6a67ee --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogProvider.java @@ -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 listRegions(); + + /** 查询分类列表 */ + List listCategories(String subjectId, String nodeId); + + /** 查询科目列表 */ + List listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type); + + /** 查询模块导航节点 */ + List listModuleNodes(String regionId, String moduleId, String parentId); + + /** 查询内容入口 */ + List listContentEntries(String regionId, String entryType, boolean includeHidden); + + /** 查询内容导航节点 */ + List listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType); + + /** 查询题集 */ + List listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogService.java new file mode 100644 index 00000000..be7e251b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogService.java @@ -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 listRegions(); + + List listCategories(String subjectId, String nodeId); + + List listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type); + + List listModuleNodes(String regionId, String moduleId, String parentId); + + List listContentEntries(String regionId, String entryType, boolean includeHidden); + + List listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType); + + List listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit); + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java new file mode 100644 index 00000000..b5cd0de9 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImpl.java @@ -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 listRegions() { + assertEnabled(); + List 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 listCategories(String subjectId, String nodeId) { + assertEnabled(); + List 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 listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) { + assertEnabled(); + List 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 listModuleNodes(String regionId, String moduleId, String parentId) { + assertEnabled(); + List 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 listContentEntries(String regionId, String entryType, boolean includeHidden) { + assertEnabled(); + List 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 listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) { + assertEnabled(); + List 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 listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) { + assertEnabled(); + List 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(); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java new file mode 100644 index 00000000..96294096 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java @@ -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> ENTITY_LIST_TYPE = + new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> ENTRY_LIST_TYPE = + new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> NODE_LIST_TYPE = + new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> 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 listRegions() { + return mapEntities(callEntityList("/api/catalog/regions")); + } + + @Override + public List listCategories(String subjectId, String nodeId) { + String path = buildPath("/api/catalog/categories", + "subjectId", subjectId, "nodeId", nodeId); + return mapEntities(callEntityList(path)); + } + + @Override + public List 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 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 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 items = callList(builder.toUriString(), ENTRY_LIST_TYPE); + return items.stream() + .map(ScalarCatalogProvider::toContentEntryDTO) + .collect(Collectors.toList()); + } + + @Override + public List 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 items = callList(builder.toUriString(), NODE_LIST_TYPE); + return items.stream() + .map(ScalarCatalogProvider::toContentNodeDTO) + .collect(Collectors.toList()); + } + + @Override + public List 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 items = callList(builder.toUriString(), COLLECTION_LIST_TYPE); + return items.stream() + .map(ScalarCatalogProvider::toQuestionCollectionDTO) + .collect(Collectors.toList()); + } + + // ========== private helpers ========== + + private List 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 条目类型 + * @return 反序列化后的单条目(非 null) + */ + T callSingleItem(String path, ParameterizedTypeReference> typeRef) { + Instant start = Instant.now(); + String sanitizedPath = sanitizedPath(path); + Long tenantId = getTenantId(); + try { + HttpHeaders headers = buildHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity> response = restTemplate.exchange( + path, HttpMethod.GET, entity, typeRef); + + ScalarSingleItemResponse 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 List callList(String path, ParameterizedTypeReference> typeRef) { + Instant start = Instant.now(); + String sanitizedPath = sanitizedPath(path); + Long tenantId = getTenantId(); + try { + HttpHeaders headers = buildHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity> response = restTemplate.exchange( + path, HttpMethod.GET, entity, typeRef); + + ScalarListResponse 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 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 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 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 mapEntities(List 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; + } + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/UnsupportedModeCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/UnsupportedModeCatalogProvider.java new file mode 100644 index 00000000..eb112aa3 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/UnsupportedModeCatalogProvider.java @@ -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 listRegions() { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public List listCategories(String subjectId, String nodeId) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public List listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public List listModuleNodes(String regionId, String moduleId, String parentId) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public List listContentEntries(String regionId, String entryType, boolean includeHidden) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + + @Override + public List listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, this.mode.name()); + } + + @Override + public List listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) { + throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name()); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentEntryDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentEntryDTO.java new file mode 100644 index 00000000..12fb92fa --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentEntryDTO.java @@ -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 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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentNodeDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentNodeDTO.java new file mode 100644 index 00000000..9a1675f2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogContentNodeDTO.java @@ -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 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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogEntityDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogEntityDTO.java new file mode 100644 index 00000000..af3147fb --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogEntityDTO.java @@ -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 metadata; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogQuestionCollectionDTO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogQuestionCollectionDTO.java new file mode 100644 index 00000000..888e0825 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/dto/CatalogQuestionCollectionDTO.java @@ -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 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; + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerHttpTest.java new file mode 100644 index 00000000..cf9e28fe --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerHttpTest.java @@ -0,0 +1,272 @@ +package cn.iocoder.yudao.module.education.controller.app.catalog; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider; +import cn.iocoder.yudao.module.education.service.catalog.CatalogService; +import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * CatalogController HTTP seam test — uses standalone MockMvc with real controller + * wiring to prove route mapping, authentication, query param forwarding, and + * disabled-provider behavior through the HTTP layer. + * + * @author 恭学教育 + */ +class CatalogControllerHttpTest { + + private MockMvc mockMvc; + private CatalogProvider catalogProvider; + + @BeforeEach + void setUp() { + catalogProvider = mock(CatalogProvider.class); + when(catalogProvider.isEnabled()).thenReturn(true); + CatalogService catalogService = new CatalogServiceImpl(catalogProvider); + CatalogController controller = new CatalogController(); + try { + var field = CatalogController.class.getDeclaredField("catalogService"); + field.setAccessible(true); + field.set(controller, catalogService); + } catch (Exception e) { + throw new RuntimeException(e); + } + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ========== Route mapping + authenticated success ========== + + @Test + void shouldReturn200ForRegionsWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listRegions()).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + void shouldReturn200ForCategoriesWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listCategories("s1", null)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/categories") + .param("subjectId", "s1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldReturn200ForSubjectsWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listSubjects("r1", null, null, null, null)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/subjects") + .param("regionId", "r1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldReturn200ForModuleNodesWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listModuleNodes("r1", "m1", "root")) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/module-nodes") + .param("regionId", "r1") + .param("moduleId", "m1") + .param("parentId", "root")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldReturn200ForContentEntriesWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listContentEntries("r1", null, false)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/content-entries") + .param("regionId", "r1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldReturn200ForContentNodesWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listContentNodes("e1", null, "children", false, null)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/content-nodes") + .param("entryId", "e1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldReturn200ForQuestionCollectionsWhenAuthenticated() throws Exception { + setLoginUser(100L); + when(catalogProvider.listQuestionCollections("r1", null, null, null, null)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/question-collections") + .param("regionId", "r1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + // ========== Anonymous → 401 ========== + + @Test + void shouldReturn401WhenNoSecurityContext() throws Exception { + mockMvc.perform(get("/education/catalog/regions")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401OnCategoriesWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/catalog/categories") + .param("subjectId", "s1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401OnSubjectsWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/catalog/subjects") + .param("regionId", "r1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + @Test + void shouldReturn401OnContentEntriesWhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/education/catalog/content-entries") + .param("regionId", "r1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode())); + } + + // ========== Query parameter forwarding ========== + + @Test + void shouldForwardIncludeHiddenTrue() throws Exception { + setLoginUser(100L); + when(catalogProvider.listContentEntries("r1", "question_bank", true)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/content-entries") + .param("regionId", "r1") + .param("entryType", "question_bank") + .param("includeHidden", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldForwardIncludeInactiveTrueAndMarkerType() throws Exception { + setLoginUser(100L); + when(catalogProvider.listContentNodes("e1", "root", "flat", true, "marker_type")) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/content-nodes") + .param("entryId", "e1") + .param("parentId", "root") + .param("mode", "flat") + .param("includeInactive", "true") + .param("markerType", "marker_type")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void shouldForwardLimitParameter() throws Exception { + setLoginUser(100L); + when(catalogProvider.listQuestionCollections("r1", "e1", "n1", "exam", 10)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/education/catalog/question-collections") + .param("regionId", "r1") + .param("entryId", "e1") + .param("nodeId", "n1") + .param("collectionType", "exam") + .param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + // ========== Feature-disabled provider ========== + + @Test + void shouldReturnErrorWhenProviderDisabled() throws Exception { + setLoginUser(100L); + when(catalogProvider.isEnabled()).thenReturn(false); + + mockMvc.perform(get("/education/catalog/regions")) + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(CATALOG_DATA_SOURCE_DISABLED.getCode())); + } + + // ========== helpers ========== + + private void setLoginUser(Long userId) { + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setTenantId(1L); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + /** + * Minimal exception handler for standalone MockMvc. + * Maps ServiceException to proper HTTP status + CommonResult body. + */ + @RestControllerAdvice + static class TestExceptionHandler { + + @ExceptionHandler(ServiceException.class) + public CommonResult handleServiceException(ServiceException ex, + jakarta.servlet.http.HttpServletResponse response) { + if (ex.getCode() == UNAUTHORIZED.getCode()) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return CommonResult.error(UNAUTHORIZED); + } + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + return CommonResult.error(ex.getCode(), ex.getMessage()); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerTest.java new file mode 100644 index 00000000..b1cc8698 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/catalog/CatalogControllerTest.java @@ -0,0 +1,224 @@ +package cn.iocoder.yudao.module.education.controller.app.catalog; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider; +import cn.iocoder.yudao.module.education.service.catalog.CatalogService; +import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * CatalogController 集成测试。 + * 直接调用 Controller 方法并验证认证、参数转发、功能开关行为。 + * + * @author 恭学教育 + */ +@SpringBootTest( + classes = { + CatalogController.class, + CatalogServiceImpl.class + }, + properties = { + "yudao.education.enabled=true", + "yudao.education.version=1.0.0-test" + }, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("unit-test") +class CatalogControllerTest { + + @Resource + private CatalogController catalogController; + + @MockitoBean + private CatalogProvider catalogProvider; + + @BeforeEach + void setUp() { + when(catalogProvider.isEnabled()).thenReturn(true); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ========== 认证成功 ========== + + @Test + void shouldReturnRegionsWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listRegions()).thenReturn(Collections.emptyList()); + + var result = catalogController.listRegions(); + assertNotNull(result); + assertEquals(0, result.getCode()); + assertNotNull(result.getData()); + } + + @Test + void shouldReturnCategoriesWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listCategories("s1", null)).thenReturn(Collections.emptyList()); + + var result = catalogController.listCategories("s1", null); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + @Test + void shouldReturnSubjectsWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listSubjects("r1", null, null, null, null)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listSubjects("r1", null, null, null, null); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + @Test + void shouldReturnModuleNodesWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listModuleNodes("r1", "m1", "root")) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listModuleNodes("r1", "m1", "root"); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + @Test + void shouldReturnContentEntriesWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listContentEntries("r1", null, false)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listContentEntries("r1", null, false); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + @Test + void shouldReturnContentNodesWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listContentNodes("e1", null, "children", false, null)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listContentNodes("e1", null, "children", false, null); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + @Test + void shouldReturnQuestionCollectionsWhenAuthenticated() { + setLoginUser(100L); + when(catalogProvider.listQuestionCollections("r1", null, null, null, null)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listQuestionCollections("r1", null, null, null, null); + assertNotNull(result); + assertEquals(0, result.getCode()); + } + + // ========== 未认证拒绝 ========== + + @Test + void shouldRejectWhenNotAuthenticated() { + // No security context — should throw UNAUTHORIZED + var ex = assertThrows(ServiceException.class, + () -> catalogController.listRegions()); + assertEquals(401, ex.getCode()); + } + + @Test + void shouldRejectCategoriesWhenNotAuthenticated() { + var ex = assertThrows(ServiceException.class, + () -> catalogController.listCategories(null, null)); + assertEquals(401, ex.getCode()); + } + + @Test + void shouldRejectAllEndpointsWhenNotAuthenticated() { + assertThrows(ServiceException.class, () -> catalogController.listSubjects(null, null, null, null, null)); + assertThrows(ServiceException.class, () -> catalogController.listModuleNodes(null, null, null)); + assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null, false)); + assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", false, null)); + assertThrows(ServiceException.class, () -> catalogController.listQuestionCollections(null, null, null, null, null)); + } + + // ========== 参数转发 ========== + + @Test + void shouldForwardIncludeHiddenTrue() { + setLoginUser(100L); + when(catalogProvider.listContentEntries("r1", null, true)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listContentEntries("r1", null, true); + assertEquals(0, result.getCode()); + } + + @Test + void shouldForwardIncludeHiddenFalse() { + setLoginUser(100L); + when(catalogProvider.listContentEntries("r1", null, false)) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listContentEntries("r1", null, false); + assertEquals(0, result.getCode()); + } + + @Test + void shouldForwardIncludeInactiveTrue() { + setLoginUser(100L); + when(catalogProvider.listContentNodes("e1", null, "flat", true, "marker")) + .thenReturn(Collections.emptyList()); + + var result = catalogController.listContentNodes("e1", null, "flat", true, "marker"); + assertEquals(0, result.getCode()); + } + + // ========== 数据源禁用 ========== + + @Test + void shouldReturnErrorWhenProviderDisabled() { + setLoginUser(100L); + when(catalogProvider.isEnabled()).thenReturn(false); + + var ex = assertThrows(ServiceException.class, + () -> catalogController.listRegions()); + assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode()); + } + + // ========== 功能开关 ========== + + @Test + void shouldRegisterControllerWhenEnabled() { + assertNotNull(catalogController, "Controller should be registered when education.enabled=true"); + } + + // ========== helpers ========== + + private void setLoginUser(Long userId) { + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setTenantId(1L); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java new file mode 100644 index 00000000..31def517 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfigurationTest.java @@ -0,0 +1,142 @@ +package cn.iocoder.yudao.module.education.integration.scalar.config; + +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.enums.ErrorCodeConstants; +import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider; +import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider; +import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + * ScalarAutoConfiguration 启动上下文测试。 + * 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景。 + * + * @author 恭学教育 + */ +class ScalarAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(ScalarAutoConfiguration.class) + .withBean(EducationProperties.class, EducationProperties::new); + + // ========== 默认 / 未启用 ========== + + @Test + void shouldNotCreateProviderWhenEducationDisabled() { + // Default: yudao.education.enabled=false — no beans created + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(CatalogProvider.class); + }); + } + + // ========== SCALAR_READ enabled + configured ========== + + @Test + void shouldCreateScalarProviderWhenEnabledAndConfigured() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=SCALAR_READ", + "yudao.education.scalar.enabled=true", + "yudao.education.scalar.base-url=http://localhost", + "yudao.education.scalar.token=test-token") + .run(context -> { + assertThat(context).hasSingleBean(CatalogProvider.class); + assertThat(context).hasSingleBean(ScalarCatalogProvider.class); + CatalogProvider provider = context.getBean(CatalogProvider.class); + assertTrue(provider.isEnabled()); + }); + } + + // ========== SCALAR_READ disabled (但 provider 仍创建) ========== + + @Test + void shouldCreateScalarProviderWhenModeScalarReadButScalarDisabled() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=SCALAR_READ") + .run(context -> { + assertThat(context).hasSingleBean(CatalogProvider.class); + CatalogProvider provider = context.getBean(CatalogProvider.class); + // Scalar disabled by default — provider exists but reports disabled + assertFalse(provider.isEnabled()); + }); + } + + // ========== SCALAR_READ enabled but missing config → startup fails ========== + @Test + void shouldFailStartupWhenScalarEnabledButMissingBaseUrl() { + // Direct constructor test — covered by ScalarCatalogProviderTest + // ApplicationContextRunner catches the factory method exception + // but in Spring 4.x the behavior differs; direct test is more reliable. + cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties bad = + new cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties(); + bad.setEnabled(true); + bad.setBaseUrl(null); + bad.setToken("test-token"); + + var ex = assertThrows(cn.iocoder.yudao.framework.common.exception.ServiceException.class, + () -> new cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider(bad)); + assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_SCALAR_NOT_CONFIGURED.getCode(), + ex.getCode()); + } + + @Test + void shouldFailStartupWhenScalarEnabledButMissingToken() { + cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties bad = + new cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties(); + bad.setEnabled(true); + bad.setBaseUrl("http://localhost"); + bad.setToken(null); + + var ex = assertThrows(cn.iocoder.yudao.framework.common.exception.ServiceException.class, + () -> new cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider(bad)); + assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_SCALAR_NOT_CONFIGURED.getCode(), + ex.getCode()); + } + + // ========== JAVA_READ (unsupported) ========== + + @Test + void shouldCreateUnsupportedProviderForJavaReadMode() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=JAVA_READ") + .run(context -> { + assertThat(context).hasSingleBean(CatalogProvider.class); + CatalogProvider provider = context.getBean(CatalogProvider.class); + assertThat(provider).isInstanceOf(UnsupportedModeCatalogProvider.class); + assertFalse(provider.isEnabled()); + }); + } + + @Test + void shouldNotCreateScalarProviderWhenJavaReadMode() { + contextRunner + .withPropertyValues( + "yudao.education.enabled=true", + "yudao.education.catalog-mode=JAVA_READ") + .run(context -> { + assertThat(context).doesNotHaveBean(ScalarCatalogProvider.class); + }); + } + + // ========== Default: SCALAR_READ (matchIfMissing) ========== + + @Test + void shouldDefaultToScalarReadWhenNoCatalogMode() { + contextRunner + .withPropertyValues("yudao.education.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(CatalogProvider.class); + assertThat(context).hasSingleBean(ScalarCatalogProvider.class); + }); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImplTest.java new file mode 100644 index 00000000..f2dc9339 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImplTest.java @@ -0,0 +1,262 @@ +package cn.iocoder.yudao.module.education.service.catalog; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * CatalogServiceImpl 单元测试 — 测试转换逻辑和功能开关。 + * 使用领域 DTO,不依赖 integration 层。 + * + * @author 恭学教育 + */ +@ExtendWith(MockitoExtension.class) +class CatalogServiceImplTest { + + @Mock + private CatalogProvider catalogProvider; + + private CatalogServiceImpl catalogService; + + @BeforeEach + void setUp() { + catalogService = new CatalogServiceImpl(catalogProvider); + when(catalogProvider.isEnabled()).thenReturn(true); + } + + // ========== Region ========== + + @Test + void shouldConvertRegions() { + when(catalogProvider.listRegions()).thenReturn(List.of( + regionDto("r1", "全国", true, 1.0), + regionDto("r2", "华东", true, 2.0))); + + List result = catalogService.listRegions(); + + assertEquals(2, result.size()); + assertEquals("r1", result.get(0).getId()); + assertEquals("全国", result.get(0).getName()); + assertEquals(1.0, result.get(0).getOrder()); + assertTrue(result.get(0).getActive()); + + assertEquals("r2", result.get(1).getId()); + } + + @Test + void shouldFilterInactiveRegions() { + when(catalogProvider.listRegions()).thenReturn(List.of( + regionDto("r1", "全国", true, 1.0), + regionDto("r2", "华东", false, 2.0), + regionDto("r3", "华南", null, 3.0))); + + List result = catalogService.listRegions(); + + assertEquals(2, result.size()); + assertEquals("r1", result.get(0).getId()); + assertEquals("r3", result.get(1).getId()); // null isActive treated as active + } + + @Test + void shouldReturnEmptyListWhenProviderReturnsNull() { + when(catalogProvider.listRegions()).thenReturn(null); + + List result = catalogService.listRegions(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenProviderReturnsEmpty() { + when(catalogProvider.listRegions()).thenReturn(List.of()); + + List result = catalogService.listRegions(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // ========== Category ========== + + @Test + void shouldConvertCategories() { + when(catalogProvider.listCategories("s1", "n1")).thenReturn(List.of( + categoryDto("c1", "数学", "subject", true, 1.0))); + + List result = catalogService.listCategories("s1", "n1"); + + assertEquals(1, result.size()); + assertEquals("c1", result.get(0).getId()); + assertEquals("数学", result.get(0).getName()); + assertEquals("subject", result.get(0).getType()); + } + + // ========== Subject ========== + + @Test + void shouldConvertSubjects() { + when(catalogProvider.listSubjects("r1", null, null, null, null)).thenReturn(List.of( + subjectDto("s1", "数学", "math", "r1", true))); + + List result = catalogService.listSubjects("r1", null, null, null, null); + + assertEquals(1, result.size()); + assertEquals("s1", result.get(0).getId()); + assertEquals("数学", result.get(0).getName()); + assertEquals("math", result.get(0).getType()); + assertEquals("r1", result.get(0).getRegionId()); + } + + // ========== ContentNode ========== + + @Test + void shouldConvertContentNodes() { + CatalogContentNodeDTO node = CatalogContentNodeDTO.contentNodeBuilder() + .id("n1").name("节点1").nodeType("chapter") + .entryId("e1").parentId("root") + .depth(1.0).isLeaf(false).isSelectable(true) + .order(1.0).isActive(true) + .build(); + + when(catalogProvider.listContentNodes("e1", null, "children", false, null)) + .thenReturn(List.of(node)); + + List result = catalogService.listContentNodes("e1", null, "children", false, null); + + assertEquals(1, result.size()); + CatalogContentNodeRespVO vo = result.get(0); + assertEquals("n1", vo.getId()); + assertEquals("节点1", vo.getName()); + assertEquals("chapter", vo.getNodeType()); + assertEquals("e1", vo.getEntryId()); + assertEquals("root", vo.getParentId()); + assertEquals(1.0, vo.getDepth()); + assertFalse(vo.getLeaf()); + assertTrue(vo.getSelectable()); + } + + @Test + void shouldFilterInactiveContentNodesWhenIncludeInactiveIsFalse() { + CatalogContentNodeDTO active = CatalogContentNodeDTO.contentNodeBuilder() + .id("n1").name("active").nodeType("chapter") + .entryId("e1").parentId("root") + .depth(1.0).isLeaf(false).isSelectable(true) + .order(1.0).isActive(true) + .build(); + CatalogContentNodeDTO inactive = CatalogContentNodeDTO.contentNodeBuilder() + .id("n2").name("inactive").nodeType("chapter") + .entryId("e1").parentId("root") + .depth(2.0).isLeaf(true).isSelectable(false) + .order(2.0).isActive(false) + .build(); + + when(catalogProvider.listContentNodes("e1", null, "children", false, null)) + .thenReturn(List.of(active, inactive)); + + List result = catalogService.listContentNodes("e1", null, "children", false, null); + + assertEquals(1, result.size()); + assertEquals("n1", result.get(0).getId()); + } + + @Test + void shouldIncludeInactiveContentNodesWhenIncludeInactiveIsTrue() { + CatalogContentNodeDTO inactive = CatalogContentNodeDTO.contentNodeBuilder() + .id("n2").name("inactive").nodeType("chapter") + .entryId("e1").parentId("root") + .depth(2.0).isLeaf(true).isSelectable(false) + .order(2.0).isActive(false) + .build(); + + when(catalogProvider.listContentNodes("e1", null, "children", true, null)) + .thenReturn(List.of(inactive)); + + List result = catalogService.listContentNodes("e1", null, "children", true, null); + + assertEquals(1, result.size()); + assertEquals("n2", result.get(0).getId()); + } + + // ========== QuestionCollection ========== + + @Test + void shouldConvertQuestionCollections() { + CatalogQuestionCollectionDTO col = CatalogQuestionCollectionDTO.questionCollectionBuilder() + .id("qc1").name("高考真题").collectionType("exam") + .questionCount(100.0).order(1.0).isActive(true) + .build(); + + when(catalogProvider.listQuestionCollections("r1", null, null, null, null)) + .thenReturn(List.of(col)); + + List result = catalogService.listQuestionCollections("r1", null, null, null, null); + + assertEquals(1, result.size()); + CatalogQuestionCollectionRespVO vo = result.get(0); + assertEquals("qc1", vo.getId()); + assertEquals("高考真题", vo.getName()); + assertEquals("exam", vo.getCollectionType()); + assertEquals(100L, vo.getQuestionCount()); + } + + @Test + void shouldHandleNullQuestionCount() { + CatalogQuestionCollectionDTO col = CatalogQuestionCollectionDTO.questionCollectionBuilder() + .id("qc1").name("题库").collectionType("bank") + .questionCount(null).order(1.0).isActive(true) + .build(); + + when(catalogProvider.listQuestionCollections("r1", null, null, null, null)) + .thenReturn(List.of(col)); + + List result = catalogService.listQuestionCollections("r1", null, null, null, null); + + assertEquals(1, result.size()); + assertNull(result.get(0).getQuestionCount()); + } + + // ========== 功能开关 ========== + + @Test + void shouldThrowWhenProviderIsDisabled() { + when(catalogProvider.isEnabled()).thenReturn(false); + + var ex = assertThrows(ServiceException.class, () -> catalogService.listRegions()); + assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode()); + } + + // ========== helpers ========== + + private static CatalogEntityDTO regionDto(String id, String name, Boolean active, Double order) { + return CatalogEntityDTO.builder() + .id(id).name(name).isActive(active).order(order) + .build(); + } + + private static CatalogEntityDTO categoryDto(String id, String name, String type, Boolean active, Double order) { + return CatalogEntityDTO.builder() + .id(id).name(name).type(type).isActive(active).order(order) + .build(); + } + + private static CatalogEntityDTO subjectDto(String id, String name, String type, String regionId, Boolean active) { + return CatalogEntityDTO.builder() + .id(id).name(name).type(type).regionId(regionId).isActive(active) + .build(); + } + +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProviderTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProviderTest.java new file mode 100644 index 00000000..d6ec4a0b --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProviderTest.java @@ -0,0 +1,577 @@ +package cn.iocoder.yudao.module.education.service.catalog; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties; +import cn.iocoder.yudao.module.education.service.catalog.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 cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarCatalogEntityDto; +import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarSingleItemResponse; +import org.springframework.core.ParameterizedTypeReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.List; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * ScalarCatalogProvider 契约测试 — 使用 MockRestServiceServer 模拟 Scalar API 行为。 + * + * 覆盖:正常列表、空数据、null body、null items、400→BAD_REQUEST、401/403/404/409/429、 + * 5xx、超时、URI 编码(空格/非ASCII/&/=/特殊字符)、租户上下文、功能开关、配置验证。 + * + * @author 恭学教育 + */ +class ScalarCatalogProviderTest { + + private ScalarCatalogProvider provider; + private MockRestServiceServer mockServer; + private RestTemplate restTemplate; + private ScalarProperties scalarProperties; + + @BeforeEach + void setUp() { + scalarProperties = new ScalarProperties(); + scalarProperties.setEnabled(true); + scalarProperties.setBaseUrl("http://mock-scalar"); + scalarProperties.setToken("test-token"); + scalarProperties.setConnectTimeout(Duration.ofSeconds(1)); + scalarProperties.setReadTimeout(Duration.ofSeconds(2)); + + provider = new ScalarCatalogProvider(scalarProperties); + + try { + var field = ScalarCatalogProvider.class.getDeclaredField("restTemplate"); + field.setAccessible(true); + restTemplate = (RestTemplate) field.get(provider); + mockServer = MockRestServiceServer.createServer(restTemplate); + } catch (Exception e) { + throw new RuntimeException("Failed to access RestTemplate", e); + } + + TenantContextHolder.setTenantId(2048L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + if (mockServer != null) { + mockServer.verify(); + } + } + + // ========== 正常场景 ========== + + @Test + void shouldReturnRegionsWhenScalarReturnsItems() { + String responseBody = """ + { + "items": [ + {"id": "r1", "name": "全国", "order": 1, "isActive": true}, + {"id": "r2", "name": "华东", "order": 2, "isActive": true} + ], + "meta": {"requestId": "req-123"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + assertEquals("/api/catalog/regions", request.getURI().getPath()); + assertEquals("Bearer test-token", request.getHeaders().getFirst("Authorization")); + assertEquals("2048", request.getHeaders().getFirst("x-tenant-id")); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + List regions = provider.listRegions(); + + assertNotNull(regions); + assertEquals(2, regions.size()); + assertEquals("r1", regions.get(0).getId()); + assertEquals("全国", regions.get(0).getName()); + assertTrue(regions.get(0).getIsActive()); + } + + @Test + void shouldReturnEmptyListWhenScalarReturnsExplicitEmptyItems() { + String responseBody = """ + { + "items": [], + "meta": {"requestId": "req-empty"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + List regions = provider.listRegions(); + assertNotNull(regions); + assertTrue(regions.isEmpty()); + } + + @Test + void shouldThrowMalformedOnNullBody() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess()); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowMalformedOnMissingItemsField() { + String responseBody = """ + { + "meta": {"requestId": "req-no-items"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldMapToDomainDTOs() { + String responseBody = """ + { + "items": [ + {"id": "r1", "name": "全国", "title": "全国范围", "type": "region", "order": 1, "isActive": true} + ], + "meta": {"requestId": "req-map"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + List regions = provider.listRegions(); + assertEquals(1, regions.size()); + CatalogEntityDTO dto = regions.get(0); + assertEquals("r1", dto.getId()); + assertEquals("全国", dto.getName()); + assertEquals("全国范围", dto.getTitle()); + assertEquals("region", dto.getType()); + assertTrue(dto.getIsActive()); + } + + @Test + void shouldMapContentEntriesToDomainDTOs() { + String responseBody = """ + { + "items": [ + {"id": "e1", "name": "高考数学", "entryKey": "gaokao-math", "entryType": "question_bank", "isActive": true} + ], + "meta": {"requestId": "req-entry"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> { + assertEquals("/api/catalog/content-entries", request.getURI().getPath()); + String query = request.getURI().getQuery(); + assertNotNull(query); + assertTrue(query.contains("regionId=r1")); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + List entries = provider.listContentEntries("r1", null, false); + assertEquals(1, entries.size()); + assertEquals("e1", entries.get(0).getId()); + assertEquals("高考数学", entries.get(0).getName()); + assertEquals("gaokao-math", entries.get(0).getEntryKey()); + assertEquals("question_bank", entries.get(0).getEntryType()); + } + + // ========== URI 编码测试 ========== + + @Test + void shouldEncodeSpacesInQueryParams() { + String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-space\"}}"; + + mockServer.expect(ExpectedCount.once(), + request -> { + assertEquals("/api/catalog/categories", request.getURI().getPath()); + String query = request.getURI().getQuery(); + assertNotNull(query); + // UriComponentsBuilder encodes spaces as %20 + assertTrue(query.contains("subjectId=hello%20world"), + "Expected encoded space, got: " + query); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + provider.listCategories("hello world", null); + } + + @Test + void shouldEncodeNonAsciiCharacters() { + String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-utf8\"}}"; + + mockServer.expect(ExpectedCount.once(), + request -> { + assertEquals("/api/catalog/categories", request.getURI().getPath()); + String query = request.getURI().getQuery(); + assertNotNull(query); + // "数学" → %E6%95%B0%E5%AD%A6 + assertTrue(query.contains("%E6%95%B0%E5%AD%A6") || query.contains("数学"), + "Expected encoded non-ASCII, got: " + query); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + provider.listCategories("数学", null); + } + + @Test + void shouldEncodeAmpersandAndEquals() { + String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-special\"}}"; + + mockServer.expect(ExpectedCount.once(), + request -> { + assertEquals("/api/catalog/categories", request.getURI().getPath()); + String query = request.getURI().getQuery(); + assertNotNull(query); + // & → %26, = → %3D + assertTrue(query.contains("%26") || query.contains("%3D"), + "Expected encoded special chars, got: " + query); + }) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + provider.listCategories("a&b=c", null); + } + + // ========== 错误场景 ========== + + @Test + void shouldThrowBadRequestOn400() { + String errorBody = """ + {"error": "regionId 格式无效", "code": "INVALID_PARAM", "requestId": "req-400", "meta": {"requestId": "req-400"}}"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withBadRequest().body(errorBody).contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + // 400 uses GlobalErrorCodeConstants.BAD_REQUEST + assertEquals(400, ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamAuthFailedOn401() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.UNAUTHORIZED) + .body("{\"error\":\"unauthorized\",\"code\":\"UNAUTHORIZED\",\"requestId\":\"req-401\",\"meta\":{\"requestId\":\"req-401\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_AUTH_FAILED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamForbiddenOn403() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.FORBIDDEN) + .body("{\"error\":\"forbidden\",\"code\":\"FORBIDDEN\",\"requestId\":\"req-403\",\"meta\":{\"requestId\":\"req-403\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_FORBIDDEN.getCode(), ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamNotFoundOn404() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.NOT_FOUND) + .body("{\"error\":\"not found\",\"code\":\"NOT_FOUND\",\"requestId\":\"req-404\",\"meta\":{\"requestId\":\"req-404\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_NOT_FOUND.getCode(), ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamConflictOn409() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.CONFLICT) + .body("{\"error\":\"conflict\",\"code\":\"CONFLICT\",\"requestId\":\"req-409\",\"meta\":{\"requestId\":\"req-409\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_CONFLICT.getCode(), ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamTooManyRequestsOn429() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.TOO_MANY_REQUESTS) + .body("{\"error\":\"rate limited\",\"code\":\"RATE_LIMITED\",\"requestId\":\"req-429\",\"meta\":{\"requestId\":\"req-429\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_TOO_MANY_REQUESTS.getCode(), ex.getCode()); + } + + @Test + void shouldThrowCatalogUpstreamErrorOn5xx() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .body("{\"error\":\"internal\",\"code\":\"INTERNAL\",\"requestId\":\"req-500\",\"meta\":{\"requestId\":\"req-500\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_ERROR.getCode(), ex.getCode()); + } + + @Test + void shouldThrowUnavailableOnConnectionRefused() { + ScalarProperties unavailableProps = new ScalarProperties(); + unavailableProps.setEnabled(true); + unavailableProps.setBaseUrl("http://localhost:1"); + unavailableProps.setToken("test-token"); + unavailableProps.setConnectTimeout(Duration.ofMillis(10)); + unavailableProps.setReadTimeout(Duration.ofMillis(10)); + + var unavailableProvider = new ScalarCatalogProvider(unavailableProps); + var ex = assertThrows(ServiceException.class, + () -> unavailableProvider.listRegions()); + assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode()); + } + + // ========== 租户上下文 ========== + + @Test + void shouldDeriveTenantIdFromTenantContextHolder() { + TenantContextHolder.setTenantId(9999L); + + String responseBody = """ + { + "items": [{"id": "r1", "name": "全国", "isActive": true}], + "meta": {"requestId": "req-tenant"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("9999", request.getHeaders().getFirst("x-tenant-id"))) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + List regions = provider.listRegions(); + assertEquals(1, regions.size()); + } + + // ========== 功能开关 & 配置 ========== + + @Test + void shouldReportEnabledStatus() { + assertTrue(provider.isEnabled()); + } + + @Test + void shouldReportDisabledWhenScalarDisabled() { + ScalarProperties disabled = new ScalarProperties(); + disabled.setEnabled(false); + var p = new ScalarCatalogProvider(disabled); + assertFalse(p.isEnabled()); + } + + @Test + void shouldThrowOnConstructionWhenEnabledButMissingBaseUrl() { + ScalarProperties bad = new ScalarProperties(); + bad.setEnabled(true); + bad.setBaseUrl(null); + bad.setToken("token"); + + var ex = assertThrows(ServiceException.class, () -> new ScalarCatalogProvider(bad)); + assertEquals(CATALOG_SCALAR_NOT_CONFIGURED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowOnConstructionWhenEnabledButMissingToken() { + ScalarProperties bad = new ScalarProperties(); + bad.setEnabled(true); + bad.setBaseUrl("http://localhost"); + bad.setToken(null); + + var ex = assertThrows(ServiceException.class, () -> new ScalarCatalogProvider(bad)); + assertEquals(CATALOG_SCALAR_NOT_CONFIGURED.getCode(), ex.getCode()); + } + + @Test + void shouldNotThrowOnConstructionWhenDisabledAndMissingConfig() { + ScalarProperties disabled = new ScalarProperties(); + disabled.setEnabled(false); + disabled.setBaseUrl(null); + disabled.setToken(null); + + // Should not throw — validation only when enabled + var p = new ScalarCatalogProvider(disabled); + assertFalse(p.isEnabled()); + } + + // ========== Fix 1: Singular item envelope ("item") ========== + + @Test + void shouldParseSingularItemEnvelope() { + String responseBody = """ + { + "item": {"id": "r-single", "name": "单独条目", "order": 1, "isActive": true}, + "meta": {"requestId": "req-single"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var typeRef = new ParameterizedTypeReference>() {}; + var item = provider.callSingleItem("/api/catalog/regions/99", typeRef); + + assertNotNull(item); + assertEquals("r-single", item.getId()); + assertEquals("单独条目", item.getName()); + } + + @Test + void shouldThrowMalformedOnNullItemInSingularEnvelope() { + String responseBody = """ + { + "meta": {"requestId": "req-no-item"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var typeRef = new ParameterizedTypeReference>() {}; + var ex = assertThrows(ServiceException.class, + () -> provider.callSingleItem("/api/catalog/regions/99", typeRef)); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowMalformedOnNullBodyInSingularEnvelope() { + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess()); + + var typeRef = new ParameterizedTypeReference>() {}; + var ex = assertThrows(ServiceException.class, + () -> provider.callSingleItem("/api/catalog/regions/99", typeRef)); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + // ========== Fix 2: Malformed JSON / type mismatch → CATALOG_UPSTREAM_MALFORMED ========== + + @Test + void shouldThrowMalformedOnInvalidJson() { + String responseBody = "this is not json at all {{{"; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowMalformedOnTypeMismatch() { + // items contains objects missing required fields → RestClientException when mapping + String responseBody = """ + { + "items": "not_an_array_but_a_string", + "meta": {"requestId": "req-type"} + }"""; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + @Test + void shouldThrowMalformedOnEmptyJsonObject() { + String responseBody = "{}"; + + mockServer.expect(ExpectedCount.once(), + request -> assertEquals("/api/catalog/regions", request.getURI().getPath())) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var ex = assertThrows(ServiceException.class, () -> provider.listRegions()); + assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode()); + } + + + @Test + void shouldThrowTimeoutOnReadTimeout() throws Exception { + // Use a delayed local HTTP server that accepts but never sends a response + var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); + server.createContext("/api/catalog/regions", exchange -> { + // Accept the connection and headers but delay past read timeout + try { + Thread.sleep(500); // longer than readTimeout below + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + exchange.close(); + }); + server.start(); + int port = server.getAddress().getPort(); + + try { + ScalarProperties slowReadProps = new ScalarProperties(); + slowReadProps.setEnabled(true); + slowReadProps.setBaseUrl("http://localhost:" + port); + slowReadProps.setToken("test-token"); + slowReadProps.setConnectTimeout(Duration.ofSeconds(5)); + slowReadProps.setReadTimeout(Duration.ofMillis(100)); + + var timeoutProvider = new ScalarCatalogProvider(slowReadProps); + var ex = assertThrows(ServiceException.class, + () -> timeoutProvider.listRegions()); + assertEquals(CATALOG_UPSTREAM_TIMEOUT.getCode(), ex.getCode()); + } finally { + server.stop(0); + } + } + +}