From b7eab13a83e033e410466bd6e74d57f39d8661bd Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Tue, 22 Sep 2026 17:04:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9D=83=E9=99=90=EF=BC=9A?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=8C=89=E7=9C=9F=E5=AE=9E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=80=89=E6=8B=A9=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 菜单层与 API 层已按真实业务身份限制,但首页只看 role_key,导致有 train_teacher 角色却无教师档案绑定的账号先进入教师工作台再被 API 拒绝。 - 身份服务新增 hasActiveTeacherIdentity / hasActiveStudentIdentity, 语义与原菜单过滤完全一致,异常吞掉返回 false - SysLoginController 抽出 resolveSelfServiceIdentity 作为唯一事实源, 自服务菜单过滤与 getInfo 新增的 selfService 标志共用该方法,不新增第三套规则 - 前端 store 新增 selfService 标志,缺失时默认全 false 并严格布尔化,登出时重置 - 自服务路由过滤与首页渲染改为按身份标志判断,且与角色授权取交集,与菜单层口径一致 - 有角色无业务档案的账号落到普通首页,Dashboard 组件根本不创建, 不会发起任何自服务 API 请求,页面给出明确的未绑定提示 Co-authored-by: Cursor --- .../controller/system/SysLoginController.java | 69 +++- .../RoleNavigationVisibilityContractTest.java | 18 +- ...erviceIdentityConsistencyContractTest.java | 302 ++++++++++++++++++ .../service/ITrainingIdentityService.java | 24 ++ .../impl/TrainingIdentityServiceImpl.java | 32 ++ .../impl/TrainingIdentityServiceTest.java | 80 +++++ build_ui/src/store/getters.js | 1 + build_ui/src/store/modules/permission.js | 13 +- build_ui/src/store/modules/user.js | 19 +- build_ui/src/utils/selfServiceRoute.js | 41 ++- build_ui/src/views/index.vue | 59 +++- .../unit/roleNavigationVisibility.spec.mjs | 30 +- .../unit/selfServiceIdentityFlags.spec.mjs | 177 ++++++++++ 13 files changed, 811 insertions(+), 54 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/SelfServiceIdentityConsistencyContractTest.java create mode 100644 build_ui/tests/unit/selfServiceIdentityFlags.spec.mjs 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 ===');