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 9bea6fc32..af454f424 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
@@ -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 buildSelfServiceFlags(Long userId)
+ {
+ boolean[] identity = resolveSelfServiceIdentity(userId);
+ Map 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 filterSelfServiceMenus(List menus, Long userId)
+ {
+ boolean[] identity = resolveSelfServiceIdentity(userId);
+ boolean hasTeacherIdentity = identity[0];
+ boolean hasStudentIdentity = identity[1];
+
+ 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;
+ }
+
+ /**
+ * 解析用户真实业务自服务身份标志,是自服务可见性的唯一事实源。
+ *
+ * 自服务菜单过滤 (getRouters) 与用户信息中的 selfService 标志 (getInfo) 共用本方法,
+ * 保证菜单层、首页渲染层与 API 层的身份口径完全一致,不再出现第三套判断规则。
+ *
+ * @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 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)
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
index dc7827024..82db8c8f6 100644
--- 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
@@ -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());
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/SelfServiceIdentityConsistencyContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/SelfServiceIdentityConsistencyContractTest.java
new file mode 100644
index 000000000..6531b11a8
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/SelfServiceIdentityConsistencyContractTest.java
@@ -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 createMockMenuTree()
+ {
+ List 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 invokeFilterSelfServiceMenus(Long userId) throws Exception
+ {
+ Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
+ method.setAccessible(true);
+ return (List) method.invoke(loginController, createMockMenuTree(), userId);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map invokeBuildSelfServiceFlags(Long userId) throws Exception
+ {
+ Method method = SysLoginController.class.getDeclaredMethod("buildSelfServiceFlags", Long.class);
+ method.setAccessible(true);
+ return (Map) method.invoke(loginController, userId);
+ }
+
+ /**
+ * 断言同一用户在菜单层与 getInfo 自服务标志层的可见性结论完全一致
+ */
+ private void assertMenuAndFlagConsistent(Long userId, boolean expectTeacher, boolean expectStudent) throws Exception
+ {
+ List filtered = invokeFilterSelfServiceMenus(userId);
+ boolean menuHasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
+ boolean menuHasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
+
+ Map 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("语义与 assertTeacherIdentity / 自服务菜单过滤完全一致:
+ * 绑定了教师档案且档案状态非停用 (status != 0) 时返回 true。
+ * 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。
+ *
+ * @param userId 系统用户ID
+ * @return 具备有效教师业务身份返回 true
+ */
+ boolean hasActiveTeacherIdentity(Long userId);
+
+ /**
+ * 判断系统用户是否具备有效(未停用)的学员业务身份。
+ *
+ * 语义与 assertStudentIdentity / 自服务菜单过滤完全一致:
+ * 绑定了学员档案且档案状态非停用 (status != 0) 时返回 true。
+ * 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。
+ *
+ * @param userId 系统用户ID
+ * @return 具备有效学员业务身份返回 true
+ */
+ boolean hasActiveStudentIdentity(Long userId);
+
/**
* 校验系统用户绑定的跨表互斥性与唯一性
*
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java
index 1d8f13d06..73fa9d342 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java
@@ -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)
{
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java
index f70af4442..3997a97ce 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java
@@ -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()
{
diff --git a/build_ui/src/store/getters.js b/build_ui/src/store/getters.js
index 8adb1b653..fb0595664 100644
--- a/build_ui/src/store/getters.js
+++ b/build_ui/src/store/getters.js
@@ -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,
diff --git a/build_ui/src/store/modules/permission.js b/build_ui/src/store/modules/permission.js
index 05f579469..a2f134609 100644
--- a/build_ui/src/store/modules/permission.js
+++ b/build_ui/src/store/modules/permission.js
@@ -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
diff --git a/build_ui/src/store/modules/user.js b/build_ui/src/store/modules/user.js
index 4c228f856..e79a260a5 100644
--- a/build_ui/src/store/modules/user.js
+++ b/build_ui/src/store/modules/user.js
@@ -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()
})
diff --git a/build_ui/src/utils/selfServiceRoute.js b/build_ui/src/utils/selfServiceRoute.js
index acae658bf..1b34488ff 100644
--- a/build_ui/src/utils/selfServiceRoute.js
+++ b/build_ui/src/utils/selfServiceRoute.js
@@ -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
diff --git a/build_ui/src/views/index.vue b/build_ui/src/views/index.vue
index 5691d60b0..64cad4046 100644
--- a/build_ui/src/views/index.vue
+++ b/build_ui/src/views/index.vue
@@ -19,18 +19,29 @@
-
+
-
+
+
+
+ 当前账号已被分配{{ missingBindingRoleText }}角色,但尚未关联对应的业务档案,因此无法进入{{ missingBindingPageText }}。请联系教务管理员完成账号与业务档案的绑定。
+
@@ -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: {
diff --git a/build_ui/tests/unit/roleNavigationVisibility.spec.mjs b/build_ui/tests/unit/roleNavigationVisibility.spec.mjs
index 73d4ecf57..c2882bb7a 100644
--- a/build_ui/tests/unit/roleNavigationVisibility.spec.mjs
+++ b/build_ui/tests/unit/roleNavigationVisibility.spec.mjs
@@ -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, '学员视角保留学员中心');
diff --git a/build_ui/tests/unit/selfServiceIdentityFlags.spec.mjs b/build_ui/tests/unit/selfServiceIdentityFlags.spec.mjs
new file mode 100644
index 000000000..b0022f949
--- /dev/null
+++ b/build_ui/tests/unit/selfServiceIdentityFlags.spec.mjs
@@ -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 ===');