修复权限:隔离教师工作台与学员中心菜单自服务可见性

This commit is contained in:
wangzhibiao committed 2026-09-22 11:59:38 +08:00
1 parent 2b1fcdb260
commit ecfd38be17
15 files changed
+929 -17

No files matched your search

@@ -92,6 +92,9 @@ public class SysLoginController
return ajax; return ajax;
} }
@Autowired(required = false)
private com.ruoyi.train.service.ITrainingIdentityService identityService;
/** /**
* 获取路由信息 * 获取路由信息
* *
@@ -102,8 +105,99 @@ public class SysLoginController
{ {
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId); List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
if (menus != null && !menus.isEmpty())
{
menus = filterSelfServiceMenus(menus, userId);
}
return AjaxResult.success(menuService.buildMenus(menus)); return AjaxResult.success(menuService.buildMenus(menus));
} }
/**
* 根据用户真实业务身份过滤角色专属自服务菜单(教师工作台、学员中心)。
* 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。
*/
private List<SysMenu> filterSelfServiceMenus(List<SysMenu> 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<SysMenu> 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) public boolean initPasswordIsModify(Date pwdUpdateDate)
@@ -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<SysMenu> createMockMenuTree()
{
List<SysMenu> list = new ArrayList<>();
// 1. 人员管理(包含教师信息与学员信息子菜单)
SysMenu peopleMgmt = new SysMenu();
peopleMgmt.setMenuId(2159L);
peopleMgmt.setMenuName("人员管理");
peopleMgmt.setPath("people");
peopleMgmt.setComponent("Layout");
List<SysMenu> 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<SysMenu> invokeFilterSelfServiceMenus(List<SysMenu> menus, Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
method.setAccessible(true);
return (List<SysMenu>) method.invoke(loginController, menus, userId);
}
/**
* 1. 验证 admin 登录时自服务菜单隐藏,且普通管理菜单完整保留
*/
@Test
public void testAdminSelfServiceMenuHidden() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> 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<SysMenu> menus = createMockMenuTree();
List<SysMenu> 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<SysMenu> menus = createMockMenuTree();
List<SysMenu> 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<SysMenu> menus = createMockMenuTree();
List<SysMenu> 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<SysMenu> menus = createMockMenuTree();
List<SysMenu> 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()"));
}
}
+14 -7
View File
@@ -4,6 +4,7 @@ import { getRouters } from '@/api/menu'
import Layout from '@/layout/index' import Layout from '@/layout/index'
import ParentView from '@/components/ParentView' import ParentView from '@/components/ParentView'
import InnerLink from '@/layout/components/InnerLink' import InnerLink from '@/layout/components/InnerLink'
import { isSelfServiceRouteAllowed } from '@/utils/selfServiceRoute'
const permission = { const permission = {
state: { state: {
@@ -30,16 +31,15 @@ const permission = {
}, },
actions: { actions: {
// 生成路由 // 生成路由
GenerateRoutes({ commit }) { GenerateRoutes({ commit, rootGetters }) {
return new Promise(resolve => { return new Promise(resolve => {
// 向后端请求路由数据 // 向后端请求路由数据
getRouters().then(res => { getRouters().then(res => {
//console.log("getRouters_res=============="); const roles = (rootGetters && rootGetters.roles) || []
//console.log(res);
const sdata = JSON.parse(JSON.stringify(res.data)) const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data)) const rdata = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata) const sidebarRoutes = filterAsyncRouter(sdata, false, false, roles)
const rewriteRoutes = filterAsyncRouter(rdata, false, true) const rewriteRoutes = filterAsyncRouter(rdata, false, true, roles)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes); const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true }) rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
router.addRoutes(asyncRoutes); 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 => { return asyncRouterMap.filter(route => {
if (!isSelfServiceRouteAllowed(route, roles)) {
return false
}
if (type && route.children) { if (type && route.children) {
route.children = filterChildren(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) { 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 { } else {
delete route['children'] delete route['children']
delete route['redirect'] delete route['redirect']
+30 -2
View File
@@ -65,12 +65,15 @@ service.interceptors.request.use(config => {
Promise.reject(error) Promise.reject(error)
}) })
let lastForbiddenNotificationTime = 0
const FORBIDDEN_DEBOUNCE_MS = 2500 // 2.5秒内对403权限拒绝只提醒一次
// 响应拦截器 // 响应拦截器
service.interceptors.response.use(res => { service.interceptors.response.use(res => {
// 未设置状态码则默认成功状态 // 未设置状态码则默认成功状态
const code = res.data.code || 200 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') { if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
return res.data return res.data
@@ -88,6 +91,18 @@ service.interceptors.response.use(res => {
}) })
} }
return Promise.reject('无效的会话,或者会话已过期,请重新登录。') 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) { } else if (code === 500) {
Message({ message: msg, type: 'error' }) Message({ message: msg, type: 'error' })
return Promise.reject(new Error(msg)) return Promise.reject(new Error(msg))
@@ -104,6 +119,19 @@ service.interceptors.response.use(res => {
error => { error => {
console.log('err' + error) console.log('err' + error)
let { message } = 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') { if (message === 'Network Error') {
message = '后端接口连接异常' message = '后端接口连接异常'
} else if (message.includes('timeout')) { } else if (message.includes('timeout')) {
+45
View File
@@ -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
}, [])
}
@@ -374,12 +374,6 @@ export default {
methods: { methods: {
initStudentData() { initStudentData() {
this.loadProfile() this.loadProfile()
this.loadEnrollments()
this.loadTimetable()
this.loadReport()
this.loadAttendance()
this.loadDuration()
this.loadPortrait()
}, },
loadProfile() { loadProfile() {
this.profileLoading = true this.profileLoading = true
@@ -388,6 +382,13 @@ export default {
if (res.code === 200) { if (res.code === 200) {
this.studentProfile = res.data this.studentProfile = res.data
this.identityErrorMsg = '' this.identityErrorMsg = ''
// 学员身份校验通过后,再并发加载学业与选课各Tab数据
this.loadEnrollments()
this.loadTimetable()
this.loadReport()
this.loadAttendance()
this.loadDuration()
this.loadPortrait()
} else if (res.code === 403) { } else if (res.code === 403) {
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何学员档案,请联系管理员核对分配。' this.identityErrorMsg = res.msg || '当前登录账号未绑定任何学员档案,请联系管理员核对分配。'
} }
@@ -309,8 +309,6 @@ export default {
methods: { methods: {
initTeacherData() { initTeacherData() {
this.loadProfile() this.loadProfile()
this.loadMyPlans()
this.loadMyTimetable()
}, },
loadProfile() { loadProfile() {
this.profileLoading = true this.profileLoading = true
@@ -319,6 +317,9 @@ export default {
if (res.code === 200) { if (res.code === 200) {
this.teacherProfile = res.data this.teacherProfile = res.data
this.identityErrorMsg = '' this.identityErrorMsg = ''
// 教师身份校验通过后,再加载任教班级与课表日程
this.loadMyPlans()
this.loadMyTimetable()
} else if (res.code === 403) { } else if (res.code === 403) {
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何教师档案,请联系管理员分配绑定。' this.identityErrorMsg = res.msg || '当前登录账号未绑定任何教师档案,请联系管理员分配绑定。'
} }
@@ -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 ===');
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

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