feat(education): resolve student tenant context

This commit is contained in:
2026-07-27 17:32:36 +08:00
parent 11e9cc6854
commit 0f846fdaf5
16 changed files with 856 additions and 7 deletions

View File

@@ -0,0 +1 @@
-- Ticket #3 creates no database records, so rollback is intentionally a no-op.

View File

@@ -0,0 +1,3 @@
-- Ticket #3 adds no administrator permission.
-- Tenant resolution is @PermitAll and current education context only requires an authenticated Member session.
-- Therefore no system_menu rows are required for this vertical slice.

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.framework.common.biz.system.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import java.util.List;
/**
@@ -23,4 +25,34 @@ public interface TenantCommonApi {
*/
void validateTenant(Long id);
/**
* 根据租户编号获得租户信息
*
* @param id 租户编号
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenant(Long id) {
throw new UnsupportedOperationException("getTenant is not implemented");
}
/**
* 根据租户名获得租户信息
*
* @param name 租户名
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenantByName(String name) {
throw new UnsupportedOperationException("getTenantByName is not implemented");
}
/**
* 根据域名获得租户信息
*
* @param website 域名
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenantByWebsite(String website) {
throw new UnsupportedOperationException("getTenantByWebsite is not implemented");
}
}

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.framework.common.biz.system.tenant.dto;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 租户信息 Response DTO
*
* @author 恭学教育
*/
@Data
public class TenantRespDTO implements Serializable {
/**
* 租户编号
*/
private Long id;
/**
* 租户名
*/
private String name;
/**
* 租户状态
*
* 0 - 开启1 - 禁用
*/
private Integer status;
/**
* 绑定域名列表
*/
private List<String> websites;
/**
* 过期时间
*/
private LocalDateTime expireTime;
}

View File

@@ -4,9 +4,11 @@
## 当前状态:应用外壳 (Shell)
此模块目前处于**应用外壳**阶段,提供:
此模块目前处于**应用外壳**阶段,提供:
- 模块骨架与包结构
- 能力探测端点 (`/education/capability`)
- 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用
- 教育上下文端点 (`/education/context`) — 学生端已认证状态
- 独立的功能开关配置
- 错误码常量
- 权限与菜单种子数据(角色授权由管理员按租户完成)
@@ -23,10 +25,12 @@ 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 分组可见)
- `yudao.education.enabled=false`(默认):禁用模块,所有教育端点不可达
## API
@@ -52,11 +56,76 @@ GET /admin-api/education/capability
}
```
### 用户 APP - 教育租户识别
```
GET /app-api/education/tenant/resolve?hostname=school.example.com
GET /app-api/education/tenant/resolve?tenantName=demo-school
GET /app-api/education/tenant/resolve?hostname=school.example.com&tenantName=demo-school
```
- 权限:无需认证(`@PermitAll`
- 说明:通过主机名或租户名解析租户,返回学生端登录引导所需的基础字段。
hostname 和 tenantName 至少提供一个。解析规则:
1. 如果同时提供两者,它们必须解析到同一个租户,否则拒绝请求
2. hostname 经过标准化(保留端口并转为小写),先查 `EducationProperties.hostnameTenantMap` 配置映射,再按 `system_tenant.websites` 的精确 authority 值查询
- 响应示例(成功):
```json
{
"code": 0,
"msg": "成功",
"data": {
"tenantId": 1024,
"tenantName": "demo-school",
"displayName": "demo-school",
"status": "ACTIVE",
"loginMethods": ["PASSWORD", "SMS"]
}
}
```
- 错误响应:
| 错误码 | 说明 |
|--------|------|
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败hostname 格式不合法等) |
| 1_005_001_004 | 当前租户不可用(过期等) |
### 用户 APP - 教育当前上下文
```
GET /app-api/education/context
```
- 权限:需要认证(登录态)
- 说明:根据当前认证用户和租户上下文返回教育业务信息。不信任请求体中的 userId一切从安全上下文和 TenantContext 派生。TenantSecurityWebFilter 前置完成租户校验,此处二次验证确保租户处于活跃状态。
- 响应示例:
```json
{
"code": 0,
"msg": "成功",
"data": {
"userId": 1024,
"tenantId": 2048,
"tenantName": "demo-school",
"displayName": "demo-school"
}
}
```
### 错误码
| 错误码 | 说明 |
|--------|------|
| 1_005_001_000 | 教育模块未启用 |
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败:{原因} |
| 1_005_001_004 | 当前租户不可用,请联系管理员 |
## 构建与运行
@@ -90,11 +159,15 @@ mvn package -pl yudao-server -am -DskipTests
## SQL 应用
```bash
# 应用种子数据(菜单 + 权限定义;执行后由管理员为目标租户角色授权)
# 应用基础种子数据(菜单 + 权限定义;执行后由管理员为目标租户角色授权)
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-seed.sql
# 应用租户识别种子数据
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-seed.sql
# 回滚
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-rollback.sql
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-rollback.sql
```
## SQL 交付约定
@@ -110,10 +183,37 @@ mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-rollback.sql
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/``src/views/mes/`),缺少 `package.json``router/``store/``config/` 等核心框架文件。
因此:
- **管理后台教育菜单项**仅在后端通过 SQL 种子数据注册了 `system_menu` 记录ID 6800-6801。前端无路由/页面组件可渲染,菜单在管理后台不会显示。
- **管理后台教育菜单项**基础 SQL 注册了 `system_menu` 记录ID 6800-6801租户解析与当前上下文属于学生端接口,不创建虚假的后台权限菜单。前端无路由/页面组件可渲染,菜单在管理后台不会显示。
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
**阻塞项**:完整前端源码(含 router、store、package.json是后续前端集成的必要前提。一旦前端源码就位
### Student 端前端集成契约
前端就位后必须实现以下流程(不能伪造静态页面):
1. **租户识别**(登录前)
- URL: `GET /app-api/education/tenant/resolve`
- 从浏览器 `window.location.host` 获取 authority包含非默认端口传入 `hostname` 参数
- 备用:支持手动输入 `tenantName`
- 根据返回的 `loginMethods` 决定展示哪种登录方式PASSWORD/SMS
- 获得 `tenantId` 后,在后续请求中通过 `tenant-id` header 传递
2. **用户认证**(复用 Member 模块)
- 密码登录: `POST /app-api/member/auth/login`
- 短信登录: `POST /app-api/member/auth/sms-login`
- 刷新令牌: `POST /app-api/member/auth/refresh-token`
- 登出: `POST /app-api/member/auth/logout`
- 所有请求携带 `tenant-id: {tenantId}` header
3. **获取上下文**(登录后)
- URL: `GET /app-api/education/context`
- 携带有效 Bearer Token + `tenant-id` header
- 从响应获取 `userId``tenantId``tenantName` 用于页面展示
4. **跨租户防护**
- 前端不应允许用户手动切换 `tenant-id` header
- 后端通过 `TenantSecurityWebFilter` 拒绝认证用户的跨租户 header 操作
**阻塞项**:完整前端源码(含 router、store、package.json是上述前端集成的必要前提。一旦前端源码就位
1. 在 Vue3 admin 的路由中添加 `/education` 路由项,绑定 Education 菜单组件
2. 添加 `src/api/education/` API 封装层
2. 添加 `src/api/education/` API 封装层(调用上述教育端点)
3. Student Web/H5 端如需要独立入口,需新建对应前端项目

View File

@@ -4,6 +4,10 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 教育模块配置属性
*
@@ -24,4 +28,19 @@ public class EducationProperties {
*/
private String version = "1.0.0";
/**
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
* key = 标准化后的主机名小写、无端口value = 租户名。
* 示例:{ "staging.school.com": "demo-school" }
*
* 映射优先级高于 system_tenant.websites 字段匹配。
*/
private Map<String, String> hostnameTenantMap = new HashMap<>();
/**
* 学生端全局开放的登录方式。它描述当前部署启用的 Member 登录入口,
* 不是租户级 OAuth 提供方探测结果。
*/
private List<String> loginMethods = List.of("PASSWORD", "SMS");
}

View File

@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.education.controller.app;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.vo.EducationContextRespVO;
import io.swagger.v3.oas.annotations.Operation;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
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;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_NOT_ACTIVE;
/**
* 教育当前上下文 Controller — 学生端
*
* <p>根据当前认证用户和安全上下文返回教育业务所需的租户与用户信息。
* 该端点需要认证,不信任请求体中的 userId。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 教育上下文")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationContextController {
@Resource
private TenantCommonApi tenantCommonApi;
@GetMapping("/context")
@Operation(summary = "获取当前教育上下文",
description = "根据当前认证用户和租户上下文返回教育业务信息。需要登录态。")
public CommonResult<EducationContextRespVO> getContext() {
// 1. 从安全上下文获取用户编号(不信任请求参数)
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
throw exception(UNAUTHORIZED);
}
// 2. 获取当前租户并验证状态(由 TenantSecurityWebFilter 前置完成,此处二次验证)
Long tenantId = TenantContextHolder.getRequiredTenantId();
tenantCommonApi.validateTenant(tenantId);
// 3. 获取租户信息用于展示
TenantRespDTO tenant = tenantCommonApi.getTenant(tenantId);
if (tenant == null) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
// 构建响应
EducationContextRespVO resp = EducationContextRespVO.builder()
.userId(userId)
.tenantId(tenantId)
.tenantName(tenant.getName())
.displayName(tenant.getName())
.build();
return success(resp);
}
}

View File

@@ -0,0 +1,181 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
/**
* 教育租户识别 Controller — 学生端入口
*
* <p>用于在登录前通过主机名或租户名解析当前租户,返回引导信息。
* 该端点无需认证(@PermitAll忽略租户上下文@TenantIgnore。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 教育租户识别")
@RestController
@RequestMapping("/education/tenant")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationTenantController {
@Resource
private TenantCommonApi tenantCommonApi;
@Resource
private EducationProperties educationProperties;
@GetMapping("/resolve")
@PermitAll
@TenantIgnore
@Operation(summary = "解析租户",
description = "通过主机名或租户名解析租户信息,返回登录引导所需的基础字段。" +
"hostname 和 tenantName 至少提供一个。")
@Parameters({
@Parameter(name = "hostname", description = "标准化主机名(小写、无端口、无协议)", example = "school.example.com"),
@Parameter(name = "tenantName", description = "租户名", example = "demo-school")
})
public CommonResult<EducationTenantRespVO> resolve(
@RequestParam(value = "hostname", required = false) String hostname,
@RequestParam(value = "tenantName", required = false) String tenantName) {
// 1. 校验输入:至少提供一个
if (StrUtil.isBlank(hostname) && StrUtil.isBlank(tenantName)) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 和 tenantName 不能同时为空");
}
// 2. 主机名标准化
String normalizedHostname = normalizeHostname(hostname);
// 3. 解析租户并验证一致性
TenantRespDTO tenant = resolveTenantAndEnsureConsistency(normalizedHostname, tenantName);
// 4. 验证租户状态
if (tenant == null) {
throw exception(EDUCATION_TENANT_NOT_FOUND);
}
if (CommonStatusEnum.isDisable(tenant.getStatus())) {
throw exception(EDUCATION_TENANT_DISABLED);
}
if (DateUtils.isExpired(tenant.getExpireTime())) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
// 5. 构建响应
EducationTenantRespVO resp = EducationTenantRespVO.builder()
.tenantId(tenant.getId())
.tenantName(tenant.getName())
.displayName(tenant.getName())
.status("ACTIVE")
.loginMethods(new ArrayList<>(educationProperties.getLoginMethods()))
.build();
return success(resp);
}
/**
* 按优先级解析租户:
* 1. 如果提供了 tenantName直接通过租户名查询
* 2. 如果提供了 hostname
* a. 先查 EducationProperties.hostnameTenantMap 配置映射
* b. 再通过 system_tenant.websites 字段匹配
*
* @param hostname 标准化后的主机名,可能为 null
* @param tenantName 租户名,可能为 null
* @return 租户 DTO未找到返回 null
*/
private TenantRespDTO resolveTenantAndEnsureConsistency(String hostname, String tenantName) {
TenantRespDTO byName = StrUtil.isNotBlank(tenantName)
? tenantCommonApi.getTenantByName(tenantName.trim()) : null;
TenantRespDTO byHostname = StrUtil.isNotBlank(hostname) ? resolveTenantByHostname(hostname) : null;
if (byName != null && byHostname != null && !byName.getId().equals(byHostname.getId())) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 与 tenantName 指向不同租户");
}
if (StrUtil.isNotBlank(tenantName) && byName == null) {
return null;
}
return byName != null ? byName : byHostname;
}
private TenantRespDTO resolveTenantByHostname(String hostname) {
String mappedTenantName = educationProperties.getHostnameTenantMap().entrySet().stream()
.filter(entry -> hostname.equals(normalizeHostname(entry.getKey())))
.map(entry -> entry.getValue())
.findFirst()
.orElse(null);
if (StrUtil.isNotBlank(mappedTenantName)) {
return tenantCommonApi.getTenantByName(mappedTenantName);
}
return tenantCommonApi.getTenantByWebsite(hostname);
}
/**
* 主机名标准化:转为小写并拒绝协议、路径和非法端口。
* 端口会被保留,因为 system_tenant.websites 使用精确 authority 匹配。
*
* @param hostname 原始主机名
* @return 标准化后的主机名,输入为空时返回 null
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 格式不合法时
*/
static String normalizeHostname(String hostname) {
if (StrUtil.isBlank(hostname)) {
return null;
}
String normalized = hostname.trim();
// 拒绝含协议的输入(如 http://example.com
if (normalized.contains("://")) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
"hostname 不应包含协议,收到: " + hostname);
}
// 拒绝含路径的输入
if (normalized.contains("/")) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
"hostname 不应包含路径,收到: " + hostname);
}
// 校验端口。保留合法端口,确保与 system_tenant.websites 的精确值一致。
int colonIdx = normalized.lastIndexOf(':');
if (colonIdx > 0 && !normalized.startsWith("[")) {
String afterColon = normalized.substring(colonIdx + 1);
if (afterColon.isEmpty() || !afterColon.chars().allMatch(Character::isDigit)) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
int port;
try {
port = Integer.parseInt(afterColon);
} catch (NumberFormatException ex) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
if (port < 1 || port > 65535) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
}
}
return normalized.toLowerCase();
}
}

View File

@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.education.controller.app.tenant.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "用户 APP - 教育租户识别 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EducationTenantRespVO {
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long tenantId;
@Schema(description = "租户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "demo-school")
private String tenantName;
@Schema(description = "租户显示名称", example = "Demo School")
private String displayName;
@Schema(description = "租户状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE",
allowableValues = {"ACTIVE", "DISABLED", "EXPIRED"})
private String status;
@Schema(description = "支持的登录方式", requiredMode = Schema.RequiredMode.REQUIRED,
example = "[\"PASSWORD\", \"SMS\"]")
private List<String> loginMethods;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 教育当前上下文 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EducationContextRespVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long userId;
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private Long tenantId;
@Schema(description = "租户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "demo-school")
private String tenantName;
@Schema(description = "租户显示名称", example = "Demo School")
private String displayName;
}

View File

@@ -12,4 +12,10 @@ public interface ErrorCodeConstants {
// ========== 通用模块 1-005-001-000 ==========
ErrorCode EDUCATION_DISABLED = new ErrorCode(1_005_001_000, "教育模块未启用,请联系管理员");
// ========== 租户识别 1-005-001-001 ~ 1-005-001-010 ==========
ErrorCode EDUCATION_TENANT_NOT_FOUND = new ErrorCode(1_005_001_001, "租户不存在");
ErrorCode EDUCATION_TENANT_DISABLED = new ErrorCode(1_005_001_002, "租户已被禁用");
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
}

View File

@@ -7,6 +7,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -47,4 +49,12 @@ public class EducationPropertiesTest {
assertEquals("1.0.0", defaults.getVersion(), "默认版本应为 1.0.0");
}
@Test
public void testHostnameTenantMapDefaults() {
EducationProperties defaults = new EducationProperties();
assertNotNull(defaults.getHostnameTenantMap(), "hostnameTenantMap 默认不应为 null");
assertTrue(defaults.getHostnameTenantMap().isEmpty(), "hostnameTenantMap 默认应为空");
assertEquals(List.of("PASSWORD", "SMS"), defaults.getLoginMethods(), "默认登录方式应与 Member 入口一致");
}
}

View File

@@ -0,0 +1,87 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link EducationTenantController} 的单元测试
*
* @author 恭学教育
*/
class EducationTenantControllerTest {
// ========== normalizeHostname ==========
@ParameterizedTest
@NullAndEmptySource
void normalizeHostname_shouldReturnNullForBlankInput(String hostname) {
assertNull(EducationTenantController.normalizeHostname(hostname));
}
@Test
void normalizeHostname_shouldLowercase() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname("School.Example.COM"));
}
@Test
void normalizeHostname_shouldTrimWhitespace() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname(" school.example.com "));
}
@Test
void normalizeHostname_shouldKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("school.example.com:8080"));
}
@Test
void normalizeHostname_shouldLowercaseAndKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("School.Example.COM:8080"));
}
@Test
void normalizeHostname_shouldRejectProtocol() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("http://school.example.com"));
}
@Test
void normalizeHostname_shouldRejectPath() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("school.example.com/path"));
}
@Test
void normalizeHostname_shouldRejectProtocolWithPort() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("https://school.example.com:8443"));
}
@Test
void normalizeHostname_shouldRejectOversizedPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("school.example.com:999999999999"));
}
@Test
void normalizeHostname_shouldKeepIPv6Colons() {
// A bare IPv6 address like ::1: the port-stripping should be safe
// because last colon is followed by digits -> treated as port
// We'll just check no exception thrown
assertDoesNotThrow(() -> EducationTenantController.normalizeHostname("[::1]"));
}
@Test
void normalizeHostname_shouldRejectNonNumericPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("host:name"));
}
}

View File

@@ -0,0 +1,197 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link EducationTenantController} 的集成测试 — 测试租户解析流程
*
* @author 恭学教育
*/
@SpringBootTest(classes = EducationTenantResolveIntegrationTest.Config.class,
properties = {
"yudao.education.enabled=true",
"yudao.education.version=1.0.0-test"
},
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("unit-test")
class EducationTenantResolveIntegrationTest {
@Configuration
@EnableConfigurationProperties(EducationProperties.class)
@Import(EducationTenantController.class)
static class Config {
}
@Resource
private EducationTenantController controller;
@MockitoBean
private TenantCommonApi tenantCommonApi;
// ========== happy path ==========
@Test
void resolve_byTenantName_shouldReturnActiveTenant() {
TenantRespDTO dto = buildTenant(100L, "demo-school", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("demo-school")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve(null, "demo-school");
assertTrue(result.isSuccess());
EducationTenantRespVO data = result.getData();
assertEquals(100L, data.getTenantId());
assertEquals("demo-school", data.getTenantName());
assertEquals("ACTIVE", data.getStatus());
assertTrue(data.getLoginMethods().contains("PASSWORD"));
assertTrue(data.getLoginMethods().contains("SMS"));
}
@Test
void resolve_byHostname_shouldFindByWebsite() {
TenantRespDTO dto = buildTenant(200L, "school-alpha", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("school.example.com", null);
assertTrue(result.isSuccess());
assertEquals(200L, result.getData().getTenantId());
assertEquals("school-alpha", result.getData().getTenantName());
}
@Test
void resolve_byHostname_shouldNormalizeCaseAndKeepPort() {
TenantRespDTO dto = buildTenant(300L, "school-beta", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com:8443")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("School.Example.COM:8443", null);
assertTrue(result.isSuccess());
assertEquals(300L, result.getData().getTenantId());
}
@Test
void resolve_conflictingHostnameAndTenantName_shouldFail() {
TenantRespDTO byName = buildTenant(600L, "school-a", CommonStatusEnum.ENABLE.getStatus());
TenantRespDTO byHost = buildTenant(601L, "school-b", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("school-a")).thenReturn(byName);
when(tenantCommonApi.getTenantByWebsite("school-b.example.com")).thenReturn(byHost);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> controller.resolve("school-b.example.com", "school-a"));
assertTrue(exception.getMessage().contains("指向不同租户"));
}
// ========== error cases ==========
@Test
void resolve_noInput_shouldFail() {
try {
controller.resolve(null, null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_blankInput_shouldFail() {
try {
controller.resolve(" ", "");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_notFound_shouldFail() {
when(tenantCommonApi.getTenantByName("nonexistent")).thenReturn(null);
when(tenantCommonApi.getTenantByWebsite(anyString())).thenReturn(null);
try {
controller.resolve(null, "nonexistent");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("租户不存在"));
}
}
@Test
void resolve_disabled_shouldFail() {
TenantRespDTO dto = buildTenant(400L, "disabled-school", CommonStatusEnum.DISABLE.getStatus());
when(tenantCommonApi.getTenantByName("disabled-school")).thenReturn(dto);
try {
controller.resolve(null, "disabled-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("已被禁用"));
}
}
@Test
void resolve_expired_shouldFail() {
TenantRespDTO dto = buildTenant(500L, "expired-school", CommonStatusEnum.ENABLE.getStatus());
dto.setExpireTime(LocalDateTime.now().minusDays(1));
when(tenantCommonApi.getTenantByName("expired-school")).thenReturn(dto);
try {
controller.resolve(null, "expired-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("当前租户不可用"));
}
}
@Test
void resolve_protocolInHostname_shouldFail() {
try {
controller.resolve("http://evil.com", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("协议"));
}
}
@Test
void resolve_pathInHostname_shouldFail() {
try {
controller.resolve("school.com/admin", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("路径"));
}
}
// ========== helpers ==========
private static TenantRespDTO buildTenant(Long id, String name, Integer status) {
TenantRespDTO dto = new TenantRespDTO();
dto.setId(id);
dto.setName(name);
dto.setStatus(status);
dto.setWebsites(Collections.singletonList(name + ".example.com"));
dto.setExpireTime(LocalDateTime.now().plusYears(1));
return dto;
}
}

View File

@@ -1,10 +1,15 @@
package cn.iocoder.yudao.module.education.framework.web.config;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.admin.EducationCapabilityController;
import cn.iocoder.yudao.module.education.controller.app.EducationContextController;
import cn.iocoder.yudao.module.education.controller.app.tenant.EducationTenantController;
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.mockito.Mockito.mock;
/**
* {@link EducationWebConfiguration} 的单元测试
@@ -14,12 +19,19 @@ import static org.assertj.core.api.Assertions.assertThat;
class EducationWebConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(EducationWebConfiguration.class, EducationCapabilityController.class);
.withUserConfiguration(EducationWebConfiguration.class,
EducationCapabilityController.class,
EducationTenantController.class,
EducationContextController.class)
.withBean(TenantCommonApi.class, () -> mock(TenantCommonApi.class))
.withBean(EducationProperties.class, EducationProperties::new);
@Test
void shouldNotRegisterEducationWebBeansByDefault() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(EducationCapabilityController.class);
assertThat(context).doesNotHaveBean(EducationTenantController.class);
assertThat(context).doesNotHaveBean(EducationContextController.class);
assertThat(context).doesNotHaveBean("educationGroupedOpenApi");
});
}
@@ -29,6 +41,8 @@ class EducationWebConfigurationTest {
contextRunner.withPropertyValues("yudao.education.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(EducationCapabilityController.class);
assertThat(context).hasSingleBean(EducationTenantController.class);
assertThat(context).hasSingleBean(EducationContextController.class);
assertThat(context).hasBean("educationGroupedOpenApi");
});
}

View File

@@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.system.api.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import org.springframework.stereotype.Service;
@@ -28,4 +31,22 @@ public class TenantApiImpl implements TenantCommonApi {
tenantService.validTenant(id);
}
@Override
public TenantRespDTO getTenant(Long id) {
TenantDO tenant = tenantService.getTenant(id);
return tenant != null ? BeanUtils.toBean(tenant, TenantRespDTO.class) : null;
}
@Override
public TenantRespDTO getTenantByName(String name) {
TenantDO tenant = tenantService.getTenantByName(name);
return tenant != null ? BeanUtils.toBean(tenant, TenantRespDTO.class) : null;
}
@Override
public TenantRespDTO getTenantByWebsite(String website) {
TenantDO tenant = tenantService.getTenantByWebsite(website);
return tenant != null ? BeanUtils.toBean(tenant, TenantRespDTO.class) : null;
}
}