修复权限:首页按真实业务身份选择工作台

菜单层与 API 层已按真实业务身份限制,但首页只看 role_key,导致有 train_teacher
角色却无教师档案绑定的账号先进入教师工作台再被 API 拒绝。

- 身份服务新增 hasActiveTeacherIdentity / hasActiveStudentIdentity,
  语义与原菜单过滤完全一致,异常吞掉返回 false
- SysLoginController 抽出 resolveSelfServiceIdentity 作为唯一事实源,
  自服务菜单过滤与 getInfo 新增的 selfService 标志共用该方法,不新增第三套规则
- 前端 store 新增 selfService 标志,缺失时默认全 false 并严格布尔化,登出时重置
- 自服务路由过滤与首页渲染改为按身份标志判断,且与角色授权取交集,与菜单层口径一致
- 有角色无业务档案的账号落到普通首页,Dashboard 组件根本不创建,
  不会发起任何自服务 API 请求,页面给出明确的未绑定提示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wangzhibiaoandCursor committed 2026-09-22 17:04:26 +08:00
1 parent eb8ce6f85c
commit b7eab13a83
13 files changed
+811 -54

No files matched your search

@@ -1,7 +1,9 @@
package com.ruoyi.web.controller.system;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -89,9 +91,26 @@ public class SysLoginController
ajax.put("permissions", permissions);
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
// 自服务真实业务身份标志:与自服务菜单过滤共用同一事实源,供前端首页决定是否渲染教师工作台/学员中心
ajax.put("selfService", buildSelfServiceFlags(user.getUserId()));
return ajax;
}
/**
* 构建自服务身份标志,键名与前端 store.user.selfService 结构保持一致。
*
* @param userId 系统用户ID
* @return 含 teacherWorkbench / studentCenter 两个布尔标志的映射
*/
private Map<String, Boolean> buildSelfServiceFlags(Long userId)
{
boolean[] identity = resolveSelfServiceIdentity(userId);
Map<String, Boolean> selfService = new LinkedHashMap<>();
selfService.put("teacherWorkbench", identity[0]);
selfService.put("studentCenter", identity[1]);
return selfService;
}
@Autowired(required = false)
private com.ruoyi.train.service.ITrainingIdentityService identityService;
@@ -117,6 +136,36 @@ public class SysLoginController
* 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。
*/
private List<SysMenu> filterSelfServiceMenus(List<SysMenu> menus, Long userId)
{
boolean[] identity = resolveSelfServiceIdentity(userId);
boolean hasTeacherIdentity = identity[0];
boolean hasStudentIdentity = identity[1];
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;
}
/**
* 解析用户真实业务自服务身份标志,是自服务可见性的唯一事实源。
*
* <p>自服务菜单过滤 (getRouters) 与用户信息中的 selfService 标志 (getInfo) 共用本方法,
* 保证菜单层、首页渲染层与 API 层的身份口径完全一致,不再出现第三套判断规则。</p>
*
* @param userId 系统用户ID
* @return 长度为 2 的数组:[0] 教师自服务身份,[1] 学员自服务身份
*/
private boolean[] resolveSelfServiceIdentity(Long userId)
{
boolean hasTeacherIdentity = false;
boolean hasStudentIdentity = false;
@@ -125,15 +174,13 @@ public class SysLoginController
{
try
{
com.ruoyi.train.domain.TrainTeacher teacher = identityService.getTeacherByUserId(userId);
hasTeacherIdentity = (teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0));
hasTeacherIdentity = identityService.hasActiveTeacherIdentity(userId);
}
catch (Exception ignored) {}
try
{
com.ruoyi.train.domain.TrainStudent student = identityService.getStudentByUserId(userId);
hasStudentIdentity = (student != null && (student.getStatus() == null || student.getStatus() != 0));
hasStudentIdentity = identityService.hasActiveStudentIdentity(userId);
}
catch (Exception ignored) {}
}
@@ -157,19 +204,7 @@ public class SysLoginController
}
}
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;
return new boolean[]{hasTeacherIdentity, hasStudentIdentity};
}
private boolean isSelfServiceAllowed(SysMenu m, boolean hasTeacherIdentity, boolean hasStudentIdentity)
@@ -97,6 +97,17 @@ public class RoleNavigationVisibilityContractTest
}
return null;
}
// 自服务可见性只读判定:与 getTeacherByUserId / getStudentByUserId 同一事实源
if ("hasActiveTeacherIdentity".equals(name))
{
Long uid = (Long) args[0];
return currentTeacherUserId.equals(uid) || currentDualUserId.equals(uid);
}
if ("hasActiveStudentIdentity".equals(name))
{
Long uid = (Long) args[0];
return currentStudentUserId.equals(uid) || currentDualUserId.equals(uid);
}
if ("assertTeacherIdentity".equals(name))
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
@@ -329,8 +340,11 @@ public class RoleNavigationVisibilityContractTest
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"));
// FIX-05:前端自服务过滤必须依据真实业务身份标志,不再依据 role_key
assertTrue("必须依据 teacherWorkbench 业务身份标志", utilsContent.contains("teacherWorkbench"));
assertTrue("必须依据 studentCenter 业务身份标志", utilsContent.contains("studentCenter"));
assertFalse("不得再按 train_teacher 角色判定自服务可见性", utilsContent.contains("roles.includes('train_teacher')"));
assertFalse("不得再按 train_student 角色判定自服务可见性", utilsContent.contains("roles.includes('train_student')"));
File permFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/permission.js");
assertTrue("permission.js 必须存在", permFile.exists());
@@ -0,0 +1,302 @@
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 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 java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.core.domain.entity.SysMenu;
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;
/**
* FIX-05 自服务身份口径一致性契约测试。
*
* 核心规则:
* 1. getInfo 返回体中的 selfService 标志与 getRouters 的自服务菜单过滤结果必须完全一致(同一事实源)。
* 2. 仅有 train_teacher / train_student 角色但无业务档案绑定的账号(如 qa_role_nobody):
* 菜单隐藏 且 selfService.teacherWorkbench / studentCenter 均为 false。
* 3. 业务档案停用 (status = 0) 的账号等同于无身份。
* 4. 前端首页与路由过滤必须消费该标志,而不是 role_key。
*/
public class SelfServiceIdentityConsistencyContractTest
{
private SysLoginController loginController;
/** 超级管理员:无任何业务档案绑定 */
private static final Long ADMIN_USER_ID = 1L;
/** 培训管理员:无任何业务档案绑定 */
private static final Long TRAIN_ADMIN_USER_ID = 2L;
/** 正常绑定且启用的教师 */
private static final Long TEACHER_USER_ID = 101L;
/** 正常绑定且启用的学员 */
private static final Long STUDENT_USER_ID = 201L;
/** 有 train_teacher 角色但无教师档案绑定(qa_role_nobody 场景) */
private static final Long ROLE_ONLY_USER_ID = 401L;
/** 绑定了教师档案但档案已停用 */
private static final Long DISABLED_TEACHER_USER_ID = 501L;
/** 绑定了学员档案但档案已停用 */
private static final Long DISABLED_STUDENT_USER_ID = 601L;
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();
ITrainingIdentityService mockIdentityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("getTeacherByUserId".equals(name))
{
return mockTeacher((Long) args[0]);
}
if ("getStudentByUserId".equals(name))
{
return mockStudent((Long) args[0]);
}
// 与 TrainingIdentityServiceImpl 实现口径一致:status 为空或非 0 视为有效身份
if ("hasActiveTeacherIdentity".equals(name))
{
TrainTeacher t = mockTeacher((Long) args[0]);
return t != null && (t.getStatus() == null || t.getStatus() != 0);
}
if ("hasActiveStudentIdentity".equals(name))
{
TrainStudent s = mockStudent((Long) args[0]);
return s != null && (s.getStatus() == null || s.getStatus() != 0);
}
return null;
}
);
Field field = SysLoginController.class.getDeclaredField("identityService");
field.setAccessible(true);
field.set(loginController, mockIdentityService);
}
private TrainTeacher mockTeacher(Long userId)
{
if (TEACHER_USER_ID.equals(userId))
{
TrainTeacher t = new TrainTeacher();
t.settId("T_MOCK_101");
t.setStatus(1);
return t;
}
if (DISABLED_TEACHER_USER_ID.equals(userId))
{
TrainTeacher t = new TrainTeacher();
t.settId("T_MOCK_501");
t.setStatus(0);
return t;
}
return null;
}
private TrainStudent mockStudent(Long userId)
{
if (STUDENT_USER_ID.equals(userId))
{
TrainStudent s = new TrainStudent();
s.setsId("S_MOCK_201");
s.setStatus(1);
return s;
}
if (DISABLED_STUDENT_USER_ID.equals(userId))
{
TrainStudent s = new TrainStudent();
s.setsId("S_MOCK_601");
s.setStatus(0);
return s;
}
return null;
}
private List<SysMenu> createMockMenuTree()
{
List<SysMenu> list = new ArrayList<>();
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);
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);
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(Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
method.setAccessible(true);
return (List<SysMenu>) method.invoke(loginController, createMockMenuTree(), userId);
}
@SuppressWarnings("unchecked")
private Map<String, Boolean> invokeBuildSelfServiceFlags(Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("buildSelfServiceFlags", Long.class);
method.setAccessible(true);
return (Map<String, Boolean>) method.invoke(loginController, userId);
}
/**
* 断言同一用户在菜单层与 getInfo 自服务标志层的可见性结论完全一致
*/
private void assertMenuAndFlagConsistent(Long userId, boolean expectTeacher, boolean expectStudent) throws Exception
{
List<SysMenu> filtered = invokeFilterSelfServiceMenus(userId);
boolean menuHasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean menuHasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
Map<String, Boolean> flags = invokeBuildSelfServiceFlags(userId);
assertNotNull("getInfo 必须返回 selfService 标志", flags);
assertTrue("selfService 必须包含 teacherWorkbench", flags.containsKey("teacherWorkbench"));
assertTrue("selfService 必须包含 studentCenter", flags.containsKey("studentCenter"));
assertEquals("userId=" + userId + " 教师工作台菜单可见性不符预期", expectTeacher, menuHasTeacherWorkbench);
assertEquals("userId=" + userId + " 学员中心菜单可见性不符预期", expectStudent, menuHasStudentCenter);
assertEquals("userId=" + userId + " teacherWorkbench 标志必须与菜单层一致",
menuHasTeacherWorkbench, flags.get("teacherWorkbench").booleanValue());
assertEquals("userId=" + userId + " studentCenter 标志必须与菜单层一致",
menuHasStudentCenter, flags.get("studentCenter").booleanValue());
// 系统管理菜单不属于自服务范围,任何身份下都必须保留
assertTrue("系统管理菜单不得被自服务过滤影响",
filtered.stream().anyMatch(m -> "系统管理".equals(m.getMenuName())));
}
/**
* 1. 有 train_teacher / train_student 角色但无业务档案绑定:菜单隐藏且标志均为 false
*/
@Test
public void testRoleOnlyUserHasNoSelfServiceIdentity() throws Exception
{
assertMenuAndFlagConsistent(ROLE_ONLY_USER_ID, false, false);
}
/**
* 2. 管理员(admin / train_admin)无业务档案绑定:菜单隐藏且标志均为 false
*/
@Test
public void testAdminUsersHaveNoSelfServiceIdentity() throws Exception
{
assertMenuAndFlagConsistent(ADMIN_USER_ID, false, false);
assertMenuAndFlagConsistent(TRAIN_ADMIN_USER_ID, false, false);
}
/**
* 3. 正常绑定的教师 / 学员:各自入口可见且标志为 true,互不越界
*/
@Test
public void testBoundIdentityUsersSeeOwnSelfServiceOnly() throws Exception
{
assertMenuAndFlagConsistent(TEACHER_USER_ID, true, false);
assertMenuAndFlagConsistent(STUDENT_USER_ID, false, true);
}
/**
* 4. 业务档案停用等同于无身份
*/
@Test
public void testDisabledProfileEqualsNoIdentity() throws Exception
{
assertMenuAndFlagConsistent(DISABLED_TEACHER_USER_ID, false, false);
assertMenuAndFlagConsistent(DISABLED_STUDENT_USER_ID, false, false);
}
/**
* 5. getInfo 必须真实下发 selfService 字段,且与菜单过滤共用同一解析方法
*/
@Test
public void testGetInfoExposesSelfServiceFromSharedResolver() throws Exception
{
File controllerFile = new File(PROJECT_ROOT,
"RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java");
assertTrue("SysLoginController.java 必须存在", controllerFile.exists());
String content = new String(Files.readAllBytes(controllerFile.toPath()), "UTF-8");
assertTrue("getInfo 必须下发 selfService 字段", content.contains("ajax.put(\"selfService\""));
assertTrue("自服务标志必须复用统一解析方法", content.contains("resolveSelfServiceIdentity"));
assertTrue("菜单过滤与标志构建必须共用同一解析方法",
content.indexOf("resolveSelfServiceIdentity") != content.lastIndexOf("resolveSelfServiceIdentity"));
assertTrue("既有字段不得被移除", content.contains("ajax.put(\"roles\", roles)")
&& content.contains("ajax.put(\"permissions\", permissions)"));
}
/**
* 6. 前端首页必须消费业务身份标志而不是 role_key 决定自服务面板渲染
*/
@Test
public void testFrontendIndexConsumesIdentityFlags() throws Exception
{
File indexVue = new File(PROJECT_ROOT, "build_ui/src/views/index.vue");
assertTrue("index.vue 必须存在", indexVue.exists());
String content = new String(Files.readAllBytes(indexVue.toPath()), "UTF-8");
assertTrue("首页必须读取 selfService 标志", content.contains("selfService"));
assertTrue("教师工作台渲染必须依据 teacherWorkbench", content.contains("teacherWorkbench"));
assertTrue("学员中心渲染必须依据 studentCenter", content.contains("studentCenter"));
// 自服务面板必须使用 v-if,v-show 会导致组件仍然创建并触发 403 请求
assertTrue("教师工作台必须使用 v-if/v-else-if 条件渲染",
content.contains("<teacher-dashboard v-else-if"));
assertTrue("学员中心必须使用 v-if/v-else-if 条件渲染",
content.contains("<student-dashboard v-else-if"));
assertFalse("自服务面板不得使用 v-show", content.contains("teacher-dashboard v-show")
|| content.contains("student-dashboard v-show"));
assertTrue("必须为有角色无绑定的账号提供绑定缺失提示", content.contains("hasMissingBusinessBinding"));
File storeFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/user.js");
String storeContent = new String(Files.readAllBytes(storeFile.toPath()), "UTF-8");
assertTrue("store 必须存放 selfService 状态", storeContent.contains("SET_SELF_SERVICE"));
assertTrue("store 必须从 getInfo 响应写入 selfService", storeContent.contains("res.selfService"));
}
}
@@ -52,6 +52,30 @@ public interface ITrainingIdentityService
*/
TrainStudent getStudentByUserId(Long userId);
/**
* 判断系统用户是否具备有效(未停用)的教师业务身份。
*
* <p>语义与 assertTeacherIdentity / 自服务菜单过滤完全一致:
* 绑定了教师档案且档案状态非停用 (status != 0) 时返回 true。
* 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。</p>
*
* @param userId 系统用户ID
* @return 具备有效教师业务身份返回 true
*/
boolean hasActiveTeacherIdentity(Long userId);
/**
* 判断系统用户是否具备有效(未停用)的学员业务身份。
*
* <p>语义与 assertStudentIdentity / 自服务菜单过滤完全一致:
* 绑定了学员档案且档案状态非停用 (status != 0) 时返回 true。
* 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。</p>
*
* @param userId 系统用户ID
* @return 具备有效学员业务身份返回 true
*/
boolean hasActiveStudentIdentity(Long userId);
/**
* 校验系统用户绑定的跨表互斥性与唯一性
*
@@ -100,6 +100,38 @@ public class TrainingIdentityServiceImpl implements ITrainingIdentityService
return studentMapper.selectTrainStudentByUserId(userId);
}
@Override
public boolean hasActiveTeacherIdentity(Long userId)
{
try
{
TrainTeacher teacher = getTeacherByUserId(userId);
// 与 assertTeacherIdentity、自服务菜单过滤保持同一判定口径:status 为空视为有效
return teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0);
}
catch (Exception ignored)
{
// 只读可见性判定,查询异常一律按“无身份”处理(fail closed)
return false;
}
}
@Override
public boolean hasActiveStudentIdentity(Long userId)
{
try
{
TrainStudent student = getStudentByUserId(userId);
// 与 assertStudentIdentity、自服务菜单过滤保持同一判定口径:status 为空视为有效
return student != null && (student.getStatus() == null || student.getStatus() != 0);
}
catch (Exception ignored)
{
// 只读可见性判定,查询异常一律按“无身份”处理(fail closed)
return false;
}
}
@Override
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId)
{
@@ -1,6 +1,7 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -330,6 +331,85 @@ public class TrainingIdentityServiceTest
assertEquals("S601", sId);
}
/**
* FIX-05:教师自服务身份只读判定必须与 assertTeacherIdentity 口径一致
*/
@Test
public void hasActiveTeacherIdentityMatchesAssertSemantics()
{
// 1. userId 为空:fail closed
assertFalse("userId 为空必须判定为无教师身份", identityService.hasActiveTeacherIdentity(null));
// 2. 未绑定任何教师档案(有 train_teacher 角色但无业务档案的账号)
assertFalse("未绑定教师档案必须判定为无教师身份", identityService.hasActiveTeacherIdentity(900L));
// 3. 绑定了教师档案但已停用 (status = 0)
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T900");
teacher.setUserId(900L);
teacher.setStatus(0);
teachersByTId.put("T900", teacher);
teachersByUserId.put(900L, teacher);
assertFalse("停用教师档案必须判定为无教师身份", identityService.hasActiveTeacherIdentity(900L));
// 4. 绑定且启用 (status = 1)
teacher.setStatus(1);
assertTrue("启用教师档案必须判定为有教师身份", identityService.hasActiveTeacherIdentity(900L));
// 5. status 为空视为有效(与 assertTeacherIdentity 的空值宽松判定保持一致)
teacher.setStatus(null);
assertTrue("status 为空必须判定为有教师身份", identityService.hasActiveTeacherIdentity(900L));
}
/**
* FIX-05:学员自服务身份只读判定必须与 assertStudentIdentity 口径一致
*/
@Test
public void hasActiveStudentIdentityMatchesAssertSemantics()
{
// 1. userId 为空:fail closed
assertFalse("userId 为空必须判定为无学员身份", identityService.hasActiveStudentIdentity(null));
// 2. 未绑定任何学员档案
assertFalse("未绑定学员档案必须判定为无学员身份", identityService.hasActiveStudentIdentity(901L));
// 3. 绑定了学员档案但已停用 (status = 0)
TrainStudent student = new TrainStudent();
student.setsId("S901");
student.setUserId(901L);
student.setStatus(0);
studentsBySId.put("S901", student);
studentsByUserId.put(901L, student);
assertFalse("停用学员档案必须判定为无学员身份", identityService.hasActiveStudentIdentity(901L));
// 4. 绑定且启用 (status = 1)
student.setStatus(1);
assertTrue("启用学员档案必须判定为有学员身份", identityService.hasActiveStudentIdentity(901L));
// 5. status 为空视为有效
student.setStatus(null);
assertTrue("status 为空必须判定为有学员身份", identityService.hasActiveStudentIdentity(901L));
}
/**
* FIX-05:底层查询异常时只读判定必须 fail closed,绝不放行自服务入口
*/
@Test
public void selfServiceIdentityChecksFailClosedOnMapperException()
{
TrainTeacherMapper explodingTeacherMapper = createProxy(TrainTeacherMapper.class, (proxy, method, args) -> {
throw new IllegalStateException("模拟数据库查询异常");
});
TrainStudentMapper explodingStudentMapper = createProxy(TrainStudentMapper.class, (proxy, method, args) -> {
throw new IllegalStateException("模拟数据库查询异常");
});
TrainingIdentityServiceImpl failing =
new TrainingIdentityServiceImpl(explodingTeacherMapper, explodingStudentMapper);
assertFalse("查询异常时教师身份必须判定为 false", failing.hasActiveTeacherIdentity(902L));
assertFalse("查询异常时学员身份必须判定为 false", failing.hasActiveStudentIdentity(902L));
}
@Test
public void bindAndUnbindTeacherUserLifecycle()
{
+1
View File
@@ -11,6 +11,7 @@ const getters = {
introduction: state => state.user.introduction,
roles: state => state.user.roles,
permissions: state => state.user.permissions,
selfService: state => state.user.selfService,
permission_routes: state => state.permission.routes,
topbarRouters:state => state.permission.topbarRouters,
defaultRoutes:state => state.permission.defaultRoutes,
+7 -6
View File
@@ -35,11 +35,12 @@ const permission = {
return new Promise(resolve => {
// 向后端请求路由数据
getRouters().then(res => {
const roles = (rootGetters && rootGetters.roles) || []
// 自服务路由过滤统一使用后端下发的真实业务身份标志,不再依据 role_key
const selfService = (rootGetters && rootGetters.selfService) || {}
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata, false, false, roles)
const rewriteRoutes = filterAsyncRouter(rdata, false, true, roles)
const sidebarRoutes = filterAsyncRouter(sdata, false, false, selfService)
const rewriteRoutes = filterAsyncRouter(rdata, false, true, selfService)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
router.addRoutes(asyncRoutes);
@@ -55,9 +56,9 @@ const permission = {
}
// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false, roles = []) {
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false, selfService = {}) {
return asyncRouterMap.filter(route => {
if (!isSelfServiceRouteAllowed(route, roles)) {
if (!isSelfServiceRouteAllowed(route, selfService)) {
return false
}
if (type && route.children) {
@@ -76,7 +77,7 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false, rol
}
}
if (route.children != null && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type, roles)
route.children = filterAsyncRouter(route.children, route, type, selfService)
// 如果作为wrapper的父路由在过滤后子路由为空,则一并移除
if (route.children.length === 0) {
return false
+18 -1
View File
@@ -9,7 +9,13 @@ const user = {
name: '',
avatar: '',
roles: [],
permissions: []
permissions: [],
// 真实业务自服务身份标志,来源于后端 getInfo 的 selfService 字段。
// 默认全部 false(fail closed):未确认业务档案绑定前不渲染任何自服务页面。
selfService: {
teacherWorkbench: false,
studentCenter: false
}
},
mutations: {
@@ -27,6 +33,13 @@ const user = {
},
SET_PERMISSIONS: (state, permissions) => {
state.permissions = permissions
},
SET_SELF_SERVICE: (state, selfService) => {
const flags = selfService || {}
state.selfService = {
teacherWorkbench: flags.teacherWorkbench === true,
studentCenter: flags.studentCenter === true
}
}
},
@@ -73,6 +86,8 @@ const user = {
getInfo().then(res => {
const user = res.user
const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar;
// 先写入业务身份标志,保证角色就绪时自服务判定已同步到位;后端未返回时按无身份处理
commit('SET_SELF_SERVICE', res.selfService)
if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
commit('SET_ROLES', res.roles)
commit('SET_PERMISSIONS', res.permissions)
@@ -95,6 +110,7 @@ const user = {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
commit('SET_SELF_SERVICE', null)
removeToken()
resolve()
}).catch(error => {
@@ -107,6 +123,7 @@ const user = {
FedLogOut({ commit }) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
commit('SET_SELF_SERVICE', null)
removeToken()
resolve()
})
+29 -12
View File
@@ -1,39 +1,56 @@
/**
* 角色专属自服务路由与菜单权限过滤工具
*
* 业务规则:
* 1. 教师工作台 (train/me/teacher/index):仅对具备 train_teacher 角色的用户可见。
* 2. 学员中心 (train/me/student/index):仅对具备 train_student 角色的用户可见。
* 3. 即使拥有超级管理员 (admin) 或培训管理员 (train_admin) 角色,无真实身份/角色时自服务菜单一律隐藏。
* 4. 普通管理与业务菜单(如人员管理下的教师信息、学员信息等)不受本规则过滤。
*
* 业务规则(FIX-05 身份口径统一后):
* 1. 判定依据是后端 getInfo 返回的真实业务身份标志 selfService,而不是 role_key。
* - selfService.teacherWorkbench:当前账号绑定了有效(未停用)教师档案。
* - selfService.studentCenter:当前账号绑定了有效(未停用)学员档案。
* 2. 教师工作台 (train/me/teacher/index):仅在 selfService.teacherWorkbench 为 true 时可见。
* 3. 学员中心 (train/me/student/index):仅在 selfService.studentCenter 为 true 时可见。
* 4. 仅有 train_teacher / train_student 角色但无业务档案绑定的账号,自服务入口一律隐藏,
* 与后端 SysLoginController 自服务菜单过滤、API 层身份断言保持同一事实源。
* 5. 标志缺失(后端未返回、接口异常)时按无身份处理,即 fail closed。
* 6. 普通管理与业务菜单(如人员管理下的教师信息、学员信息等)不受本规则过滤。
*/
export function isSelfServiceRouteAllowed(route, roles = []) {
/**
* 判断单个路由在当前业务身份下是否允许展示
*
* @param {Object} route 后端下发的路由节点
* @param {Object} selfService 业务身份标志 { teacherWorkbench, studentCenter }
* @returns {boolean} 允许展示返回 true
*/
export function isSelfServiceRouteAllowed(route, selfService = {}) {
if (!route) return false
const flags = selfService || {}
const comp = route.component
// 教师工作台专属自服务组件
if (comp === 'train/me/teacher/index') {
return Array.isArray(roles) && roles.includes('train_teacher')
return flags.teacherWorkbench === true
}
// 学员中心专属自服务组件
if (comp === 'train/me/student/index') {
return Array.isArray(roles) && roles.includes('train_student')
return flags.studentCenter === true
}
return true
}
/**
* 深度过滤路由树中的自服务专属路由
*
* @param {Array} routes 路由树
* @param {Object} selfService 业务身份标志 { teacherWorkbench, studentCenter }
* @returns {Array} 过滤后的路由树
*/
export function filterSelfServiceRoutes(routes, roles = []) {
export function filterSelfServiceRoutes(routes, selfService = {}) {
if (!Array.isArray(routes)) return []
return routes.reduce((acc, route) => {
if (!isSelfServiceRouteAllowed(route, roles)) {
if (!isSelfServiceRouteAllowed(route, selfService)) {
return acc
}
const item = { ...route }
if (item.children && Array.isArray(item.children)) {
item.children = filterSelfServiceRoutes(item.children, roles)
item.children = filterSelfServiceRoutes(item.children, selfService)
// 如果作为父容器wrapper在子路由被完全过滤后变为空,则移除该父容器
if (item.children.length === 0 && (item.component === 'Layout' || !item.component || item.path === '/')) {
return acc
+54 -5
View File
@@ -19,18 +19,29 @@
</el-result>
</div>
<!-- 3. 教师身份 (仅 train_teacher) -->
<!-- 3. 教师身份:必须具备真实有效的教师业务档案绑定 (v-if 保证无身份时组件不创建、不发起任何自服务请求) -->
<teacher-dashboard v-else-if="isTeacherOnly" />
<!-- 4. 学员身份 (仅 train_student) -->
<!-- 4. 学员身份:必须具备真实有效的学员业务档案绑定 -->
<student-dashboard v-else-if="isStudentOnly" />
<!-- 5. 兜底普通角色/无培训业务角色 -->
<div v-else class="app-container">
<!-- 有教师/学员角色但未关联业务档案时的温和提示,避免用户误以为系统故障 -->
<el-alert
v-if="hasMissingBusinessBinding"
title="账号尚未关联业务档案"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 15px;"
>
<span>当前账号已被分配{{ missingBindingRoleText }}角色,但尚未关联对应的业务档案,因此无法进入{{ missingBindingPageText }}。请联系教务管理员完成账号与业务档案的绑定。</span>
</el-alert>
<el-result
icon="info"
title="欢迎登录系统"
sub-title="当前账号未分配具体教学业务角色,请通过左侧导航菜单开展受限工作或联系管理员授权。"
:sub-title="fallbackSubTitle"
/>
</div>
</div>
@@ -57,6 +68,10 @@ export default {
roles() {
return this.$store.getters.roles || []
},
// 后端 getInfo 下发的真实业务身份标志,与自服务菜单过滤、API 层身份断言同源
selfService() {
return this.$store.getters.selfService || {}
},
loadingRoles() {
return !this.roles || this.roles.length === 0
},
@@ -69,15 +84,49 @@ export default {
hasStudent() {
return this.roles.includes('train_student')
},
// 具备有效教师业务档案绑定(非仅有 train_teacher 角色)
hasTeacherIdentity() {
return this.selfService.teacherWorkbench === true
},
// 具备有效学员业务档案绑定(非仅有 train_student 角色)
hasStudentIdentity() {
return this.selfService.studentCenter === true
},
hasRoleConflict() {
// 非 Admin 且同时包含教师和学员角色时,触发受控互斥拦截
return !this.isAdmin && this.hasTeacher && this.hasStudent
},
isTeacherOnly() {
return !this.isAdmin && this.hasTeacher && !this.hasStudent
// 渲染条件 = 角色授权 ∩ 真实业务身份,与菜单层(角色菜单 ∩ 身份过滤)口径一致
return !this.isAdmin && this.hasTeacher && !this.hasStudent && this.hasTeacherIdentity
},
isStudentOnly() {
return !this.isAdmin && this.hasStudent && !this.hasTeacher
return !this.isAdmin && this.hasStudent && !this.hasTeacher && this.hasStudentIdentity
},
// 有教师/学员角色但缺少对应业务档案绑定
hasMissingBusinessBinding() {
if (this.isAdmin || this.hasRoleConflict) {
return false
}
return (this.hasTeacher && !this.hasTeacherIdentity) || (this.hasStudent && !this.hasStudentIdentity)
},
missingBindingRoleText() {
if (this.hasTeacher && !this.hasTeacherIdentity) {
return '【培训教师】'
}
return '【培训学员】'
},
missingBindingPageText() {
if (this.hasTeacher && !this.hasTeacherIdentity) {
return '教师工作台'
}
return '学员个人培训中心'
},
fallbackSubTitle() {
if (this.hasMissingBusinessBinding) {
return '完成业务档案绑定后,对应的自服务工作台将自动出现在左侧导航与首页。'
}
return '当前账号未分配具体教学业务角色,请通过左侧导航菜单开展受限工作或联系管理员授权。'
}
},
methods: {
@@ -3,6 +3,14 @@ import { isSelfServiceRouteAllowed, filterSelfServiceRoutes } from '../../src/ut
console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tests ===');
// FIX-05:自服务可见性事实源已由 role_key 切换为后端下发的真实业务身份标志 selfService。
// 下列 *Identity 常量即各类账号登录后 getInfo 返回的 selfService 取值。
const ADMIN_IDENTITY = { teacherWorkbench: false, studentCenter: false };
const TRAIN_ADMIN_IDENTITY = { teacherWorkbench: false, studentCenter: false };
const TEACHER_IDENTITY = { teacherWorkbench: true, studentCenter: false };
const STUDENT_IDENTITY = { teacherWorkbench: false, studentCenter: true };
const DUAL_IDENTITY = { teacherWorkbench: true, studentCenter: true };
// 1. 测试 isSelfServiceRouteAllowed 基础路由拦截
{
const teacherWorkbench = { path: 'teacher', component: 'train/me/teacher/index', meta: { title: '教师工作台' } };
@@ -13,7 +21,7 @@ console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tes
const system = { path: 'system', component: 'Layout', meta: { title: '系统管理' } };
// A. 超级管理员 (admin)
const adminRoles = ['admin'];
const adminRoles = ADMIN_IDENTITY;
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, adminRoles), false, 'admin 不能看到教师工作台');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, adminRoles), false, 'admin 不能看到学员中心');
assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, adminRoles), true, 'admin 必须保留人员管理下的教师信息');
@@ -23,27 +31,27 @@ console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tes
console.log('✔ PASS: admin correctly blocks self-service routes while preserving management routes');
// B. 培训管理员 (train_admin)
const trainAdminRoles = ['train_admin'];
const trainAdminRoles = TRAIN_ADMIN_IDENTITY;
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'];
// C. 教师 (train_teacher 角色 + 有效教师档案绑定)
const teacherRoles = TEACHER_IDENTITY;
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'];
// D. 学员 (train_student 角色 + 有效学员档案绑定)
const studentRoles = STUDENT_IDENTITY;
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'];
// E. 双重身份 (同时绑定有效教师与学员档案)
const dualRoles = DUAL_IDENTITY;
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');
@@ -88,7 +96,7 @@ console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tes
];
// 管理员视角下的路由过滤
const adminFiltered = filterSelfServiceRoutes(mockMenuTree, ['admin']);
const adminFiltered = filterSelfServiceRoutes(mockMenuTree, ADMIN_IDENTITY);
assert.strictEqual(adminFiltered.length, 2, '管理员过滤后仅保留人员管理和系统管理两个根节点');
assert.strictEqual(adminFiltered[0].meta.title, '人员管理');
assert.strictEqual(adminFiltered[0].children.length, 2, '人员管理下的教师信息与学员信息完好');
@@ -96,14 +104,14 @@ console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tes
console.log('✔ PASS: admin menu tree completely eliminates empty wrappers for self-service routes');
// 教师视角下的路由过滤
const teacherFiltered = filterSelfServiceRoutes(mockMenuTree, ['train_teacher']);
const teacherFiltered = filterSelfServiceRoutes(mockMenuTree, TEACHER_IDENTITY);
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 studentFiltered = filterSelfServiceRoutes(mockMenuTree, STUDENT_IDENTITY);
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, '学员视角保留学员中心');
@@ -0,0 +1,177 @@
import assert from 'node:assert';
import { isSelfServiceRouteAllowed, filterSelfServiceRoutes } from '../../src/utils/selfServiceRoute.js';
console.log('=== Running Self-Service Identity Flag Tests (FIX-05) ===');
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: '系统管理' } };
// 1. 新签名:依据真实业务身份标志判定,而不是 role_key
{
const noIdentity = { teacherWorkbench: false, studentCenter: false };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, noIdentity), false, '无教师业务身份时教师工作台必须隐藏');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, noIdentity), false, '无学员业务身份时学员中心必须隐藏');
assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, noIdentity), true, '人员管理下的教师信息不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, noIdentity), true, '人员管理下的学员信息不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(portrait, noIdentity), true, '学员画像不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(system, noIdentity), true, '系统管理不受自服务过滤影响');
console.log('✔ PASS: no business identity hides both self-service entries while keeping management routes');
const teacherIdentity = { teacherWorkbench: true, studentCenter: false };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, teacherIdentity), true, '有教师业务身份时教师工作台可见');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, teacherIdentity), false, '教师身份不得看到学员中心');
console.log('✔ PASS: teacher identity sees teacher workbench only');
const studentIdentity = { teacherWorkbench: false, studentCenter: true };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, studentIdentity), false, '学员身份不得看到教师工作台');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, studentIdentity), true, '有学员业务身份时学员中心可见');
console.log('✔ PASS: student identity sees student center only');
}
// 2. Fail closed:标志缺失 / 非布尔真值一律按无身份处理
{
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench), false, '未传身份标志时必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter), false, '未传身份标志时必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, {}), false, '空标志对象必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, null), false, 'null 标志必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: undefined }), false, 'undefined 标志必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: 'true' }), false, '非布尔真值不得放行');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: 1 }), false, '数字 1 不得放行');
assert.strictEqual(isSelfServiceRouteAllowed(null, { teacherWorkbench: true }), false, '空路由必须返回 false');
console.log('✔ PASS: missing or non-boolean identity flags fail closed');
}
// 3. 关键回归:只有 role_key 没有业务档案绑定的账号(qa_role_nobody 场景)必须被拦截
{
// 旧实现传入 ['train_teacher'] 角色数组即可放行,新实现必须拒绝
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, ['train_teacher']), false, '角色数组不得再被当作身份凭据');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, ['train_student']), false, '角色数组不得再被当作身份凭据');
console.log('✔ PASS: role_key alone can no longer unlock self-service routes (qa_role_nobody regression guard)');
}
// 4. 菜单树深度过滤与空 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 titlesOf = (routes) => routes.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : '')));
// A. 管理员 / 有角色无绑定账号:两个自服务 wrapper 都被清理
const noIdentityFiltered = filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: false, studentCenter: false });
assert.strictEqual(noIdentityFiltered.length, 2, '无业务身份时仅保留人员管理与系统管理');
assert.strictEqual(noIdentityFiltered[0].meta.title, '人员管理');
assert.strictEqual(noIdentityFiltered[0].children.length, 2, '人员管理下的教师信息与学员信息完好');
assert.strictEqual(noIdentityFiltered[1].meta.title, '系统管理');
console.log('✔ PASS: identity-less user gets both self-service wrappers pruned');
// B. 教师身份
const teacherTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: true, studentCenter: false }));
assert.strictEqual(teacherTitles.includes('教师工作台'), true, '教师身份保留教师工作台');
assert.strictEqual(teacherTitles.includes('学员中心'), false, '教师身份不含学员中心');
// C. 学员身份
const studentTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: false, studentCenter: true }));
assert.strictEqual(studentTitles.includes('教师工作台'), false, '学员身份不含教师工作台');
assert.strictEqual(studentTitles.includes('学员中心'), true, '学员身份保留学员中心');
// D. 未传标志时整棵树的自服务入口都被清理
const defaultTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree));
assert.strictEqual(defaultTitles.includes('教师工作台'), false, '默认参数下教师工作台必须隐藏');
assert.strictEqual(defaultTitles.includes('学员中心'), false, '默认参数下学员中心必须隐藏');
assert.strictEqual(filterSelfServiceRoutes(null).length, 0, '非数组输入返回空数组');
console.log('✔ PASS: menu tree filtering follows identity flags in all branches');
}
// 5. 首页面板渲染判定:角色授权 ∩ 真实业务身份,并在缺失绑定时给出提示
{
function resolveHomePanel(roles, selfService) {
const flags = selfService || {};
const isAdmin = roles.includes('admin') || roles.includes('train_admin');
const hasTeacherRole = roles.includes('train_teacher');
const hasStudentRole = roles.includes('train_student');
const hasTeacherIdentity = flags.teacherWorkbench === true;
const hasStudentIdentity = flags.studentCenter === true;
const hasRoleConflict = !isAdmin && hasTeacherRole && hasStudentRole;
if (isAdmin) return { panel: 'admin', missingBinding: false };
if (hasRoleConflict) return { panel: 'conflict', missingBinding: false };
if (hasTeacherRole && !hasStudentRole && hasTeacherIdentity) return { panel: 'teacher', missingBinding: false };
if (hasStudentRole && !hasTeacherRole && hasStudentIdentity) return { panel: 'student', missingBinding: false };
return {
panel: 'fallback',
missingBinding: (hasTeacherRole && !hasTeacherIdentity) || (hasStudentRole && !hasStudentIdentity)
};
}
const bound = { teacherWorkbench: true, studentCenter: false };
const boundStudent = { teacherWorkbench: false, studentCenter: true };
const none = { teacherWorkbench: false, studentCenter: false };
// 不得回归:admin / train_admin 行为不变
assert.deepStrictEqual(resolveHomePanel(['admin'], none), { panel: 'admin', missingBinding: false }, 'admin 仍进管理驾驶舱');
assert.deepStrictEqual(resolveHomePanel(['train_admin'], none), { panel: 'admin', missingBinding: false }, 'train_admin 仍进管理驾驶舱');
// 不得回归:正常绑定的教师 / 学员行为不变
assert.strictEqual(resolveHomePanel(['train_teacher'], bound).panel, 'teacher', '绑定教师仍进教师工作台');
assert.strictEqual(resolveHomePanel(['train_student'], boundStudent).panel, 'student', '绑定学员仍进学员中心');
// 核心缺陷修复:qa_role_nobody(有 train_teacher 角色、无教师档案)
const roleNobody = resolveHomePanel(['train_teacher'], none);
assert.strictEqual(roleNobody.panel, 'fallback', '有角色无绑定不得渲染教师工作台');
assert.strictEqual(roleNobody.missingBinding, true, '有角色无绑定必须提示未关联业务档案');
const studentRoleNobody = resolveHomePanel(['train_student'], none);
assert.strictEqual(studentRoleNobody.panel, 'fallback', '有角色无绑定不得渲染学员中心');
assert.strictEqual(studentRoleNobody.missingBinding, true, '有角色无绑定必须提示未关联业务档案');
// 无培训业务角色的普通账号不应看到绑定缺失提示
const plainUser = resolveHomePanel(['common'], none);
assert.strictEqual(plainUser.panel, 'fallback', '普通账号进入兜底首页');
assert.strictEqual(plainUser.missingBinding, false, '无培训角色账号不显示绑定缺失提示');
// 角色互斥冲突分支保持原有行为,且不显示绑定缺失提示
const conflict = resolveHomePanel(['train_teacher', 'train_student'], none);
assert.strictEqual(conflict.panel, 'conflict', '角色互斥冲突分支保持原有告警');
assert.strictEqual(conflict.missingBinding, false, '冲突告警场景不叠加绑定缺失提示');
console.log('✔ PASS: home panel selection = role grant ∩ business identity, with missing-binding hint');
}
console.log('=== All Self-Service Identity Flag Unit Tests Passed ===');