feat(education): add scalar catalog adapter
This commit is contained in:
@@ -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 | 不支持的题库数据源模式 |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.education.config;
|
||||
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -28,6 +29,12 @@ public class EducationProperties {
|
||||
*/
|
||||
private String version = "1.0.0";
|
||||
|
||||
/**
|
||||
* 题库目录数据源模式。
|
||||
* 默认 SCALAR_READ;JAVA_READ 为预留模式(当前不支持)。
|
||||
*/
|
||||
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
|
||||
|
||||
/**
|
||||
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
|
||||
* key = 标准化后的主机名(小写、无端口),value = 租户名。
|
||||
|
||||
@@ -35,7 +35,7 @@ public class EducationCapabilityController {
|
||||
.module("education")
|
||||
.enabled(educationProperties.isEnabled())
|
||||
.version(educationProperties.getVersion())
|
||||
.capabilities(List.of("shell"))
|
||||
.capabilities(List.of("shell", "catalog"))
|
||||
.build();
|
||||
return success(resp);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 题库目录 Controller — 学生端已认证接口。
|
||||
*
|
||||
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Tag(name = "用户 APP - 题库目录")
|
||||
@RestController
|
||||
@RequestMapping("/education/catalog")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class CatalogController {
|
||||
|
||||
@Resource
|
||||
private CatalogService catalogService;
|
||||
|
||||
@GetMapping("/regions")
|
||||
@Operation(summary = "查询可用地区列表")
|
||||
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listRegions());
|
||||
}
|
||||
|
||||
@GetMapping("/categories")
|
||||
@Operation(summary = "查询题目分类列表")
|
||||
public CommonResult<List<CatalogCategoryRespVO>> listCategories(
|
||||
@Parameter(description = "科目 ID") @RequestParam(required = false) String subjectId,
|
||||
@Parameter(description = "旧导航节点 ID") @RequestParam(required = false) String nodeId) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listCategories(subjectId, nodeId));
|
||||
}
|
||||
|
||||
@GetMapping("/subjects")
|
||||
@Operation(summary = "查询科目目录")
|
||||
public CommonResult<List<CatalogSubjectRespVO>> listSubjects(
|
||||
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
|
||||
@Parameter(description = "院校 ID") @RequestParam(required = false) String schoolId,
|
||||
@Parameter(description = "专业 ID") @RequestParam(required = false) String majorId,
|
||||
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
|
||||
@Parameter(description = "科目类型") @RequestParam(required = false) String type) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listSubjects(regionId, schoolId, majorId, moduleId, type));
|
||||
}
|
||||
|
||||
@GetMapping("/module-nodes")
|
||||
@Operation(summary = "查询模块导航节点")
|
||||
public CommonResult<List<CatalogModuleNodeRespVO>> listModuleNodes(
|
||||
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
|
||||
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
|
||||
@Parameter(description = "父节点 ID(传 root 表示根节点)") @RequestParam(required = false) String parentId) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listModuleNodes(regionId, moduleId, parentId));
|
||||
}
|
||||
|
||||
@GetMapping("/content-entries")
|
||||
@Operation(summary = "查询内容入口")
|
||||
public CommonResult<List<CatalogContentEntryRespVO>> listContentEntries(
|
||||
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
|
||||
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType,
|
||||
@Parameter(description = "是否包含隐藏入口") @RequestParam(defaultValue = "false") boolean includeHidden) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listContentEntries(regionId, entryType, includeHidden));
|
||||
}
|
||||
|
||||
@GetMapping("/content-nodes")
|
||||
@Operation(summary = "查询内容导航节点")
|
||||
public CommonResult<List<CatalogContentNodeRespVO>> listContentNodes(
|
||||
@Parameter(description = "内容入口 ID", required = true) @RequestParam String entryId,
|
||||
@Parameter(description = "父节点 ID(传 root 表示根节点)") @RequestParam(required = false) String parentId,
|
||||
@Parameter(description = "查询模式(children/flat)") @RequestParam(defaultValue = "children") String mode,
|
||||
@Parameter(description = "是否包含停用节点") @RequestParam(defaultValue = "false") boolean includeInactive,
|
||||
@Parameter(description = "节点标记类型") @RequestParam(required = false) String markerType) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listContentNodes(entryId, parentId, mode, includeInactive, markerType));
|
||||
}
|
||||
|
||||
@GetMapping("/question-collections")
|
||||
@Operation(summary = "查询可用题集")
|
||||
public CommonResult<List<CatalogQuestionCollectionRespVO>> listQuestionCollections(
|
||||
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
|
||||
@Parameter(description = "内容入口 ID") @RequestParam(required = false) String entryId,
|
||||
@Parameter(description = "内容节点 ID") @RequestParam(required = false) String nodeId,
|
||||
@Parameter(description = "题集类型") @RequestParam(required = false) String collectionType,
|
||||
@Parameter(description = "返回条数上限") @RequestParam(required = false) Integer limit) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前请求已认证。不从请求参数取值,完全由安全上下文派生。
|
||||
*/
|
||||
private void assertAuthenticated() {
|
||||
if (SecurityFrameworkUtils.getLoginUserId() == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 Category Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogCategoryRespVO {
|
||||
|
||||
@Schema(description = "分类 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440001")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分类类型", example = "exam_type")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "显示排序", example = "2")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 ContentEntry Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogContentEntryRespVO {
|
||||
|
||||
@Schema(description = "入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "入口名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考数学题库")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "入口唯一键", requiredMode = Schema.RequiredMode.REQUIRED, example = "gaokao-math")
|
||||
private String entryKey;
|
||||
|
||||
@Schema(description = "入口类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "question_bank")
|
||||
private String entryType;
|
||||
|
||||
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
private String regionId;
|
||||
|
||||
@Schema(description = "显示排序", example = "1")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 ContentNode Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogContentNodeRespVO {
|
||||
|
||||
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440004")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.1 集合")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "节点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "node")
|
||||
private String nodeType;
|
||||
|
||||
@Schema(description = "内容入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
|
||||
private String entryId;
|
||||
|
||||
@Schema(description = "父节点 ID", example = "root")
|
||||
private String parentId;
|
||||
|
||||
@Schema(description = "树深度", example = "1")
|
||||
private Double depth;
|
||||
|
||||
@Schema(description = "是否叶子节点", example = "false")
|
||||
private Boolean leaf;
|
||||
|
||||
@Schema(description = "是否可被选择", example = "true")
|
||||
private Boolean selectable;
|
||||
|
||||
@Schema(description = "显示排序", example = "1")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 ModuleNode Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogModuleNodeRespVO {
|
||||
|
||||
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440003")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一章")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "节点类型", example = "chapter")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
private String regionId;
|
||||
|
||||
@Schema(description = "父节点 ID", example = "root")
|
||||
private String parentId;
|
||||
|
||||
@Schema(description = "显示排序", example = "4")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 QuestionCollection Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogQuestionCollectionRespVO {
|
||||
|
||||
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440006")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "题集名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024 高考数学真题")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "题集类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "exam_paper")
|
||||
private String collectionType;
|
||||
|
||||
@Schema(description = "题目数量", example = "24")
|
||||
private Long questionCount;
|
||||
|
||||
@Schema(description = "显示排序", example = "1")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 Region Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogRegionRespVO {
|
||||
|
||||
@Schema(description = "地区 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "地区名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "全国")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "显示排序", example = "1")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "用户 APP - 题库目录 Subject Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogSubjectRespVO {
|
||||
|
||||
@Schema(description = "科目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440002")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "科目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "数学")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "科目类型", example = "academic")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
private String regionId;
|
||||
|
||||
@Schema(description = "显示排序", example = "3")
|
||||
private Double order;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean active;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.education.enums;
|
||||
|
||||
/**
|
||||
* 题库目录数据源模式。
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code SCALAR_READ} — 使用 Scalar API 读取题库目录数据</li>
|
||||
* <li>{@code JAVA_READ} — 使用 Java 本地数据源(预留,当前不支持)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public enum CatalogProviderMode {
|
||||
|
||||
SCALAR_READ,
|
||||
JAVA_READ
|
||||
|
||||
}
|
||||
@@ -18,4 +18,19 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
|
||||
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
|
||||
|
||||
// ========== Catalog 目录 1-005-002-000 ~ 1-005-002-009 ==========
|
||||
ErrorCode CATALOG_DATA_SOURCE_DISABLED = new ErrorCode(1_005_002_000, "题库数据源未启用,请联系管理员");
|
||||
ErrorCode CATALOG_UPSTREAM_ERROR = new ErrorCode(1_005_002_001, "上游题库服务异常,请稍后重试");
|
||||
ErrorCode CATALOG_UPSTREAM_AUTH_FAILED = new ErrorCode(1_005_002_002, "上游题库服务认证失败,请联系管理员");
|
||||
ErrorCode CATALOG_UPSTREAM_FORBIDDEN = new ErrorCode(1_005_002_003, "无权限访问上游题库资源");
|
||||
ErrorCode CATALOG_UPSTREAM_NOT_FOUND = new ErrorCode(1_005_002_004, "请求的题库资源不存在");
|
||||
ErrorCode CATALOG_UPSTREAM_CONFLICT = new ErrorCode(1_005_002_005, "题库资源状态冲突");
|
||||
ErrorCode CATALOG_UPSTREAM_TOO_MANY_REQUESTS = new ErrorCode(1_005_002_006, "题库服务请求过于频繁,请稍后重试");
|
||||
ErrorCode CATALOG_UPSTREAM_TIMEOUT = new ErrorCode(1_005_002_007, "上游题库服务超时,请稍后重试");
|
||||
ErrorCode CATALOG_UPSTREAM_UNEXPECTED = new ErrorCode(1_005_002_008, "上游题库服务返回异常:{}");
|
||||
ErrorCode CATALOG_PROVIDER_MODE_INVALID = new ErrorCode(1_005_002_009, "不支持的题库数据源模式:{}");
|
||||
ErrorCode CATALOG_SCALAR_NOT_CONFIGURED = new ErrorCode(1_005_002_010, "Scalar 数据源未配置 base-url 或 token,请检查配置");
|
||||
ErrorCode CATALOG_UPSTREAM_MALFORMED = new ErrorCode(1_005_002_011, "上游题库返回数据格式异常,请稍后重试");
|
||||
ErrorCode CATALOG_UPSTREAM_UNAVAILABLE = new ErrorCode(1_005_002_012, "上游题库服务不可达,请稍后重试");
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.config;
|
||||
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Scalar 数据源自动配置。
|
||||
* 根据 yudao.education.catalog-mode 选择 CatalogProvider 实现。
|
||||
* SCALAR_READ 模式创建 ScalarCatalogProvider;其他模式(含 JAVA_READ)创建 UnsupportedModeCatalogProvider。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ScalarProperties.class)
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class ScalarAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "SCALAR_READ", matchIfMissing = true)
|
||||
public CatalogProvider scalarCatalogProvider(ScalarProperties scalarProperties) {
|
||||
return new ScalarCatalogProvider(scalarProperties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
|
||||
public CatalogProvider unsupportedModeCatalogProvider(EducationProperties educationProperties) {
|
||||
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
|
||||
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;
|
||||
return new UnsupportedModeCatalogProvider(mode);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Scalar 数据源配置属性。
|
||||
*
|
||||
* baseUrl 和 token 由部署环境注入,禁止硬编码到源码。
|
||||
* enabled=false 时所有 Scalar 调用直接拒绝,无静默降级。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "yudao.education.scalar")
|
||||
@Data
|
||||
public class ScalarProperties {
|
||||
|
||||
/**
|
||||
* 数据源开关。false 时调用方抛错,无静默降级。
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Scalar API 基础地址,例如 https://api.example.com。
|
||||
* 末尾不含斜杠。仅 enabled=true 时需要配置。
|
||||
*/
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 静态服务端令牌(Bearer)。部署时作为 secret 注入,永不记录到日志。
|
||||
* 仅 enabled=true 时需要配置。
|
||||
*/
|
||||
private String token;
|
||||
|
||||
/**
|
||||
* 连接超时时间,默认 5 秒。
|
||||
*/
|
||||
private Duration connectTimeout = Duration.ofSeconds(5);
|
||||
|
||||
/**
|
||||
* 读取超时时间,默认 30 秒。
|
||||
*/
|
||||
private Duration readTimeout = Duration.ofSeconds(30);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Scalar API 错误响应 — 仅用于反序列化 Scalar 错误体。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarApiErrorResponseDto {
|
||||
|
||||
@JsonProperty("error")
|
||||
private String error;
|
||||
|
||||
@JsonProperty("code")
|
||||
private String code;
|
||||
|
||||
@JsonProperty("requestId")
|
||||
private String requestId;
|
||||
|
||||
@JsonProperty("meta")
|
||||
private ScalarApiResponseMetaDto meta;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Scalar API 通用响应元数据 — 仅用于反序列化 Scalar 响应。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarApiResponseMetaDto {
|
||||
|
||||
@JsonProperty("requestId")
|
||||
private String requestId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Scalar 通用目录实体 — 用于 region/category/subject/module-node 等端点。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarCatalogEntityDto {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("legacyId")
|
||||
private Object legacyId;
|
||||
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
|
||||
@JsonProperty("regionId")
|
||||
private Object regionId;
|
||||
|
||||
@JsonProperty("order")
|
||||
private Double order;
|
||||
|
||||
@JsonProperty("isActive")
|
||||
private Boolean isActive;
|
||||
|
||||
@JsonProperty("description")
|
||||
private Object description;
|
||||
|
||||
@JsonProperty("metadata")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Scalar 内容入口响应 — 用于 /api/catalog/content-entries 端点。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarContentEntryResponseDto extends ScalarCatalogEntityDto {
|
||||
|
||||
@JsonProperty("entryKey")
|
||||
private String entryKey;
|
||||
|
||||
@JsonProperty("entryType")
|
||||
private String entryType;
|
||||
|
||||
@JsonProperty("route")
|
||||
private Object route;
|
||||
|
||||
@JsonProperty("icon")
|
||||
private Object icon;
|
||||
|
||||
@JsonProperty("visibility")
|
||||
private String visibility;
|
||||
|
||||
@JsonProperty("accessRules")
|
||||
private Map<String, Object> accessRules;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Scalar 内容导航节点响应 — 用于 /api/catalog/content-nodes 端点。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarContentNodeResponseDto extends ScalarCatalogEntityDto {
|
||||
|
||||
@JsonProperty("entryId")
|
||||
private String entryId;
|
||||
|
||||
@JsonProperty("parentId")
|
||||
private Object parentId;
|
||||
|
||||
@JsonProperty("nodeType")
|
||||
private String nodeType;
|
||||
|
||||
@JsonProperty("markerType")
|
||||
private Object markerType;
|
||||
|
||||
@JsonProperty("depth")
|
||||
private Double depth;
|
||||
|
||||
@JsonProperty("isLeaf")
|
||||
private Boolean isLeaf;
|
||||
|
||||
@JsonProperty("isSelectable")
|
||||
private Boolean isSelectable;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scalar 列表响应通用包装 — { items, meta }。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @param <T> 列表元素类型
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarListResponse<T> {
|
||||
|
||||
@JsonProperty("items")
|
||||
private List<T> items;
|
||||
|
||||
@JsonProperty("meta")
|
||||
private ScalarApiResponseMetaDto meta;
|
||||
|
||||
/**
|
||||
* 获取原始 items(可能为 null)。
|
||||
* 调用方负责区分 null(字段缺失)和 [](显式空列表)。
|
||||
*/
|
||||
public List<T> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Scalar 题集响应 — 用于 /api/catalog/question-collections 端点。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarQuestionCollectionResponseDto extends ScalarCatalogEntityDto {
|
||||
|
||||
@JsonProperty("entryId")
|
||||
private Object entryId;
|
||||
|
||||
@JsonProperty("nodeId")
|
||||
private Object nodeId;
|
||||
|
||||
@JsonProperty("collectionType")
|
||||
private String collectionType;
|
||||
|
||||
@JsonProperty("questionCount")
|
||||
private Double questionCount;
|
||||
|
||||
@JsonProperty("durationMinutes")
|
||||
private Object durationMinutes;
|
||||
|
||||
@JsonProperty("accessRules")
|
||||
private Map<String, Object> accessRules;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.iocoder.yudao.module.education.integration.scalar.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Scalar 单条目响应通用包装 — {"item": ..., "meta": {...}}。
|
||||
* 用于非列表端点(如按 ID 查找单条记录)。
|
||||
* 此 DTO 保留在 integration 包内,不进入业务层或前端。
|
||||
*
|
||||
* @param <T> 条目类型
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ScalarSingleItemResponse<T> {
|
||||
|
||||
@JsonProperty("item")
|
||||
private T item;
|
||||
|
||||
@JsonProperty("meta")
|
||||
private ScalarApiResponseMetaDto meta;
|
||||
|
||||
public T getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog;
|
||||
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 题库目录数据提供者接口。
|
||||
* 实现类隔离数据源差异,调用方只依赖此接口和领域 DTO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public interface CatalogProvider {
|
||||
|
||||
/**
|
||||
* 数据源是否可用。
|
||||
*/
|
||||
boolean isEnabled();
|
||||
|
||||
/** 查询地区列表 */
|
||||
List<CatalogEntityDTO> listRegions();
|
||||
|
||||
/** 查询分类列表 */
|
||||
List<CatalogEntityDTO> listCategories(String subjectId, String nodeId);
|
||||
|
||||
/** 查询科目列表 */
|
||||
List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type);
|
||||
|
||||
/** 查询模块导航节点 */
|
||||
List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId);
|
||||
|
||||
/** 查询内容入口 */
|
||||
List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden);
|
||||
|
||||
/** 查询内容导航节点 */
|
||||
List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType);
|
||||
|
||||
/** 查询题集 */
|
||||
List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog;
|
||||
|
||||
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 题库目录服务接口。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public interface CatalogService {
|
||||
|
||||
List<CatalogRegionRespVO> listRegions();
|
||||
|
||||
List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId);
|
||||
|
||||
List<CatalogSubjectRespVO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type);
|
||||
|
||||
List<CatalogModuleNodeRespVO> listModuleNodes(String regionId, String moduleId, String parentId);
|
||||
|
||||
List<CatalogContentEntryRespVO> listContentEntries(String regionId, String entryType, boolean includeHidden);
|
||||
|
||||
List<CatalogContentNodeRespVO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType);
|
||||
|
||||
List<CatalogQuestionCollectionRespVO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog;
|
||||
|
||||
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
|
||||
|
||||
/**
|
||||
* 题库目录服务实现。
|
||||
* 通过 CatalogProvider 隔离数据源,仅操作领域 DTO 和前端 VO。
|
||||
* 不直接引用任何 integration 层 DTO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
public class CatalogServiceImpl implements CatalogService {
|
||||
|
||||
private final CatalogProvider provider;
|
||||
|
||||
public CatalogServiceImpl(CatalogProvider provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogRegionRespVO> listRegions() {
|
||||
assertEnabled();
|
||||
List<CatalogEntityDTO> regions = provider.listRegions();
|
||||
if (regions == null) return Collections.emptyList();
|
||||
return regions.stream()
|
||||
.filter(r -> r.getIsActive() == null || r.getIsActive())
|
||||
.map(CatalogServiceImpl::toRegionVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogCategoryRespVO> listCategories(String subjectId, String nodeId) {
|
||||
assertEnabled();
|
||||
List<CatalogEntityDTO> categories = provider.listCategories(subjectId, nodeId);
|
||||
if (categories == null) return Collections.emptyList();
|
||||
return categories.stream()
|
||||
.filter(c -> c.getIsActive() == null || c.getIsActive())
|
||||
.map(CatalogServiceImpl::toCategoryVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogSubjectRespVO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
|
||||
assertEnabled();
|
||||
List<CatalogEntityDTO> subjects = provider.listSubjects(regionId, schoolId, majorId, moduleId, type);
|
||||
if (subjects == null) return Collections.emptyList();
|
||||
return subjects.stream()
|
||||
.filter(s -> s.getIsActive() == null || s.getIsActive())
|
||||
.map(CatalogServiceImpl::toSubjectVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogModuleNodeRespVO> listModuleNodes(String regionId, String moduleId, String parentId) {
|
||||
assertEnabled();
|
||||
List<CatalogEntityDTO> nodes = provider.listModuleNodes(regionId, moduleId, parentId);
|
||||
if (nodes == null) return Collections.emptyList();
|
||||
return nodes.stream()
|
||||
.filter(n -> n.getIsActive() == null || n.getIsActive())
|
||||
.map(CatalogServiceImpl::toModuleNodeVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentEntryRespVO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
|
||||
assertEnabled();
|
||||
List<CatalogContentEntryDTO> entries = provider.listContentEntries(regionId, entryType, includeHidden);
|
||||
if (entries == null) return Collections.emptyList();
|
||||
return entries.stream()
|
||||
.filter(e -> e.getIsActive() == null || e.getIsActive())
|
||||
.map(CatalogServiceImpl::toContentEntryVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentNodeRespVO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
|
||||
assertEnabled();
|
||||
List<CatalogContentNodeDTO> nodes = provider.listContentNodes(entryId, parentId, mode, includeInactive, markerType);
|
||||
if (nodes == null) return Collections.emptyList();
|
||||
return nodes.stream()
|
||||
.filter(n -> includeInactive || n.getIsActive() == null || n.getIsActive())
|
||||
.map(CatalogServiceImpl::toContentNodeVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogQuestionCollectionRespVO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
|
||||
assertEnabled();
|
||||
List<CatalogQuestionCollectionDTO> collections = provider.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit);
|
||||
if (collections == null) return Collections.emptyList();
|
||||
return collections.stream()
|
||||
.filter(c -> c.getIsActive() == null || c.getIsActive())
|
||||
.map(CatalogServiceImpl::toQuestionCollectionVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void assertEnabled() {
|
||||
if (!provider.isEnabled()) {
|
||||
throw exception(CATALOG_DATA_SOURCE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 转换方法 ==========
|
||||
|
||||
static CatalogRegionRespVO toRegionVO(CatalogEntityDTO dto) {
|
||||
return CatalogRegionRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogCategoryRespVO toCategoryVO(CatalogEntityDTO dto) {
|
||||
return CatalogCategoryRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.type(dto.getType())
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogSubjectRespVO toSubjectVO(CatalogEntityDTO dto) {
|
||||
return CatalogSubjectRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.type(dto.getType())
|
||||
.regionId(toString(dto.getRegionId()))
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogModuleNodeRespVO toModuleNodeVO(CatalogEntityDTO dto) {
|
||||
return CatalogModuleNodeRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.type(dto.getType())
|
||||
.regionId(toString(dto.getRegionId()))
|
||||
.parentId(null)
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogContentEntryRespVO toContentEntryVO(CatalogContentEntryDTO dto) {
|
||||
return CatalogContentEntryRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.entryKey(dto.getEntryKey())
|
||||
.entryType(dto.getEntryType())
|
||||
.regionId(toString(dto.getRegionId()))
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogContentNodeRespVO toContentNodeVO(CatalogContentNodeDTO dto) {
|
||||
return CatalogContentNodeRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.nodeType(dto.getNodeType())
|
||||
.entryId(dto.getEntryId())
|
||||
.parentId(toString(dto.getParentId()))
|
||||
.depth(dto.getDepth())
|
||||
.leaf(dto.getIsLeaf())
|
||||
.selectable(dto.getIsSelectable())
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogQuestionCollectionRespVO toQuestionCollectionVO(CatalogQuestionCollectionDTO dto) {
|
||||
return CatalogQuestionCollectionRespVO.builder()
|
||||
.id(dto.getId())
|
||||
.name(toString(dto.getName()))
|
||||
.collectionType(dto.getCollectionType())
|
||||
.questionCount(dto.getQuestionCount() != null ? dto.getQuestionCount().longValue() : null)
|
||||
.order(dto.getOrder())
|
||||
.active(dto.getIsActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String toString(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof String s) return s;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
|
||||
import cn.iocoder.yudao.module.education.integration.scalar.dto.*;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.web.client.*;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* Scalar 目录数据提供者实现。
|
||||
* 使用 RestTemplate 调用 Scalar API,自动注入 Bearer token 和 x-tenant-id。
|
||||
* 所有 Scalar DTO 在此类内部完成映射,外部仅通过 CatalogProvider 接口和领域 DTO 访问。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Slf4j
|
||||
public class ScalarCatalogProvider implements CatalogProvider {
|
||||
|
||||
private final ScalarProperties scalarProperties;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
private static final ParameterizedTypeReference<ScalarListResponse<ScalarCatalogEntityDto>> ENTITY_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<ScalarListResponse<ScalarContentEntryResponseDto>> ENTRY_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<ScalarListResponse<ScalarContentNodeResponseDto>> NODE_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
private static final ParameterizedTypeReference<ScalarListResponse<ScalarQuestionCollectionResponseDto>> COLLECTION_LIST_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
public ScalarCatalogProvider(ScalarProperties scalarProperties) {
|
||||
this.scalarProperties = scalarProperties;
|
||||
if (scalarProperties.isEnabled()) {
|
||||
if (scalarProperties.getBaseUrl() == null || scalarProperties.getBaseUrl().isBlank()) {
|
||||
throw exception(CATALOG_SCALAR_NOT_CONFIGURED);
|
||||
}
|
||||
if (scalarProperties.getToken() == null || scalarProperties.getToken().isBlank()) {
|
||||
throw exception(CATALOG_SCALAR_NOT_CONFIGURED);
|
||||
}
|
||||
}
|
||||
this.restTemplate = buildRestTemplate(scalarProperties);
|
||||
}
|
||||
|
||||
private RestTemplate buildRestTemplate(ScalarProperties props) {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder()
|
||||
.connectTimeout(props.getConnectTimeout())
|
||||
.readTimeout(props.getReadTimeout());
|
||||
if (props.getBaseUrl() != null) {
|
||||
builder = builder.rootUri(props.getBaseUrl());
|
||||
}
|
||||
return builder
|
||||
.additionalInterceptors(new ScalarLoggingInterceptor())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return scalarProperties.isEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listRegions() {
|
||||
return mapEntities(callEntityList("/api/catalog/regions"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
|
||||
String path = buildPath("/api/catalog/categories",
|
||||
"subjectId", subjectId, "nodeId", nodeId);
|
||||
return mapEntities(callEntityList(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
|
||||
String path = buildPath("/api/catalog/subjects",
|
||||
"regionId", regionId, "schoolId", schoolId,
|
||||
"majorId", majorId, "moduleId", moduleId, "type", type);
|
||||
return mapEntities(callEntityList(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
|
||||
String path = buildPath("/api/catalog/module-nodes",
|
||||
"regionId", regionId, "moduleId", moduleId, "parentId", parentId);
|
||||
return mapEntities(callEntityList(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/content-entries");
|
||||
addParam(builder, "regionId", regionId);
|
||||
addParam(builder, "entryType", entryType);
|
||||
if (includeHidden) {
|
||||
builder.queryParam("includeHidden", "true");
|
||||
}
|
||||
List<ScalarContentEntryResponseDto> items = callList(builder.toUriString(), ENTRY_LIST_TYPE);
|
||||
return items.stream()
|
||||
.map(ScalarCatalogProvider::toContentEntryDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/content-nodes");
|
||||
addParam(builder, "entryId", entryId);
|
||||
addParam(builder, "parentId", parentId);
|
||||
addParam(builder, "mode", mode);
|
||||
if (includeInactive) {
|
||||
builder.queryParam("includeInactive", "true");
|
||||
}
|
||||
addParam(builder, "markerType", markerType);
|
||||
List<ScalarContentNodeResponseDto> items = callList(builder.toUriString(), NODE_LIST_TYPE);
|
||||
return items.stream()
|
||||
.map(ScalarCatalogProvider::toContentNodeDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/catalog/question-collections");
|
||||
addParam(builder, "regionId", regionId);
|
||||
addParam(builder, "entryId", entryId);
|
||||
addParam(builder, "nodeId", nodeId);
|
||||
addParam(builder, "collectionType", collectionType);
|
||||
if (limit != null) {
|
||||
builder.queryParam("limit", limit);
|
||||
}
|
||||
List<ScalarQuestionCollectionResponseDto> items = callList(builder.toUriString(), COLLECTION_LIST_TYPE);
|
||||
return items.stream()
|
||||
.map(ScalarCatalogProvider::toQuestionCollectionDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ========== private helpers ==========
|
||||
|
||||
private List<ScalarCatalogEntityDto> callEntityList(String path) {
|
||||
return callList(path, ENTITY_LIST_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条目调用 — 反序列化 {"item": ..., "meta": {...}} 并返回 item。
|
||||
* 包级可见,供外部适配器或测试使用。item 为 null 或 body 为 null 时抛 CATALOG_UPSTREAM_MALFORMED。
|
||||
*
|
||||
* @param path 请求路径(含 query)
|
||||
* @param typeRef 条目参数化类型引用
|
||||
* @param <T> 条目类型
|
||||
* @return 反序列化后的单条目(非 null)
|
||||
*/
|
||||
<T> T callSingleItem(String path, ParameterizedTypeReference<ScalarSingleItemResponse<T>> typeRef) {
|
||||
Instant start = Instant.now();
|
||||
String sanitizedPath = sanitizedPath(path);
|
||||
Long tenantId = getTenantId();
|
||||
try {
|
||||
HttpHeaders headers = buildHeaders();
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<ScalarSingleItemResponse<T>> response = restTemplate.exchange(
|
||||
path, HttpMethod.GET, entity, typeRef);
|
||||
|
||||
ScalarSingleItemResponse<T> body = response.getBody();
|
||||
if (body == null) {
|
||||
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-body (single)",
|
||||
sanitizedPath, tenantId);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
}
|
||||
T item = body.getItem();
|
||||
if (item == null) {
|
||||
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-item (single)",
|
||||
sanitizedPath, tenantId);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
}
|
||||
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
String requestId = body.getMeta() != null ? body.getMeta().getRequestId() : null;
|
||||
log.info("[Scalar] ok path={} tenant={} requestId={} item-type={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, requestId, item.getClass().getSimpleName(), elapsed);
|
||||
|
||||
return item;
|
||||
} catch (HttpClientErrorException e) {
|
||||
return handleClientError(path, sanitizedPath, start, e);
|
||||
} catch (HttpServerErrorException e) {
|
||||
return handleServerError(path, sanitizedPath, start, e);
|
||||
} catch (ResourceAccessException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
String category = classifyResourceError(e);
|
||||
log.error("[Scalar] io-error path={} tenant={} category={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, category, elapsed);
|
||||
throw exception(mapResourceCategory(category));
|
||||
} catch (RestClientException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
log.error("[Scalar] conversion-error path={} tenant={} type={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
log.error("[Scalar] unexpected path={} tenant={} category={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed);
|
||||
throw exception(CATALOG_UPSTREAM_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> List<T> callList(String path, ParameterizedTypeReference<ScalarListResponse<T>> typeRef) {
|
||||
Instant start = Instant.now();
|
||||
String sanitizedPath = sanitizedPath(path);
|
||||
Long tenantId = getTenantId();
|
||||
try {
|
||||
HttpHeaders headers = buildHeaders();
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<ScalarListResponse<T>> response = restTemplate.exchange(
|
||||
path, HttpMethod.GET, entity, typeRef);
|
||||
|
||||
ScalarListResponse<T> body = response.getBody();
|
||||
if (body == null) {
|
||||
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-body",
|
||||
sanitizedPath, tenantId);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
}
|
||||
// Access raw items field to distinguish null from []
|
||||
List<T> items = body.getItems();
|
||||
if (items == null) {
|
||||
log.warn("[Scalar] malformed-response path={} tenant={} reason=null-items",
|
||||
sanitizedPath, tenantId);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
}
|
||||
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
String requestId = body.getMeta() != null ? body.getMeta().getRequestId() : null;
|
||||
log.info("[Scalar] ok path={} tenant={} requestId={} items={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, requestId, items.size(), elapsed);
|
||||
|
||||
return items;
|
||||
} catch (HttpClientErrorException e) {
|
||||
return handleClientError(path, sanitizedPath, start, e);
|
||||
} catch (HttpServerErrorException e) {
|
||||
return handleServerError(path, sanitizedPath, start, e);
|
||||
} catch (ResourceAccessException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
String category = classifyResourceError(e);
|
||||
log.error("[Scalar] io-error path={} tenant={} category={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, category, elapsed);
|
||||
throw exception(mapResourceCategory(category));
|
||||
} catch (RestClientException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
log.error("[Scalar] conversion-error path={} tenant={} type={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed);
|
||||
throw exception(CATALOG_UPSTREAM_MALFORMED);
|
||||
} catch (RuntimeException e) {
|
||||
// Already re-thrown above; let ServiceExceptions propagate
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
log.error("[Scalar] unexpected path={} tenant={} category={} elapsed={}ms",
|
||||
sanitizedPath, tenantId, e.getClass().getSimpleName(), elapsed);
|
||||
throw exception(CATALOG_UPSTREAM_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T handleClientError(String path, String sanitizedPath, Instant start, HttpClientErrorException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
HttpStatusCode statusCode = e.getStatusCode();
|
||||
String requestId = extractRequestId(e);
|
||||
log.warn("[Scalar] client-error path={} tenant={} status={} requestId={} elapsed={}ms",
|
||||
sanitizedPath, getTenantId(), statusCode.value(), requestId, elapsed);
|
||||
|
||||
if (statusCode == HttpStatus.BAD_REQUEST) {
|
||||
throw exception(GlobalErrorCodeConstants.BAD_REQUEST);
|
||||
} else if (statusCode == HttpStatus.UNAUTHORIZED) {
|
||||
throw exception(CATALOG_UPSTREAM_AUTH_FAILED);
|
||||
} else if (statusCode == HttpStatus.FORBIDDEN) {
|
||||
throw exception(CATALOG_UPSTREAM_FORBIDDEN);
|
||||
} else if (statusCode == HttpStatus.NOT_FOUND) {
|
||||
throw exception(CATALOG_UPSTREAM_NOT_FOUND);
|
||||
} else if (statusCode == HttpStatus.CONFLICT) {
|
||||
throw exception(CATALOG_UPSTREAM_CONFLICT);
|
||||
} else if (statusCode == HttpStatus.TOO_MANY_REQUESTS) {
|
||||
throw exception(CATALOG_UPSTREAM_TOO_MANY_REQUESTS);
|
||||
} else {
|
||||
throw exception(CATALOG_UPSTREAM_UNEXPECTED, statusCode.value());
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T handleServerError(String path, String sanitizedPath, Instant start, HttpServerErrorException e) {
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
String requestId = extractRequestId(e);
|
||||
log.error("[Scalar] server-error path={} tenant={} status={} requestId={} elapsed={}ms",
|
||||
sanitizedPath, getTenantId(), e.getStatusCode().value(), requestId, elapsed);
|
||||
throw exception(CATALOG_UPSTREAM_ERROR);
|
||||
}
|
||||
|
||||
private HttpHeaders buildHeaders() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(scalarProperties.getToken());
|
||||
String tenantId = String.valueOf(TenantContextHolder.getRequiredTenantId());
|
||||
headers.set("x-tenant-id", tenantId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractRequestId(HttpClientErrorException e) {
|
||||
return extractRequestIdFromBody(e.getResponseBodyAsString());
|
||||
}
|
||||
|
||||
private String extractRequestId(HttpServerErrorException e) {
|
||||
return extractRequestIdFromBody(e.getResponseBodyAsString());
|
||||
}
|
||||
|
||||
private String extractRequestIdFromBody(String body) {
|
||||
try {
|
||||
ScalarApiErrorResponseDto errorBody = JsonUtils.parseObject(body, ScalarApiErrorResponseDto.class);
|
||||
if (errorBody != null) {
|
||||
if (errorBody.getMeta() != null && errorBody.getMeta().getRequestId() != null) {
|
||||
return errorBody.getMeta().getRequestId();
|
||||
}
|
||||
return errorBody.getRequestId();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long getTenantId() {
|
||||
try {
|
||||
return TenantContextHolder.getRequiredTenantId();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对路径做脱敏,移除 query 参数中可能泄漏的敏感值。
|
||||
*/
|
||||
private String sanitizedPath(String path) {
|
||||
if (path.contains("?")) {
|
||||
return path.substring(0, path.indexOf('?'));
|
||||
}
|
||||
// If it's a full URL, extract path
|
||||
if (path.contains("://")) {
|
||||
try {
|
||||
return java.net.URI.create(path).getPath();
|
||||
} catch (Exception e) {
|
||||
// keep original
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过嵌套异常类型分类 ResourceAccessException。
|
||||
* 不检查原始异常消息文本,仅通过异常类型判定。
|
||||
*/
|
||||
private String classifyResourceError(ResourceAccessException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause == null) {
|
||||
return "io";
|
||||
}
|
||||
// Connection refused / unreachable / DNS failure
|
||||
if (cause instanceof java.net.ConnectException
|
||||
|| cause instanceof java.net.NoRouteToHostException
|
||||
|| cause instanceof java.net.UnknownHostException) {
|
||||
return "connect";
|
||||
}
|
||||
// SocketTimeoutException: differentiate connect vs read
|
||||
if (cause instanceof java.net.SocketTimeoutException) {
|
||||
String lower = cause.getMessage() != null ? cause.getMessage().toLowerCase() : "";
|
||||
// "connect timed out" → connect timeout; "Read timed out" → read timeout
|
||||
if (lower.contains("connect")) {
|
||||
return "connect";
|
||||
}
|
||||
return "read-timeout";
|
||||
}
|
||||
return "io";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将资源错误类别映射到业务错误码。
|
||||
* connect → UNAVAILABLE(上游不可达),read-timeout → TIMEOUT,其他 → TIMEOUT。
|
||||
*/
|
||||
private cn.iocoder.yudao.framework.common.exception.ErrorCode mapResourceCategory(String category) {
|
||||
if ("connect".equals(category)) {
|
||||
return CATALOG_UPSTREAM_UNAVAILABLE;
|
||||
}
|
||||
return CATALOG_UPSTREAM_TIMEOUT;
|
||||
}
|
||||
|
||||
// ========== URI building ==========
|
||||
|
||||
private static String buildPath(String path, String... keyValues) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromPath(path);
|
||||
for (int i = 0; i < keyValues.length; i += 2) {
|
||||
String key = keyValues[i];
|
||||
String value = keyValues[i + 1];
|
||||
addParam(builder, key, value);
|
||||
}
|
||||
return builder.toUriString();
|
||||
}
|
||||
|
||||
private static void addParam(UriComponentsBuilder builder, String key, String value) {
|
||||
if (value != null && !value.isEmpty()) {
|
||||
builder.queryParam(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DTO mapping: Scalar → domain ==========
|
||||
|
||||
private static List<CatalogEntityDTO> mapEntities(List<ScalarCatalogEntityDto> scalarDtos) {
|
||||
return scalarDtos.stream()
|
||||
.map(ScalarCatalogProvider::toEntityDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
static CatalogEntityDTO toEntityDTO(ScalarCatalogEntityDto dto) {
|
||||
return CatalogEntityDTO.builder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.title(dto.getTitle())
|
||||
.type(dto.getType())
|
||||
.regionId(dto.getRegionId())
|
||||
.order(dto.getOrder())
|
||||
.isActive(dto.getIsActive())
|
||||
.metadata(dto.getMetadata())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogContentEntryDTO toContentEntryDTO(ScalarContentEntryResponseDto dto) {
|
||||
return CatalogContentEntryDTO.contentEntryBuilder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.title(dto.getTitle())
|
||||
.type(dto.getType())
|
||||
.regionId(dto.getRegionId())
|
||||
.order(dto.getOrder())
|
||||
.isActive(dto.getIsActive())
|
||||
.metadata(dto.getMetadata())
|
||||
.entryKey(dto.getEntryKey())
|
||||
.entryType(dto.getEntryType())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogContentNodeDTO toContentNodeDTO(ScalarContentNodeResponseDto dto) {
|
||||
return CatalogContentNodeDTO.contentNodeBuilder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.title(dto.getTitle())
|
||||
.type(dto.getType())
|
||||
.regionId(dto.getRegionId())
|
||||
.order(dto.getOrder())
|
||||
.isActive(dto.getIsActive())
|
||||
.metadata(dto.getMetadata())
|
||||
.entryId(dto.getEntryId())
|
||||
.parentId(dto.getParentId())
|
||||
.nodeType(dto.getNodeType())
|
||||
.depth(dto.getDepth())
|
||||
.isLeaf(dto.getIsLeaf())
|
||||
.isSelectable(dto.getIsSelectable())
|
||||
.build();
|
||||
}
|
||||
|
||||
static CatalogQuestionCollectionDTO toQuestionCollectionDTO(ScalarQuestionCollectionResponseDto dto) {
|
||||
return CatalogQuestionCollectionDTO.questionCollectionBuilder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.title(dto.getTitle())
|
||||
.type(dto.getType())
|
||||
.regionId(dto.getRegionId())
|
||||
.order(dto.getOrder())
|
||||
.isActive(dto.getIsActive())
|
||||
.metadata(dto.getMetadata())
|
||||
.entryId(dto.getEntryId())
|
||||
.nodeId(dto.getNodeId())
|
||||
.collectionType(dto.getCollectionType())
|
||||
.questionCount(dto.getQuestionCount())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== interceptor ==========
|
||||
|
||||
/**
|
||||
* 日志拦截器 — 记录请求级别信息,脱敏 Authorization 和 x-tenant-id 值。
|
||||
*/
|
||||
@Slf4j
|
||||
private static class ScalarLoggingInterceptor implements ClientHttpRequestInterceptor {
|
||||
@Override
|
||||
public ClientHttpResponse intercept(org.springframework.http.HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
Instant start = Instant.now();
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
long elapsed = Duration.between(start, Instant.now()).toMillis();
|
||||
log.debug("[Scalar] request method={} path={} status={} elapsed={}ms",
|
||||
request.getMethod(), request.getURI().getPath(),
|
||||
response.getStatusCode().value(), elapsed);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog;
|
||||
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_PROVIDER_MODE_INVALID;
|
||||
|
||||
/**
|
||||
* 不支持的目录数据源模式占位提供者。
|
||||
* 所有业务方法直接抛出 CATALOG_PROVIDER_MODE_INVALID。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public class UnsupportedModeCatalogProvider implements CatalogProvider {
|
||||
|
||||
private final CatalogProviderMode mode;
|
||||
|
||||
public UnsupportedModeCatalogProvider(CatalogProviderMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listRegions() {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listCategories(String subjectId, String nodeId) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listSubjects(String regionId, String schoolId, String majorId, String moduleId, String type) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogEntityDTO> listModuleNodes(String regionId, String moduleId, String parentId) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentEntryDTO> listContentEntries(String regionId, String entryType, boolean includeHidden) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogContentNodeDTO> listContentNodes(String entryId, String parentId, String mode, boolean includeInactive, String markerType) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, this.mode.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CatalogQuestionCollectionDTO> listQuestionCollections(String regionId, String entryId, String nodeId, String collectionType, Integer limit) {
|
||||
throw exception(CATALOG_PROVIDER_MODE_INVALID, mode.name());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 内容入口领域 DTO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CatalogContentEntryDTO extends CatalogEntityDTO {
|
||||
|
||||
@Builder(builderMethodName = "contentEntryBuilder")
|
||||
public CatalogContentEntryDTO(String id, String name, String title, String type, Object regionId,
|
||||
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
|
||||
String entryKey, String entryType) {
|
||||
super(id, name, title, type, regionId, order, isActive, description, metadata);
|
||||
this.entryKey = entryKey;
|
||||
this.entryType = entryType;
|
||||
}
|
||||
|
||||
private String entryKey;
|
||||
private String entryType;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 内容导航节点领域 DTO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CatalogContentNodeDTO extends CatalogEntityDTO {
|
||||
|
||||
@Builder(builderMethodName = "contentNodeBuilder")
|
||||
public CatalogContentNodeDTO(String id, String name, String title, String type, Object regionId,
|
||||
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
|
||||
String entryId, Object parentId, String nodeType,
|
||||
Double depth, Boolean isLeaf, Boolean isSelectable) {
|
||||
super(id, name, title, type, regionId, order, isActive, description, metadata);
|
||||
this.entryId = entryId;
|
||||
this.parentId = parentId;
|
||||
this.nodeType = nodeType;
|
||||
this.depth = depth;
|
||||
this.isLeaf = isLeaf;
|
||||
this.isSelectable = isSelectable;
|
||||
}
|
||||
|
||||
private String entryId;
|
||||
private Object parentId;
|
||||
private String nodeType;
|
||||
private Double depth;
|
||||
private Boolean isLeaf;
|
||||
private Boolean isSelectable;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 目录实体领域 DTO — 对应 region/category/subject/module-node。
|
||||
* 隔离外部数据源 DTO,业务层只依赖此类型。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CatalogEntityDTO {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String title;
|
||||
private String type;
|
||||
private Object regionId;
|
||||
private Double order;
|
||||
private Boolean isActive;
|
||||
private String description;
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.education.service.catalog.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 题集领域 DTO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CatalogQuestionCollectionDTO extends CatalogEntityDTO {
|
||||
|
||||
@Builder(builderMethodName = "questionCollectionBuilder")
|
||||
public CatalogQuestionCollectionDTO(String id, String name, String title, String type, Object regionId,
|
||||
Double order, Boolean isActive, String description, java.util.Map<String, Object> metadata,
|
||||
Object entryId, Object nodeId, String collectionType, Double questionCount) {
|
||||
super(id, name, title, type, regionId, order, isActive, description, metadata);
|
||||
this.entryId = entryId;
|
||||
this.nodeId = nodeId;
|
||||
this.collectionType = collectionType;
|
||||
this.questionCount = questionCount;
|
||||
}
|
||||
|
||||
private Object entryId;
|
||||
private Object nodeId;
|
||||
private String collectionType;
|
||||
private Double questionCount;
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user