feat(education): add scalar catalog adapter

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

View File

@@ -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<List<T>>`
```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 | 不支持的题库数据源模式 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.education.integration.scalar.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Scalar API 错误响应 — 仅用于反序列化 Scalar 错误体。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarApiErrorResponseDto {
@JsonProperty("error")
private String error;
@JsonProperty("code")
private String code;
@JsonProperty("requestId")
private String requestId;
@JsonProperty("meta")
private ScalarApiResponseMetaDto meta;
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.integration.scalar.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Scalar API 通用响应元数据 — 仅用于反序列化 Scalar 响应。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarApiResponseMetaDto {
@JsonProperty("requestId")
private String requestId;
}

View File

@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.education.integration.scalar.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Scalar 通用目录实体 — 用于 region/category/subject/module-node 等端点。
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
*
* @author 恭学教育
*/
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScalarCatalogEntityDto {
@JsonProperty("id")
private String id;
@JsonProperty("legacyId")
private Object legacyId;
@JsonProperty("name")
private String name;
@JsonProperty("title")
private String title;
@JsonProperty("type")
private String type;
@JsonProperty("regionId")
private Object regionId;
@JsonProperty("order")
private Double order;
@JsonProperty("isActive")
private Boolean isActive;
@JsonProperty("description")
private Object description;
@JsonProperty("metadata")
private Map<String, Object> metadata;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_PROVIDER_MODE_INVALID;
/**
* 不支持的目录数据源模式占位提供者。
* 所有业务方法直接抛出 CATALOG_PROVIDER_MODE_INVALID。
*
* @author 恭学教育
*/
public class UnsupportedModeCatalogProvider implements CatalogProvider {
private final CatalogProviderMode mode;
public UnsupportedModeCatalogProvider(CatalogProviderMode mode) {
this.mode = mode;
}
@Override
public boolean isEnabled() {
return false;
}
@Override
public List<CatalogEntityDTO> listRegions() {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
@Override
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, this.mode.name());
}
@Override
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
}
}

View File

@@ -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<CatalogRegionRespVO> 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<CatalogRegionRespVO> 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<CatalogRegionRespVO> result = catalogService.listRegions();
assertNotNull(result);
assertTrue(result.isEmpty());
}
@Test
void shouldReturnEmptyListWhenProviderReturnsEmpty() {
when(catalogProvider.listRegions()).thenReturn(List.of());
List<CatalogRegionRespVO> 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<CatalogCategoryRespVO> 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<CatalogSubjectRespVO> 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<CatalogContentNodeRespVO> 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<CatalogContentNodeRespVO> 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<CatalogContentNodeRespVO> 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<CatalogQuestionCollectionRespVO> 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<CatalogQuestionCollectionRespVO> 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();
}
}

View File

@@ -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<CatalogEntityDTO> 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<CatalogEntityDTO> 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<CatalogEntityDTO> 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<CatalogContentEntryDTO> 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<CatalogEntityDTO> 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<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
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<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
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<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
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);
}
}
}