diff --git a/sql/mysql/education/001-education-tenant-rollback.sql b/sql/mysql/education/001-education-tenant-rollback.sql new file mode 100644 index 00000000..d0181bb6 --- /dev/null +++ b/sql/mysql/education/001-education-tenant-rollback.sql @@ -0,0 +1 @@ +-- Ticket #3 creates no database records, so rollback is intentionally a no-op. diff --git a/sql/mysql/education/001-education-tenant-seed.sql b/sql/mysql/education/001-education-tenant-seed.sql new file mode 100644 index 00000000..e9df9ae0 --- /dev/null +++ b/sql/mysql/education/001-education-tenant-seed.sql @@ -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. diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/TenantCommonApi.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/TenantCommonApi.java index cacc764b..be5c53fe 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/TenantCommonApi.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/TenantCommonApi.java @@ -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"); + } + } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/dto/TenantRespDTO.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/dto/TenantRespDTO.java new file mode 100644 index 00000000..e9cc9715 --- /dev/null +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/dto/TenantRespDTO.java @@ -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 websites; + + /** + * 过期时间 + */ + private LocalDateTime expireTime; + +} diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index a01beea6..9e3f22b0 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -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 端如需要独立入口,需新建对应前端项目 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java index 2e82b14c..db6116a0 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java @@ -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 hostnameTenantMap = new HashMap<>(); + + /** + * 学生端全局开放的登录方式。它描述当前部署启用的 Member 登录入口, + * 不是租户级 OAuth 提供方探测结果。 + */ + private List loginMethods = List.of("PASSWORD", "SMS"); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/EducationContextController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/EducationContextController.java new file mode 100644 index 00000000..f1948d23 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/EducationContextController.java @@ -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 — 学生端 + * + *

根据当前认证用户和安全上下文返回教育业务所需的租户与用户信息。 + * 该端点需要认证,不信任请求体中的 userId。

+ * + * @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 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); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java new file mode 100644 index 00000000..82c434a2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java @@ -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 — 学生端入口 + * + *

用于在登录前通过主机名或租户名解析当前租户,返回引导信息。 + * 该端点无需认证(@PermitAll),忽略租户上下文(@TenantIgnore)。

+ * + * @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 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(); + } + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/vo/EducationTenantRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/vo/EducationTenantRespVO.java new file mode 100644 index 00000000..4aeea97b --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/vo/EducationTenantRespVO.java @@ -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 loginMethods; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vo/EducationContextRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vo/EducationContextRespVO.java new file mode 100644 index 00000000..934625d2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vo/EducationContextRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 90a68a19..6b81d30d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "当前租户不可用,请联系管理员"); + } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/config/EducationPropertiesTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/config/EducationPropertiesTest.java index 0c84f36d..295deb78 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/config/EducationPropertiesTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/config/EducationPropertiesTest.java @@ -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 入口一致"); + } + } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantControllerTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantControllerTest.java new file mode 100644 index 00000000..dd06b2e5 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantControllerTest.java @@ -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")); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantResolveIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantResolveIntegrationTest.java new file mode 100644 index 00000000..32f28343 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantResolveIntegrationTest.java @@ -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 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 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 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; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/framework/web/config/EducationWebConfigurationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/framework/web/config/EducationWebConfigurationTest.java index 1b46e9c8..bbe079a4 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/framework/web/config/EducationWebConfigurationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/framework/web/config/EducationWebConfigurationTest.java @@ -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"); }); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/tenant/TenantApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/tenant/TenantApiImpl.java index 0d28d85a..2a1477d8 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/tenant/TenantApiImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/tenant/TenantApiImpl.java @@ -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; + } + }