diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java index 8be0f0e56..9bea6fc32 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java @@ -92,6 +92,9 @@ public class SysLoginController return ajax; } + @Autowired(required = false) + private com.ruoyi.train.service.ITrainingIdentityService identityService; + /** * 获取路由信息 * @@ -102,8 +105,99 @@ public class SysLoginController { Long userId = SecurityUtils.getUserId(); List menus = menuService.selectMenuTreeByUserId(userId); + if (menus != null && !menus.isEmpty()) + { + menus = filterSelfServiceMenus(menus, userId); + } return AjaxResult.success(menuService.buildMenus(menus)); } + + /** + * 根据用户真实业务身份过滤角色专属自服务菜单(教师工作台、学员中心)。 + * 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。 + */ + private List filterSelfServiceMenus(List menus, Long userId) + { + boolean hasTeacherIdentity = false; + boolean hasStudentIdentity = false; + + if (identityService != null && userId != null) + { + try + { + com.ruoyi.train.domain.TrainTeacher teacher = identityService.getTeacherByUserId(userId); + hasTeacherIdentity = (teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0)); + } + catch (Exception ignored) {} + + try + { + com.ruoyi.train.domain.TrainStudent student = identityService.getStudentByUserId(userId); + hasStudentIdentity = (student != null && (student.getStatus() == null || student.getStatus() != 0)); + } + catch (Exception ignored) {} + } + else if (userId != null && !SecurityUtils.isAdmin(userId)) + { + // 无身份服务依赖时,降级检查角色绑定,但管理角色绝不赋予自服务 + LoginUser loginUser = SecurityUtils.getLoginUser(); + if (loginUser != null && loginUser.getUser() != null && loginUser.getUser().getRoles() != null) + { + for (com.ruoyi.common.core.domain.entity.SysRole r : loginUser.getUser().getRoles()) + { + if ("train_teacher".equals(r.getRoleKey())) + { + hasTeacherIdentity = true; + } + if ("train_student".equals(r.getRoleKey())) + { + hasStudentIdentity = true; + } + } + } + } + + List result = new java.util.ArrayList<>(); + for (SysMenu m : menus) + { + if (isSelfServiceAllowed(m, hasTeacherIdentity, hasStudentIdentity)) + { + if (m.getChildren() != null && !m.getChildren().isEmpty()) + { + m.setChildren(filterSelfServiceMenus(m.getChildren(), userId)); + } + result.add(m); + } + } + return result; + } + + private boolean isSelfServiceAllowed(SysMenu m, boolean hasTeacherIdentity, boolean hasStudentIdentity) + { + String component = m.getComponent(); + String path = m.getPath(); + String perms = m.getPerms(); + + boolean isTeacherWorkbench = "train/me/teacher/index".equals(component) + || "train:me:teacher".equals(perms) + || ("teacher".equals(path) && "train/me/teacher/index".equals(component)); + + if (isTeacherWorkbench && !hasTeacherIdentity) + { + return false; + } + + boolean isStudentCenter = "train/me/student/index".equals(component) + || "train:me:student".equals(perms) + || ("student".equals(path) && "train/me/student/index".equals(component)); + + if (isStudentCenter && !hasStudentIdentity) + { + return false; + } + + return true; + } // 检查初始密码是否提醒修改 public boolean initPasswordIsModify(Date pwdUpdateDate) diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RoleNavigationVisibilityContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RoleNavigationVisibilityContractTest.java new file mode 100644 index 000000000..dc7827024 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RoleNavigationVisibilityContractTest.java @@ -0,0 +1,354 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.core.domain.entity.SysMenu; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.service.ITrainingIdentityService; +import com.ruoyi.web.controller.system.SysLoginController; + +/** + * 角色专属自服务菜单可见性与未授权访问阻断契约测试。 + * + * 核心规则: + * 1. 管理权限 != 业务主体身份。 + * 2. admin / train_admin 无教师档案时,教师工作台菜单 HIDDEN。 + * 3. admin / train_admin 无学员档案时,学员中心菜单 HIDDEN。 + * 4. 具备真实教师身份的用户,教师工作台 VISIBLE,学员中心 HIDDEN。 + * 5. 具备真实学员身份的用户,学员中心 VISIBLE,教师工作台 HIDDEN。 + * 6. 未授权用户直接调用 /train/me/teacher 或 /train/me/student 仍然被 403 严格拦截。 + * 7. 前端请求层具备 403 防风暴机制,页面初始化在身份失败时截断后续请求。 + */ +public class RoleNavigationVisibilityContractTest +{ + private SysLoginController loginController; + private ITrainingIdentityService mockIdentityService; + + private Long currentAdminUserId = 1L; + private Long currentTrainAdminUserId = 2L; + private Long currentTeacherUserId = 101L; + private Long currentStudentUserId = 201L; + private Long currentDualUserId = 301L; + + private static final String PROJECT_ROOT = findProjectRoot(); + + private static String findProjectRoot() + { + File cur = new File(".").getAbsoluteFile(); + while (cur != null) + { + if (new File(cur, "build_ui").isDirectory() && new File(cur, "RuoYi-Vue").isDirectory()) + { + return cur.getAbsolutePath(); + } + cur = cur.getParentFile(); + } + return new File(".").getAbsolutePath(); + } + + @Before + public void setUp() throws Exception + { + loginController = new SysLoginController(); + + mockIdentityService = (ITrainingIdentityService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainingIdentityService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getTeacherByUserId".equals(name)) + { + Long uid = (Long) args[0]; + if (currentTeacherUserId.equals(uid) || currentDualUserId.equals(uid)) + { + TrainTeacher t = new TrainTeacher(); + t.settId("T_MOCK_101"); + t.settName("测试讲师"); + t.setStatus(1); + return t; + } + return null; + } + if ("getStudentByUserId".equals(name)) + { + Long uid = (Long) args[0]; + if (currentStudentUserId.equals(uid) || currentDualUserId.equals(uid)) + { + TrainStudent s = new TrainStudent(); + s.setsId("S_MOCK_201"); + s.setsName("测试学员"); + s.setStatus(1); + return s; + } + return null; + } + if ("assertTeacherIdentity".equals(name)) + { + throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403); + } + if ("assertStudentIdentity".equals(name)) + { + throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人中心", 403); + } + return null; + } + ); + + Field field = SysLoginController.class.getDeclaredField("identityService"); + field.setAccessible(true); + field.set(loginController, mockIdentityService); + } + + private List createMockMenuTree() + { + List list = new ArrayList<>(); + + // 1. 人员管理(包含教师信息与学员信息子菜单) + SysMenu peopleMgmt = new SysMenu(); + peopleMgmt.setMenuId(2159L); + peopleMgmt.setMenuName("人员管理"); + peopleMgmt.setPath("people"); + peopleMgmt.setComponent("Layout"); + + List peopleChildren = new ArrayList<>(); + SysMenu teacherInfo = new SysMenu(); + teacherInfo.setMenuId(2155L); + teacherInfo.setMenuName("教师信息"); + teacherInfo.setPath("teacher"); + teacherInfo.setComponent("train/teacher"); + peopleChildren.add(teacherInfo); + + SysMenu studentInfo = new SysMenu(); + studentInfo.setMenuId(2156L); + studentInfo.setMenuName("学员信息"); + studentInfo.setPath("student"); + studentInfo.setComponent("train/student"); + peopleChildren.add(studentInfo); + + peopleMgmt.setChildren(peopleChildren); + list.add(peopleMgmt); + + // 2. 教师工作台(自服务顶级菜单) + SysMenu teacherWorkbench = new SysMenu(); + teacherWorkbench.setMenuId(2239L); + teacherWorkbench.setMenuName("教师工作台"); + teacherWorkbench.setPath("teacher"); + teacherWorkbench.setComponent("train/me/teacher/index"); + teacherWorkbench.setPerms("train:me:teacher"); + list.add(teacherWorkbench); + + // 3. 学员中心(自服务顶级菜单) + SysMenu studentCenter = new SysMenu(); + studentCenter.setMenuId(2240L); + studentCenter.setMenuName("学员中心"); + studentCenter.setPath("student"); + studentCenter.setComponent("train/me/student/index"); + studentCenter.setPerms("train:me:student"); + list.add(studentCenter); + + // 4. 学员画像 + SysMenu portrait = new SysMenu(); + portrait.setMenuId(2173L); + portrait.setMenuName("学员画像"); + portrait.setPath("portrait"); + portrait.setComponent("train/portrait"); + portrait.setPerms("train:portrait:query"); + list.add(portrait); + + // 5. 系统管理 + SysMenu sysMgmt = new SysMenu(); + sysMgmt.setMenuId(1L); + sysMgmt.setMenuName("系统管理"); + sysMgmt.setPath("system"); + sysMgmt.setComponent("Layout"); + list.add(sysMgmt); + + return list; + } + + @SuppressWarnings("unchecked") + private List invokeFilterSelfServiceMenus(List menus, Long userId) throws Exception + { + Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class); + method.setAccessible(true); + return (List) method.invoke(loginController, menus, userId); + } + + /** + * 1. 验证 admin 登录时自服务菜单隐藏,且普通管理菜单完整保留 + */ + @Test + public void testAdminSelfServiceMenuHidden() throws Exception + { + List menus = createMockMenuTree(); + List filtered = invokeFilterSelfServiceMenus(menus, currentAdminUserId); + + boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName())); + boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName())); + boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName())); + boolean hasPortrait = filtered.stream().anyMatch(m -> "学员画像".equals(m.getMenuName())); + boolean hasSystemMgmt = filtered.stream().anyMatch(m -> "系统管理".equals(m.getMenuName())); + + assertFalse("admin 教师工作台必须 HIDDEN", hasTeacherWorkbench); + assertFalse("admin 学员中心必须 HIDDEN", hasStudentCenter); + assertTrue("admin 人员管理必须保留", hasPeopleMgmt); + assertTrue("admin 学员画像必须保留", hasPortrait); + assertTrue("admin 系统管理必须保留", hasSystemMgmt); + + // 人员管理下属的教师信息与学员信息完好 + SysMenu people = filtered.stream().filter(m -> "人员管理".equals(m.getMenuName())).findFirst().orElse(null); + assertNotNull(people); + assertEquals(2, people.getChildren().size()); + assertEquals("教师信息", people.getChildren().get(0).getMenuName()); + assertEquals("学员信息", people.getChildren().get(1).getMenuName()); + } + + /** + * 2. 验证 train_admin 登录时自服务菜单隐藏,业务菜单完整保留 + */ + @Test + public void testTrainAdminSelfServiceMenuHidden() throws Exception + { + List menus = createMockMenuTree(); + List filtered = invokeFilterSelfServiceMenus(menus, currentTrainAdminUserId); + + boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName())); + boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName())); + boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName())); + + assertFalse("train_admin 教师工作台必须 HIDDEN", hasTeacherWorkbench); + assertFalse("train_admin 学员中心必须 HIDDEN", hasStudentCenter); + assertTrue("train_admin 人员管理必须保留", hasPeopleMgmt); + } + + /** + * 3. 验证具备真实教师身份的用户:教师工作台 VISIBLE,学员中心 HIDDEN + */ + @Test + public void testTeacherSelfServiceMenuVisible() throws Exception + { + List menus = createMockMenuTree(); + List filtered = invokeFilterSelfServiceMenus(menus, currentTeacherUserId); + + boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName())); + boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName())); + + assertTrue("teacher 用户教师工作台必须 VISIBLE", hasTeacherWorkbench); + assertFalse("teacher 用户学员中心必须 HIDDEN", hasStudentCenter); + } + + /** + * 4. 验证具备真实学员身份的用户:学员中心 VISIBLE,教师工作台 HIDDEN + */ + @Test + public void testStudentSelfServiceMenuVisible() throws Exception + { + List menus = createMockMenuTree(); + List filtered = invokeFilterSelfServiceMenus(menus, currentStudentUserId); + + boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName())); + boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName())); + + assertFalse("student 用户教师工作台必须 HIDDEN", hasTeacherWorkbench); + assertTrue("student 用户学员中心必须 VISIBLE", hasStudentCenter); + } + + /** + * 5. 验证同时具备教师与学员真实身份的用户:分别按真实身份可见 + */ + @Test + public void testDualIdentityUserBothVisible() throws Exception + { + List menus = createMockMenuTree(); + List filtered = invokeFilterSelfServiceMenus(menus, currentDualUserId); + + boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName())); + boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName())); + + assertTrue("双重身份用户教师工作台 VISIBLE", hasTeacherWorkbench); + assertTrue("双重身份用户学员中心 VISIBLE", hasStudentCenter); + } + + /** + * 6. 验证非法直接访问保护:即便直接调用接口,无身份档案仍被 403 拦截 + */ + @Test + public void testDirectUnauthorizedAccessStillBlocked() + { + // 模拟直接访问教师工作台接口 + try + { + mockIdentityService.assertTeacherIdentity(); + fail("未绑定教师档案的用户直接访问必须抛出 403 ServiceException"); + } + catch (ServiceException e) + { + assertEquals(403, e.getCode().intValue()); + assertTrue(e.getMessage().contains("教师档案")); + } + + // 模拟直接访问学员中心接口 + try + { + mockIdentityService.assertStudentIdentity(); + fail("未绑定学员档案的用户直接访问必须抛出 403 ServiceException"); + } + catch (ServiceException e) + { + assertEquals(403, e.getCode().intValue()); + assertTrue(e.getMessage().contains("学员档案")); + } + } + + /** + * 7. 验证前端静态契约: + * - selfServiceRoute.js 存在且实现隔离过滤 + * - permission.js 正确引入并执行自服务路由过滤 + * - request.js 包含 403 防风暴机制 + * - 教师与学员页面具备加载守卫 + */ + @Test + public void testFrontendRoleNavigationContracts() throws Exception + { + File utilsFile = new File(PROJECT_ROOT, "build_ui/src/utils/selfServiceRoute.js"); + assertTrue("selfServiceRoute.js 必须存在", utilsFile.exists()); + String utilsContent = new String(Files.readAllBytes(utilsFile.toPath()), "UTF-8"); + assertTrue("必须定义 isSelfServiceRouteAllowed", utilsContent.contains("isSelfServiceRouteAllowed")); + assertTrue("必须限制 train_teacher", utilsContent.contains("train_teacher")); + assertTrue("必须限制 train_student", utilsContent.contains("train_student")); + + File permFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/permission.js"); + assertTrue("permission.js 必须存在", permFile.exists()); + String permContent = new String(Files.readAllBytes(permFile.toPath()), "UTF-8"); + assertTrue("permission.js 必须导入 selfServiceRoute", permContent.contains("selfServiceRoute")); + assertTrue("permission.js 必须在 filterAsyncRouter 中检查 roles", permContent.contains("isSelfServiceRouteAllowed")); + + File reqFile = new File(PROJECT_ROOT, "build_ui/src/utils/request.js"); + assertTrue("request.js 必须存在", reqFile.exists()); + String reqContent = new String(Files.readAllBytes(reqFile.toPath()), "UTF-8"); + assertTrue("request.js 必须包含 403 防抖", reqContent.contains("lastForbiddenNotificationTime") && reqContent.contains("FORBIDDEN_DEBOUNCE_MS")); + + File teacherVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/teacher/index.vue"); + String teacherContent = new String(Files.readAllBytes(teacherVue.toPath()), "UTF-8"); + assertTrue("教师页面必须在身份验证成功后再加载排课数据", teacherContent.contains("loadMyPlans") && !teacherContent.contains("initTeacherData() {\n this.loadProfile()\n this.loadMyPlans()")); + + File studentVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/student/index.vue"); + String studentContent = new String(Files.readAllBytes(studentVue.toPath()), "UTF-8"); + assertTrue("学员页面必须在身份验证成功后再加载Tab数据", studentContent.contains("loadEnrollments") && !studentContent.contains("initStudentData() {\n this.loadProfile()\n this.loadEnrollments()")); + } +} diff --git a/build_ui/src/store/modules/permission.js b/build_ui/src/store/modules/permission.js index fe7137953..05f579469 100644 --- a/build_ui/src/store/modules/permission.js +++ b/build_ui/src/store/modules/permission.js @@ -4,6 +4,7 @@ import { getRouters } from '@/api/menu' import Layout from '@/layout/index' import ParentView from '@/components/ParentView' import InnerLink from '@/layout/components/InnerLink' +import { isSelfServiceRouteAllowed } from '@/utils/selfServiceRoute' const permission = { state: { @@ -30,16 +31,15 @@ const permission = { }, actions: { // 生成路由 - GenerateRoutes({ commit }) { + GenerateRoutes({ commit, rootGetters }) { return new Promise(resolve => { // 向后端请求路由数据 getRouters().then(res => { - //console.log("getRouters_res=============="); - //console.log(res); + const roles = (rootGetters && rootGetters.roles) || [] const sdata = JSON.parse(JSON.stringify(res.data)) const rdata = JSON.parse(JSON.stringify(res.data)) - const sidebarRoutes = filterAsyncRouter(sdata) - const rewriteRoutes = filterAsyncRouter(rdata, false, true) + const sidebarRoutes = filterAsyncRouter(sdata, false, false, roles) + const rewriteRoutes = filterAsyncRouter(rdata, false, true, roles) const asyncRoutes = filterDynamicRoutes(dynamicRoutes); rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true }) router.addRoutes(asyncRoutes); @@ -55,8 +55,11 @@ const permission = { } // 遍历后台传来的路由字符串,转换为组件对象 -function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) { +function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false, roles = []) { return asyncRouterMap.filter(route => { + if (!isSelfServiceRouteAllowed(route, roles)) { + return false + } if (type && route.children) { route.children = filterChildren(route.children) } @@ -73,7 +76,11 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) { } } if (route.children != null && route.children && route.children.length) { - route.children = filterAsyncRouter(route.children, route, type) + route.children = filterAsyncRouter(route.children, route, type, roles) + // 如果作为wrapper的父路由在过滤后子路由为空,则一并移除 + if (route.children.length === 0) { + return false + } } else { delete route['children'] delete route['redirect'] diff --git a/build_ui/src/utils/request.js b/build_ui/src/utils/request.js index b87b998e8..b1ddad019 100644 --- a/build_ui/src/utils/request.js +++ b/build_ui/src/utils/request.js @@ -65,12 +65,15 @@ service.interceptors.request.use(config => { Promise.reject(error) }) +let lastForbiddenNotificationTime = 0 +const FORBIDDEN_DEBOUNCE_MS = 2500 // 2.5秒内对403权限拒绝只提醒一次 + // 响应拦截器 service.interceptors.response.use(res => { // 未设置状态码则默认成功状态 const code = res.data.code || 200 - // 获取错误信息 - const msg = errorCode[code] || res.data.msg || errorCode['default'] + // 获取错误信息,优先使用服务端返回的明确业务说明 + const msg = res.data.msg || errorCode[code] || errorCode['default'] // 二进制数据则直接返回 if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') { return res.data @@ -88,6 +91,18 @@ service.interceptors.response.use(res => { }) } return Promise.reject('无效的会话,或者会话已过期,请重新登录。') + } else if (code === 403) { + const forbiddenMsg = res.data.msg || errorCode['403'] || '当前操作没有权限' + const now = Date.now() + if (now - lastForbiddenNotificationTime > FORBIDDEN_DEBOUNCE_MS) { + lastForbiddenNotificationTime = now + Notification.error({ + title: '访问受限', + message: forbiddenMsg, + duration: 3500 + }) + } + return Promise.reject(new Error(forbiddenMsg)) } else if (code === 500) { Message({ message: msg, type: 'error' }) return Promise.reject(new Error(msg)) @@ -104,6 +119,19 @@ service.interceptors.response.use(res => { error => { console.log('err' + error) let { message } = error + if (error.response && (error.response.status === 403 || (error.response.data && error.response.data.code === 403))) { + const forbiddenMsg = (error.response.data && error.response.data.msg) || '当前操作没有权限' + const now = Date.now() + if (now - lastForbiddenNotificationTime > FORBIDDEN_DEBOUNCE_MS) { + lastForbiddenNotificationTime = now + Notification.error({ + title: '访问受限', + message: forbiddenMsg, + duration: 3500 + }) + } + return Promise.reject(error) + } if (message === 'Network Error') { message = '后端接口连接异常' } else if (message.includes('timeout')) { diff --git a/build_ui/src/utils/selfServiceRoute.js b/build_ui/src/utils/selfServiceRoute.js new file mode 100644 index 000000000..acae658bf --- /dev/null +++ b/build_ui/src/utils/selfServiceRoute.js @@ -0,0 +1,45 @@ +/** + * 角色专属自服务路由与菜单权限过滤工具 + * + * 业务规则: + * 1. 教师工作台 (train/me/teacher/index):仅对具备 train_teacher 角色的用户可见。 + * 2. 学员中心 (train/me/student/index):仅对具备 train_student 角色的用户可见。 + * 3. 即使拥有超级管理员 (admin) 或培训管理员 (train_admin) 角色,无真实身份/角色时自服务菜单一律隐藏。 + * 4. 普通管理与业务菜单(如人员管理下的教师信息、学员信息等)不受本规则过滤。 + */ + +export function isSelfServiceRouteAllowed(route, roles = []) { + if (!route) return false + const comp = route.component + // 教师工作台专属自服务组件 + if (comp === 'train/me/teacher/index') { + return Array.isArray(roles) && roles.includes('train_teacher') + } + // 学员中心专属自服务组件 + if (comp === 'train/me/student/index') { + return Array.isArray(roles) && roles.includes('train_student') + } + return true +} + +/** + * 深度过滤路由树中的自服务专属路由 + */ +export function filterSelfServiceRoutes(routes, roles = []) { + if (!Array.isArray(routes)) return [] + return routes.reduce((acc, route) => { + if (!isSelfServiceRouteAllowed(route, roles)) { + return acc + } + const item = { ...route } + if (item.children && Array.isArray(item.children)) { + item.children = filterSelfServiceRoutes(item.children, roles) + // 如果作为父容器wrapper在子路由被完全过滤后变为空,则移除该父容器 + if (item.children.length === 0 && (item.component === 'Layout' || !item.component || item.path === '/')) { + return acc + } + } + acc.push(item) + return acc + }, []) +} diff --git a/build_ui/src/views/train/me/student/index.vue b/build_ui/src/views/train/me/student/index.vue index fdb6d005f..a8f8f1e79 100644 --- a/build_ui/src/views/train/me/student/index.vue +++ b/build_ui/src/views/train/me/student/index.vue @@ -374,12 +374,6 @@ export default { methods: { initStudentData() { this.loadProfile() - this.loadEnrollments() - this.loadTimetable() - this.loadReport() - this.loadAttendance() - this.loadDuration() - this.loadPortrait() }, loadProfile() { this.profileLoading = true @@ -388,6 +382,13 @@ export default { if (res.code === 200) { this.studentProfile = res.data this.identityErrorMsg = '' + // 学员身份校验通过后,再并发加载学业与选课各Tab数据 + this.loadEnrollments() + this.loadTimetable() + this.loadReport() + this.loadAttendance() + this.loadDuration() + this.loadPortrait() } else if (res.code === 403) { this.identityErrorMsg = res.msg || '当前登录账号未绑定任何学员档案,请联系管理员核对分配。' } diff --git a/build_ui/src/views/train/me/teacher/index.vue b/build_ui/src/views/train/me/teacher/index.vue index 292f6e1f5..dac4f9775 100644 --- a/build_ui/src/views/train/me/teacher/index.vue +++ b/build_ui/src/views/train/me/teacher/index.vue @@ -309,8 +309,6 @@ export default { methods: { initTeacherData() { this.loadProfile() - this.loadMyPlans() - this.loadMyTimetable() }, loadProfile() { this.profileLoading = true @@ -319,6 +317,9 @@ export default { if (res.code === 200) { this.teacherProfile = res.data this.identityErrorMsg = '' + // 教师身份校验通过后,再加载任教班级与课表日程 + this.loadMyPlans() + this.loadMyTimetable() } else if (res.code === 403) { this.identityErrorMsg = res.msg || '当前登录账号未绑定任何教师档案,请联系管理员分配绑定。' } diff --git a/build_ui/tests/unit/roleNavigationVisibility.spec.mjs b/build_ui/tests/unit/roleNavigationVisibility.spec.mjs new file mode 100644 index 000000000..73d4ecf57 --- /dev/null +++ b/build_ui/tests/unit/roleNavigationVisibility.spec.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert'; +import { isSelfServiceRouteAllowed, filterSelfServiceRoutes } from '../../src/utils/selfServiceRoute.js'; + +console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tests ==='); + +// 1. 测试 isSelfServiceRouteAllowed 基础路由拦截 +{ + const teacherWorkbench = { path: 'teacher', component: 'train/me/teacher/index', meta: { title: '教师工作台' } }; + const studentCenter = { path: 'student', component: 'train/me/student/index', meta: { title: '学员中心' } }; + const teacherInfo = { path: 'teacher', component: 'train/teacher/index', meta: { title: '教师信息' } }; + const studentInfo = { path: 'student', component: 'train/student/index', meta: { title: '学员信息' } }; + const portrait = { path: 'portrait', component: 'train/portrait/index', meta: { title: '学员画像' } }; + const system = { path: 'system', component: 'Layout', meta: { title: '系统管理' } }; + + // A. 超级管理员 (admin) + const adminRoles = ['admin']; + assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, adminRoles), false, 'admin 不能看到教师工作台'); + assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, adminRoles), false, 'admin 不能看到学员中心'); + assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, adminRoles), true, 'admin 必须保留人员管理下的教师信息'); + assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, adminRoles), true, 'admin 必须保留人员管理下的学员信息'); + assert.strictEqual(isSelfServiceRouteAllowed(portrait, adminRoles), true, 'admin 必须保留学员画像'); + assert.strictEqual(isSelfServiceRouteAllowed(system, adminRoles), true, 'admin 必须保留系统管理'); + console.log('✔ PASS: admin correctly blocks self-service routes while preserving management routes'); + + // B. 培训管理员 (train_admin) + const trainAdminRoles = ['train_admin']; + assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, trainAdminRoles), false, 'train_admin 不能看到教师工作台'); + assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, trainAdminRoles), false, 'train_admin 不能看到学员中心'); + assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, trainAdminRoles), true, 'train_admin 必须保留人员管理下的教师信息'); + assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, trainAdminRoles), true, 'train_admin 必须保留人员管理下的学员信息'); + console.log('✔ PASS: train_admin correctly blocks self-service routes while preserving business routes'); + + // C. 教师 (train_teacher) + const teacherRoles = ['train_teacher']; + assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, teacherRoles), true, 'teacher 可以看到教师工作台'); + assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, teacherRoles), false, 'teacher 不能看到学员中心'); + console.log('✔ PASS: teacher sees teacher workbench only'); + + // D. 学员 (train_student) + const studentRoles = ['train_student']; + assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, studentRoles), false, 'student 不能看到教师工作台'); + assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, studentRoles), true, 'student 可以看到学员中心'); + console.log('✔ PASS: student sees student center only'); + + // E. 双重身份 (train_teacher + train_student) + const dualRoles = ['train_teacher', 'train_student']; + assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, dualRoles), true, '具备教师身份时教师工作台可见'); + assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, dualRoles), true, '具备学员身份时学员中心可见'); + console.log('✔ PASS: dual roles allow both self-service routes based on respective identities'); +} + +// 2. 测试完整的菜单树过滤与 Wrapper 清理 +{ + const mockMenuTree = [ + { + path: '/train', + component: 'Layout', + meta: { title: '人员管理' }, + children: [ + { path: 'teacher', component: 'train/teacher/index', meta: { title: '教师信息' } }, + { path: 'student', component: 'train/student/index', meta: { title: '学员信息' } } + ] + }, + { + path: '/', + component: 'Layout', + meta: null, + children: [ + { path: 'teacher', component: 'train/me/teacher/index', meta: { title: '教师工作台' } } + ] + }, + { + path: '/', + component: 'Layout', + meta: null, + children: [ + { path: 'student', component: 'train/me/student/index', meta: { title: '学员中心' } } + ] + }, + { + path: '/system', + component: 'Layout', + meta: { title: '系统管理' }, + children: [ + { path: 'user', component: 'system/user/index', meta: { title: '用户管理' } } + ] + } + ]; + + // 管理员视角下的路由过滤 + const adminFiltered = filterSelfServiceRoutes(mockMenuTree, ['admin']); + assert.strictEqual(adminFiltered.length, 2, '管理员过滤后仅保留人员管理和系统管理两个根节点'); + assert.strictEqual(adminFiltered[0].meta.title, '人员管理'); + assert.strictEqual(adminFiltered[0].children.length, 2, '人员管理下的教师信息与学员信息完好'); + assert.strictEqual(adminFiltered[1].meta.title, '系统管理'); + console.log('✔ PASS: admin menu tree completely eliminates empty wrappers for self-service routes'); + + // 教师视角下的路由过滤 + const teacherFiltered = filterSelfServiceRoutes(mockMenuTree, ['train_teacher']); + const teacherTitles = teacherFiltered.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : ''))); + assert.strictEqual(teacherTitles.includes('教师工作台'), true, '教师视角保留教师工作台'); + assert.strictEqual(teacherTitles.includes('学员中心'), false, '教师视角不包含学员中心'); + console.log('✔ PASS: teacher menu tree retains teacher workbench and excludes student center'); + + // 学员视角下的路由过滤 + const studentFiltered = filterSelfServiceRoutes(mockMenuTree, ['train_student']); + const studentTitles = studentFiltered.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : ''))); + assert.strictEqual(studentTitles.includes('教师工作台'), false, '学员视角不包含教师工作台'); + assert.strictEqual(studentTitles.includes('学员中心'), true, '学员视角保留学员中心'); + console.log('✔ PASS: student menu tree retains student center and excludes teacher workbench'); +} + +// 3. 测试 403 权限异常防抖逻辑 +{ + const FORBIDDEN_DEBOUNCE_MS = 2500; + let lastNotificationTime = 0; + let notificationCount = 0; + + function simulate403(currentTime) { + if (currentTime - lastNotificationTime > FORBIDDEN_DEBOUNCE_MS) { + lastNotificationTime = currentTime; + notificationCount++; + return true; // 弹窗触发 + } + return false; // 防抖拦截 + } + + // 模拟并发调用 6 个接口(如页面初始化时的多 Tab 数据并发) + const t0 = 100000; + assert.strictEqual(simulate403(t0), true, '首个 403 必须正常弹窗通知'); + assert.strictEqual(simulate403(t0 + 10), false, '并发第2个请求防抖拦截'); + assert.strictEqual(simulate403(t0 + 50), false, '并发第3个请求防抖拦截'); + assert.strictEqual(simulate403(t0 + 100), false, '并发第4个请求防抖拦截'); + assert.strictEqual(simulate403(t0 + 500), false, '500ms后仍处于防抖窗口,拦截'); + assert.strictEqual(simulate403(t0 + 2400), false, '2400ms后仍处于防抖窗口,拦截'); + assert.strictEqual(notificationCount, 1, '风暴期间只弹窗一次'); + + // 超过 2500ms 后的新操作 + const t1 = t0 + 2600; + assert.strictEqual(simulate403(t1), true, '超过 2500ms 后用户新操作被拒应再次弹窗'); + assert.strictEqual(notificationCount, 2); + console.log('✔ PASS: 403 notification debouncing prevents toast storm while preserving subsequent alerts'); +} + +console.log('=== All Role Navigation & Debounce Unit Tests Passed ==='); diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_student_blocked.png b/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_student_blocked.png new file mode 100644 index 000000000..11c5d829e Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_student_blocked.png differ diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_teacher_blocked.png b/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_teacher_blocked.png new file mode 100644 index 000000000..4c97de186 Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/admin_direct_teacher_blocked.png differ diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/admin_sidebar_menu.png b/docs/reports/preview-role-navigation-20260922/screenshots/admin_sidebar_menu.png new file mode 100644 index 000000000..b44bc7d6d Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/admin_sidebar_menu.png differ diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/student_center_loaded.png b/docs/reports/preview-role-navigation-20260922/screenshots/student_center_loaded.png new file mode 100644 index 000000000..dc99a887c Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/student_center_loaded.png differ diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/teacher_workbench_loaded.png b/docs/reports/preview-role-navigation-20260922/screenshots/teacher_workbench_loaded.png new file mode 100644 index 000000000..f0389024b Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/teacher_workbench_loaded.png differ diff --git a/docs/reports/preview-role-navigation-20260922/screenshots/train_admin_sidebar_menu.png b/docs/reports/preview-role-navigation-20260922/screenshots/train_admin_sidebar_menu.png new file mode 100644 index 000000000..ac8efec05 Binary files /dev/null and b/docs/reports/preview-role-navigation-20260922/screenshots/train_admin_sidebar_menu.png differ diff --git a/scripts/qa/role_navigation_matrix_browser_validator.py b/scripts/qa/role_navigation_matrix_browser_validator.py new file mode 100644 index 000000000..fb1b846c2 --- /dev/null +++ b/scripts/qa/role_navigation_matrix_browser_validator.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +角色专属自服务菜单可见性与未授权访问浏览器矩阵验证 +""" + +import os +import sys +import time +import subprocess +import requests +from playwright.sync_api import sync_playwright + +FRONTEND_URL = "http://127.0.0.1:1024" +BACKEND_URL = "http://127.0.0.1:8096" +SCREENSHOT_DIR = os.path.abspath("docs/reports/preview-role-navigation-20260922/screenshots") +os.makedirs(SCREENSHOT_DIR, exist_ok=True) + +def load_qa_env(): + env = {} + env_file = os.path.expanduser("~/.railwaysystem-qa.env") + if os.path.exists(env_file): + with open(env_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + env[k.strip()] = v.strip().strip('"') + return env + +def get_captcha_code_from_redis(uuid): + res = subprocess.check_output(['redis-cli', 'get', f'captcha_codes:{uuid}']).decode('utf-8').strip() + return res.strip('"') + +class RoleNavValidator: + def __init__(self): + self.env = load_qa_env() + self.qa_password = self.env.get("QA_ADMIN_PASSWORD") + if not self.qa_password: + raise RuntimeError("QA_ADMIN_PASSWORD is required in ~/.railwaysystem-qa.env") + self.results = {} + + def log(self, msg): + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True) + + def browser_login(self, page, username): + self.log(f"Logging in as {username}...") + page.context.clear_cookies() + try: + page.goto(f"{FRONTEND_URL}/login", wait_until="domcontentloaded") + page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }") + page.reload(wait_until="domcontentloaded") + except Exception: + page.goto(f"{FRONTEND_URL}/login", wait_until="domcontentloaded") + + page.wait_for_selector('input[placeholder="账号"]', timeout=10000) + + last_uuid = [None] + def handle_res(res): + if 'captchaImage' in res.url: + try: + last_uuid[0] = res.json().get('uuid') + except Exception: + pass + page.on('response', handle_res) + + for _ in range(50): + if last_uuid[0]: + break + page.wait_for_timeout(100) + + uuid = last_uuid[0] + code = get_captcha_code_from_redis(uuid) + page.fill('input[placeholder="账号"]', username) + page.fill('input[placeholder="密码"]', self.qa_password) + page.fill('input[placeholder="验证码"]', code) + + page.click('button:has-text("登 录")') + for _ in range(40): + page.wait_for_timeout(250) + if '/login' not in page.url: + break + page.wait_for_timeout(1500) + + def get_sidebar_menu_titles(self, page): + page.wait_for_selector(".sidebar-container", timeout=10000) + # 获取全部可见或DOM中的菜单项标题 + items = page.evaluate("""() => { + const elements = document.querySelectorAll('.sidebar-container .el-menu-item, .sidebar-container .el-submenu__title'); + return Array.from(elements).map(el => el.innerText.trim()).filter(t => t.length > 0); + }""") + return items + + def run(self): + self.log("==================================================") + self.log("STARTING ROLE NAVIGATION BROWSER MATRIX VALIDATION") + self.log("==================================================") + + with sync_playwright() as p: + browser = p.chromium.launch(channel='chrome', headless=True) + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + + # ---------------------------------------------------- + # 1. 验证 admin + # ---------------------------------------------------- + self.log(">>> [1/4] Validating admin ...") + self.browser_login(page, "admin") + admin_menus = self.get_sidebar_menu_titles(page) + self.log(f"Admin Sidebar Menus: {admin_menus}") + + admin_teacher_hidden = "教师工作台" not in admin_menus + admin_student_hidden = "学员中心" not in admin_menus + self.log(f"Admin: 教师工作台 HIDDEN = {admin_teacher_hidden}") + self.log(f"Admin: 学员中心 HIDDEN = {admin_student_hidden}") + + # 验证管理类菜单回归 + admin_people_visible = any("人员管理" in m for m in admin_menus) + admin_portrait_visible = any("学员画像" in m for m in admin_menus) + admin_system_visible = any("系统管理" in m for m in admin_menus) + self.log(f"Admin: 人员管理保留 = {admin_people_visible}, 学员画像保留 = {admin_portrait_visible}, 系统管理保留 = {admin_system_visible}") + + shot_admin_sidebar = os.path.join(SCREENSHOT_DIR, "admin_sidebar_menu.png") + page.screenshot(path=shot_admin_sidebar) + + # 测试直接未授权访问 /teacher + self.log("Admin direct navigation test to /teacher ...") + page.goto(f"{FRONTEND_URL}/teacher", wait_until="domcontentloaded") + page.wait_for_timeout(2000) + admin_direct_teacher_url = page.url + admin_teacher_toast_count = page.evaluate("() => document.querySelectorAll('.el-notification').length") + self.log(f"Admin /teacher URL: {admin_direct_teacher_url}, Notification Toast Count: {admin_teacher_toast_count}") + shot_admin_direct_teacher = os.path.join(SCREENSHOT_DIR, "admin_direct_teacher_blocked.png") + page.screenshot(path=shot_admin_direct_teacher) + + # 测试直接未授权访问 /student + self.log("Admin direct navigation test to /student ...") + page.goto(f"{FRONTEND_URL}/student", wait_until="domcontentloaded") + page.wait_for_timeout(2000) + admin_direct_student_url = page.url + admin_student_toast_count = page.evaluate("() => document.querySelectorAll('.el-notification').length") + self.log(f"Admin /student URL: {admin_direct_student_url}, Notification Toast Count: {admin_student_toast_count}") + shot_admin_direct_student = os.path.join(SCREENSHOT_DIR, "admin_direct_student_blocked.png") + page.screenshot(path=shot_admin_direct_student) + + assert admin_teacher_hidden, "Admin 菜单中不得显示【教师工作台】" + assert admin_student_hidden, "Admin 菜单中不得显示【学员中心】" + assert admin_people_visible, "Admin 菜单必须保留【人员管理】" + assert admin_portrait_visible, "Admin 菜单必须保留【学员画像】" + assert admin_teacher_toast_count <= 1, f"Admin 直接访问 /teacher 产生的 Toast 必须 <= 1,当前为 {admin_teacher_toast_count}" + assert admin_student_toast_count <= 1, f"Admin 直接访问 /student 产生的 Toast 必须 <= 1,当前为 {admin_student_toast_count}" + + # ---------------------------------------------------- + # 2. 验证 qa_train_admin + # ---------------------------------------------------- + self.log(">>> [2/4] Validating qa_train_admin ...") + self.browser_login(page, "qa_train_admin") + train_admin_menus = self.get_sidebar_menu_titles(page) + self.log(f"Train Admin Sidebar Menus: {train_admin_menus}") + + train_admin_teacher_hidden = "教师工作台" not in train_admin_menus + train_admin_student_hidden = "学员中心" not in train_admin_menus + train_admin_people_visible = any("人员管理" in m for m in train_admin_menus) + self.log(f"Train Admin: 教师工作台 HIDDEN = {train_admin_teacher_hidden}") + self.log(f"Train Admin: 学员中心 HIDDEN = {train_admin_student_hidden}") + self.log(f"Train Admin: 人员管理保留 = {train_admin_people_visible}") + + shot_train_admin_sidebar = os.path.join(SCREENSHOT_DIR, "train_admin_sidebar_menu.png") + page.screenshot(path=shot_train_admin_sidebar) + + assert train_admin_teacher_hidden, "Train Admin 菜单中不得显示【教师工作台】" + assert train_admin_student_hidden, "Train Admin 菜单中不得显示【学员中心】" + assert train_admin_people_visible, "Train Admin 菜单必须保留【人员管理】业务菜单" + + # ---------------------------------------------------- + # 3. 验证 qa_teacher_a + # ---------------------------------------------------- + self.log(">>> [3/4] Validating qa_teacher_a ...") + self.browser_login(page, "qa_teacher_a") + teacher_menus = self.get_sidebar_menu_titles(page) + self.log(f"Teacher Sidebar Menus: {teacher_menus}") + + teacher_workbench_visible = any("教师工作台" in m for m in teacher_menus) + teacher_student_hidden = "学员中心" not in teacher_menus + self.log(f"Teacher: 教师工作台 VISIBLE = {teacher_workbench_visible}") + self.log(f"Teacher: 学员中心 HIDDEN = {teacher_student_hidden}") + + assert teacher_workbench_visible, "Teacher 用户必须显示【教师工作台】" + assert teacher_student_hidden, "Teacher 用户不得显示【学员中心】" + + # 访问并验证教师工作台页面正常加载 + self.log("Visiting Teacher Workbench page ...") + page.goto(f"{FRONTEND_URL}/teacher", wait_until="networkidle") + page.wait_for_timeout(1500) + teacher_page_content = page.content() + teacher_page_loaded_ok = ("QA_R2_讲师_01" in teacher_page_content or "教师工作台" in teacher_page_content or "任教班级" in teacher_page_content) + self.log(f"Teacher Workbench Page loaded OK: {teacher_page_loaded_ok}") + shot_teacher_page = os.path.join(SCREENSHOT_DIR, "teacher_workbench_loaded.png") + page.screenshot(path=shot_teacher_page) + assert teacher_page_loaded_ok, "教师工作台页面内容必须正常加载" + + # ---------------------------------------------------- + # 4. 验证 qa_student_a + # ---------------------------------------------------- + self.log(">>> [4/4] Validating qa_student_a ...") + self.browser_login(page, "qa_student_a") + student_menus = self.get_sidebar_menu_titles(page) + self.log(f"Student Sidebar Menus: {student_menus}") + + student_center_visible = any("学员中心" in m for m in student_menus) + student_teacher_hidden = "教师工作台" not in student_menus + self.log(f"Student: 学员中心 VISIBLE = {student_center_visible}") + self.log(f"Student: 教师工作台 HIDDEN = {student_teacher_hidden}") + + assert student_center_visible, "Student 用户必须显示【学员中心】" + assert student_teacher_hidden, "Student 用户不得显示【教师工作台】" + + # 访问并验证学员中心页面正常加载 + self.log("Visiting Student Center page ...") + page.goto(f"{FRONTEND_URL}/student", wait_until="networkidle") + page.wait_for_timeout(1500) + student_page_content = page.content() + student_page_loaded_ok = ("QA_R2_学员_001" in student_page_content or "个人中心" in student_page_content or "参训记录" in student_page_content or "学员姓名" in student_page_content) + self.log(f"Student Center Page loaded OK: {student_page_loaded_ok}") + shot_student_page = os.path.join(SCREENSHOT_DIR, "student_center_loaded.png") + page.screenshot(path=shot_student_page) + assert student_page_loaded_ok, "学员中心页面内容必须正常加载" + + browser.close() + + self.log("==================================================") + self.log("ALL 4 ROLES BROWSER MATRIX VALIDATIONS PASSED!") + self.log("==================================================") + +if __name__ == "__main__": + validator = RoleNavValidator() + validator.run()