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

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

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

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

No files matched your search

@@ -1,7 +1,9 @@
package com.ruoyi.web.controller.system;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -89,9 +91,26 @@ public class SysLoginController
ajax.put("permissions", permissions);
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
// 自服务真实业务身份标志:与自服务菜单过滤共用同一事实源,供前端首页决定是否渲染教师工作台/学员中心
ajax.put("selfService", buildSelfServiceFlags(user.getUserId()));
return ajax;
}
/**
* 构建自服务身份标志,键名与前端 store.user.selfService 结构保持一致。
*
* @param userId 系统用户ID
* @return 含 teacherWorkbench / studentCenter 两个布尔标志的映射
*/
private Map<String, Boolean> buildSelfServiceFlags(Long userId)
{
boolean[] identity = resolveSelfServiceIdentity(userId);
Map<String, Boolean> selfService = new LinkedHashMap<>();
selfService.put("teacherWorkbench", identity[0]);
selfService.put("studentCenter", identity[1]);
return selfService;
}
@Autowired(required = false)
private com.ruoyi.train.service.ITrainingIdentityService identityService;
@@ -117,6 +136,36 @@ public class SysLoginController
* 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。
*/
private List<SysMenu> filterSelfServiceMenus(List<SysMenu> menus, Long userId)
{
boolean[] identity = resolveSelfServiceIdentity(userId);
boolean hasTeacherIdentity = identity[0];
boolean hasStudentIdentity = identity[1];
List<SysMenu> result = new java.util.ArrayList<>();
for (SysMenu m : menus)
{
if (isSelfServiceAllowed(m, hasTeacherIdentity, hasStudentIdentity))
{
if (m.getChildren() != null && !m.getChildren().isEmpty())
{
m.setChildren(filterSelfServiceMenus(m.getChildren(), userId));
}
result.add(m);
}
}
return result;
}
/**
* 解析用户真实业务自服务身份标志,是自服务可见性的唯一事实源。
*
* <p>自服务菜单过滤 (getRouters) 与用户信息中的 selfService 标志 (getInfo) 共用本方法,
* 保证菜单层、首页渲染层与 API 层的身份口径完全一致,不再出现第三套判断规则。</p>
*
* @param userId 系统用户ID
* @return 长度为 2 的数组:[0] 教师自服务身份,[1] 学员自服务身份
*/
private boolean[] resolveSelfServiceIdentity(Long userId)
{
boolean hasTeacherIdentity = false;
boolean hasStudentIdentity = false;
@@ -125,15 +174,13 @@ public class SysLoginController
{
try
{
com.ruoyi.train.domain.TrainTeacher teacher = identityService.getTeacherByUserId(userId);
hasTeacherIdentity = (teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0));
hasTeacherIdentity = identityService.hasActiveTeacherIdentity(userId);
}
catch (Exception ignored) {}
try
{
com.ruoyi.train.domain.TrainStudent student = identityService.getStudentByUserId(userId);
hasStudentIdentity = (student != null && (student.getStatus() == null || student.getStatus() != 0));
hasStudentIdentity = identityService.hasActiveStudentIdentity(userId);
}
catch (Exception ignored) {}
}
@@ -157,19 +204,7 @@ public class SysLoginController
}
}
List<SysMenu> result = new java.util.ArrayList<>();
for (SysMenu m : menus)
{
if (isSelfServiceAllowed(m, hasTeacherIdentity, hasStudentIdentity))
{
if (m.getChildren() != null && !m.getChildren().isEmpty())
{
m.setChildren(filterSelfServiceMenus(m.getChildren(), userId));
}
result.add(m);
}
}
return result;
return new boolean[]{hasTeacherIdentity, hasStudentIdentity};
}
private boolean isSelfServiceAllowed(SysMenu m, boolean hasTeacherIdentity, boolean hasStudentIdentity)
@@ -97,6 +97,17 @@ public class RoleNavigationVisibilityContractTest
}
return null;
}
// 自服务可见性只读判定:与 getTeacherByUserId / getStudentByUserId 同一事实源
if ("hasActiveTeacherIdentity".equals(name))
{
Long uid = (Long) args[0];
return currentTeacherUserId.equals(uid) || currentDualUserId.equals(uid);
}
if ("hasActiveStudentIdentity".equals(name))
{
Long uid = (Long) args[0];
return currentStudentUserId.equals(uid) || currentDualUserId.equals(uid);
}
if ("assertTeacherIdentity".equals(name))
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
@@ -329,8 +340,11 @@ public class RoleNavigationVisibilityContractTest
assertTrue("selfServiceRoute.js 必须存在", utilsFile.exists());
String utilsContent = new String(Files.readAllBytes(utilsFile.toPath()), "UTF-8");
assertTrue("必须定义 isSelfServiceRouteAllowed", utilsContent.contains("isSelfServiceRouteAllowed"));
assertTrue("必须限制 train_teacher", utilsContent.contains("train_teacher"));
assertTrue("必须限制 train_student", utilsContent.contains("train_student"));
// FIX-05:前端自服务过滤必须依据真实业务身份标志,不再依据 role_key
assertTrue("必须依据 teacherWorkbench 业务身份标志", utilsContent.contains("teacherWorkbench"));
assertTrue("必须依据 studentCenter 业务身份标志", utilsContent.contains("studentCenter"));
assertFalse("不得再按 train_teacher 角色判定自服务可见性", utilsContent.contains("roles.includes('train_teacher')"));
assertFalse("不得再按 train_student 角色判定自服务可见性", utilsContent.contains("roles.includes('train_student')"));
File permFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/permission.js");
assertTrue("permission.js 必须存在", permFile.exists());
@@ -0,0 +1,302 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.core.domain.entity.SysMenu;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.service.ITrainingIdentityService;
import com.ruoyi.web.controller.system.SysLoginController;
/**
* FIX-05 自服务身份口径一致性契约测试。
*
* 核心规则:
* 1. getInfo 返回体中的 selfService 标志与 getRouters 的自服务菜单过滤结果必须完全一致(同一事实源)。
* 2. 仅有 train_teacher / train_student 角色但无业务档案绑定的账号(如 qa_role_nobody):
* 菜单隐藏 且 selfService.teacherWorkbench / studentCenter 均为 false。
* 3. 业务档案停用 (status = 0) 的账号等同于无身份。
* 4. 前端首页与路由过滤必须消费该标志,而不是 role_key。
*/
public class SelfServiceIdentityConsistencyContractTest
{
private SysLoginController loginController;
/** 超级管理员:无任何业务档案绑定 */
private static final Long ADMIN_USER_ID = 1L;
/** 培训管理员:无任何业务档案绑定 */
private static final Long TRAIN_ADMIN_USER_ID = 2L;
/** 正常绑定且启用的教师 */
private static final Long TEACHER_USER_ID = 101L;
/** 正常绑定且启用的学员 */
private static final Long STUDENT_USER_ID = 201L;
/** 有 train_teacher 角色但无教师档案绑定(qa_role_nobody 场景) */
private static final Long ROLE_ONLY_USER_ID = 401L;
/** 绑定了教师档案但档案已停用 */
private static final Long DISABLED_TEACHER_USER_ID = 501L;
/** 绑定了学员档案但档案已停用 */
private static final Long DISABLED_STUDENT_USER_ID = 601L;
private static final String PROJECT_ROOT = findProjectRoot();
private static String findProjectRoot()
{
File cur = new File(".").getAbsoluteFile();
while (cur != null)
{
if (new File(cur, "build_ui").isDirectory() && new File(cur, "RuoYi-Vue").isDirectory())
{
return cur.getAbsolutePath();
}
cur = cur.getParentFile();
}
return new File(".").getAbsolutePath();
}
@Before
public void setUp() throws Exception
{
loginController = new SysLoginController();
ITrainingIdentityService mockIdentityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("getTeacherByUserId".equals(name))
{
return mockTeacher((Long) args[0]);
}
if ("getStudentByUserId".equals(name))
{
return mockStudent((Long) args[0]);
}
// 与 TrainingIdentityServiceImpl 实现口径一致:status 为空或非 0 视为有效身份
if ("hasActiveTeacherIdentity".equals(name))
{
TrainTeacher t = mockTeacher((Long) args[0]);
return t != null && (t.getStatus() == null || t.getStatus() != 0);
}
if ("hasActiveStudentIdentity".equals(name))
{
TrainStudent s = mockStudent((Long) args[0]);
return s != null && (s.getStatus() == null || s.getStatus() != 0);
}
return null;
}
);
Field field = SysLoginController.class.getDeclaredField("identityService");
field.setAccessible(true);
field.set(loginController, mockIdentityService);
}
private TrainTeacher mockTeacher(Long userId)
{
if (TEACHER_USER_ID.equals(userId))
{
TrainTeacher t = new TrainTeacher();
t.settId("T_MOCK_101");
t.setStatus(1);
return t;
}
if (DISABLED_TEACHER_USER_ID.equals(userId))
{
TrainTeacher t = new TrainTeacher();
t.settId("T_MOCK_501");
t.setStatus(0);
return t;
}
return null;
}
private TrainStudent mockStudent(Long userId)
{
if (STUDENT_USER_ID.equals(userId))
{
TrainStudent s = new TrainStudent();
s.setsId("S_MOCK_201");
s.setStatus(1);
return s;
}
if (DISABLED_STUDENT_USER_ID.equals(userId))
{
TrainStudent s = new TrainStudent();
s.setsId("S_MOCK_601");
s.setStatus(0);
return s;
}
return null;
}
private List<SysMenu> createMockMenuTree()
{
List<SysMenu> list = new ArrayList<>();
SysMenu teacherWorkbench = new SysMenu();
teacherWorkbench.setMenuId(2239L);
teacherWorkbench.setMenuName("教师工作台");
teacherWorkbench.setPath("teacher");
teacherWorkbench.setComponent("train/me/teacher/index");
teacherWorkbench.setPerms("train:me:teacher");
list.add(teacherWorkbench);
SysMenu studentCenter = new SysMenu();
studentCenter.setMenuId(2240L);
studentCenter.setMenuName("学员中心");
studentCenter.setPath("student");
studentCenter.setComponent("train/me/student/index");
studentCenter.setPerms("train:me:student");
list.add(studentCenter);
SysMenu sysMgmt = new SysMenu();
sysMgmt.setMenuId(1L);
sysMgmt.setMenuName("系统管理");
sysMgmt.setPath("system");
sysMgmt.setComponent("Layout");
list.add(sysMgmt);
return list;
}
@SuppressWarnings("unchecked")
private List<SysMenu> invokeFilterSelfServiceMenus(Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
method.setAccessible(true);
return (List<SysMenu>) method.invoke(loginController, createMockMenuTree(), userId);
}
@SuppressWarnings("unchecked")
private Map<String, Boolean> invokeBuildSelfServiceFlags(Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("buildSelfServiceFlags", Long.class);
method.setAccessible(true);
return (Map<String, Boolean>) method.invoke(loginController, userId);
}
/**
* 断言同一用户在菜单层与 getInfo 自服务标志层的可见性结论完全一致
*/
private void assertMenuAndFlagConsistent(Long userId, boolean expectTeacher, boolean expectStudent) throws Exception
{
List<SysMenu> filtered = invokeFilterSelfServiceMenus(userId);
boolean menuHasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean menuHasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
Map<String, Boolean> flags = invokeBuildSelfServiceFlags(userId);
assertNotNull("getInfo 必须返回 selfService 标志", flags);
assertTrue("selfService 必须包含 teacherWorkbench", flags.containsKey("teacherWorkbench"));
assertTrue("selfService 必须包含 studentCenter", flags.containsKey("studentCenter"));
assertEquals("userId=" + userId + " 教师工作台菜单可见性不符预期", expectTeacher, menuHasTeacherWorkbench);
assertEquals("userId=" + userId + " 学员中心菜单可见性不符预期", expectStudent, menuHasStudentCenter);
assertEquals("userId=" + userId + " teacherWorkbench 标志必须与菜单层一致",
menuHasTeacherWorkbench, flags.get("teacherWorkbench").booleanValue());
assertEquals("userId=" + userId + " studentCenter 标志必须与菜单层一致",
menuHasStudentCenter, flags.get("studentCenter").booleanValue());
// 系统管理菜单不属于自服务范围,任何身份下都必须保留
assertTrue("系统管理菜单不得被自服务过滤影响",
filtered.stream().anyMatch(m -> "系统管理".equals(m.getMenuName())));
}
/**
* 1. 有 train_teacher / train_student 角色但无业务档案绑定:菜单隐藏且标志均为 false
*/
@Test
public void testRoleOnlyUserHasNoSelfServiceIdentity() throws Exception
{
assertMenuAndFlagConsistent(ROLE_ONLY_USER_ID, false, false);
}
/**
* 2. 管理员(admin / train_admin)无业务档案绑定:菜单隐藏且标志均为 false
*/
@Test
public void testAdminUsersHaveNoSelfServiceIdentity() throws Exception
{
assertMenuAndFlagConsistent(ADMIN_USER_ID, false, false);
assertMenuAndFlagConsistent(TRAIN_ADMIN_USER_ID, false, false);
}
/**
* 3. 正常绑定的教师 / 学员:各自入口可见且标志为 true,互不越界
*/
@Test
public void testBoundIdentityUsersSeeOwnSelfServiceOnly() throws Exception
{
assertMenuAndFlagConsistent(TEACHER_USER_ID, true, false);
assertMenuAndFlagConsistent(STUDENT_USER_ID, false, true);
}
/**
* 4. 业务档案停用等同于无身份
*/
@Test
public void testDisabledProfileEqualsNoIdentity() throws Exception
{
assertMenuAndFlagConsistent(DISABLED_TEACHER_USER_ID, false, false);
assertMenuAndFlagConsistent(DISABLED_STUDENT_USER_ID, false, false);
}
/**
* 5. getInfo 必须真实下发 selfService 字段,且与菜单过滤共用同一解析方法
*/
@Test
public void testGetInfoExposesSelfServiceFromSharedResolver() throws Exception
{
File controllerFile = new File(PROJECT_ROOT,
"RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java");
assertTrue("SysLoginController.java 必须存在", controllerFile.exists());
String content = new String(Files.readAllBytes(controllerFile.toPath()), "UTF-8");
assertTrue("getInfo 必须下发 selfService 字段", content.contains("ajax.put(\"selfService\""));
assertTrue("自服务标志必须复用统一解析方法", content.contains("resolveSelfServiceIdentity"));
assertTrue("菜单过滤与标志构建必须共用同一解析方法",
content.indexOf("resolveSelfServiceIdentity") != content.lastIndexOf("resolveSelfServiceIdentity"));
assertTrue("既有字段不得被移除", content.contains("ajax.put(\"roles\", roles)")
&& content.contains("ajax.put(\"permissions\", permissions)"));
}
/**
* 6. 前端首页必须消费业务身份标志而不是 role_key 决定自服务面板渲染
*/
@Test
public void testFrontendIndexConsumesIdentityFlags() throws Exception
{
File indexVue = new File(PROJECT_ROOT, "build_ui/src/views/index.vue");
assertTrue("index.vue 必须存在", indexVue.exists());
String content = new String(Files.readAllBytes(indexVue.toPath()), "UTF-8");
assertTrue("首页必须读取 selfService 标志", content.contains("selfService"));
assertTrue("教师工作台渲染必须依据 teacherWorkbench", content.contains("teacherWorkbench"));
assertTrue("学员中心渲染必须依据 studentCenter", content.contains("studentCenter"));
// 自服务面板必须使用 v-if,v-show 会导致组件仍然创建并触发 403 请求
assertTrue("教师工作台必须使用 v-if/v-else-if 条件渲染",
content.contains("<teacher-dashboard v-else-if"));
assertTrue("学员中心必须使用 v-if/v-else-if 条件渲染",
content.contains("<student-dashboard v-else-if"));
assertFalse("自服务面板不得使用 v-show", content.contains("teacher-dashboard v-show")
|| content.contains("student-dashboard v-show"));
assertTrue("必须为有角色无绑定的账号提供绑定缺失提示", content.contains("hasMissingBusinessBinding"));
File storeFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/user.js");
String storeContent = new String(Files.readAllBytes(storeFile.toPath()), "UTF-8");
assertTrue("store 必须存放 selfService 状态", storeContent.contains("SET_SELF_SERVICE"));
assertTrue("store 必须从 getInfo 响应写入 selfService", storeContent.contains("res.selfService"));
}
}
@@ -52,6 +52,30 @@ public interface ITrainingIdentityService
*/
TrainStudent getStudentByUserId(Long userId);
/**
* 判断系统用户是否具备有效(未停用)的教师业务身份。
*
* <p>语义与 assertTeacherIdentity / 自服务菜单过滤完全一致:
* 绑定了教师档案且档案状态非停用 (status != 0) 时返回 true。
* 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。</p>
*
* @param userId 系统用户ID
* @return 具备有效教师业务身份返回 true
*/
boolean hasActiveTeacherIdentity(Long userId);
/**
* 判断系统用户是否具备有效(未停用)的学员业务身份。
*
* <p>语义与 assertStudentIdentity / 自服务菜单过滤完全一致:
* 绑定了学员档案且档案状态非停用 (status != 0) 时返回 true。
* 内部查询异常一律吞掉并返回 false(fail closed),仅用于只读的可见性判定。</p>
*
* @param userId 系统用户ID
* @return 具备有效学员业务身份返回 true
*/
boolean hasActiveStudentIdentity(Long userId);
/**
* 校验系统用户绑定的跨表互斥性与唯一性
*
@@ -100,6 +100,38 @@ public class TrainingIdentityServiceImpl implements ITrainingIdentityService
return studentMapper.selectTrainStudentByUserId(userId);
}
@Override
public boolean hasActiveTeacherIdentity(Long userId)
{
try
{
TrainTeacher teacher = getTeacherByUserId(userId);
// 与 assertTeacherIdentity、自服务菜单过滤保持同一判定口径:status 为空视为有效
return teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0);
}
catch (Exception ignored)
{
// 只读可见性判定,查询异常一律按“无身份”处理(fail closed)
return false;
}
}
@Override
public boolean hasActiveStudentIdentity(Long userId)
{
try
{
TrainStudent student = getStudentByUserId(userId);
// 与 assertStudentIdentity、自服务菜单过滤保持同一判定口径:status 为空视为有效
return student != null && (student.getStatus() == null || student.getStatus() != 0);
}
catch (Exception ignored)
{
// 只读可见性判定,查询异常一律按“无身份”处理(fail closed)
return false;
}
}
@Override
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId)
{
@@ -1,6 +1,7 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -330,6 +331,85 @@ public class TrainingIdentityServiceTest
assertEquals("S601", sId);
}
/**
* FIX-05:教师自服务身份只读判定必须与 assertTeacherIdentity 口径一致
*/
@Test
public void hasActiveTeacherIdentityMatchesAssertSemantics()
{
// 1. userId 为空:fail closed
assertFalse("userId 为空必须判定为无教师身份", identityService.hasActiveTeacherIdentity(null));
// 2. 未绑定任何教师档案(有 train_teacher 角色但无业务档案的账号)
assertFalse("未绑定教师档案必须判定为无教师身份", identityService.hasActiveTeacherIdentity(900L));
// 3. 绑定了教师档案但已停用 (status = 0)
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T900");
teacher.setUserId(900L);
teacher.setStatus(0);
teachersByTId.put("T900", teacher);
teachersByUserId.put(900L, teacher);
assertFalse("停用教师档案必须判定为无教师身份", identityService.hasActiveTeacherIdentity(900L));
// 4. 绑定且启用 (status = 1)
teacher.setStatus(1);
assertTrue("启用教师档案必须判定为有教师身份", identityService.hasActiveTeacherIdentity(900L));
// 5. status 为空视为有效(与 assertTeacherIdentity 的空值宽松判定保持一致)
teacher.setStatus(null);
assertTrue("status 为空必须判定为有教师身份", identityService.hasActiveTeacherIdentity(900L));
}
/**
* FIX-05:学员自服务身份只读判定必须与 assertStudentIdentity 口径一致
*/
@Test
public void hasActiveStudentIdentityMatchesAssertSemantics()
{
// 1. userId 为空:fail closed
assertFalse("userId 为空必须判定为无学员身份", identityService.hasActiveStudentIdentity(null));
// 2. 未绑定任何学员档案
assertFalse("未绑定学员档案必须判定为无学员身份", identityService.hasActiveStudentIdentity(901L));
// 3. 绑定了学员档案但已停用 (status = 0)
TrainStudent student = new TrainStudent();
student.setsId("S901");
student.setUserId(901L);
student.setStatus(0);
studentsBySId.put("S901", student);
studentsByUserId.put(901L, student);
assertFalse("停用学员档案必须判定为无学员身份", identityService.hasActiveStudentIdentity(901L));
// 4. 绑定且启用 (status = 1)
student.setStatus(1);
assertTrue("启用学员档案必须判定为有学员身份", identityService.hasActiveStudentIdentity(901L));
// 5. status 为空视为有效
student.setStatus(null);
assertTrue("status 为空必须判定为有学员身份", identityService.hasActiveStudentIdentity(901L));
}
/**
* FIX-05:底层查询异常时只读判定必须 fail closed,绝不放行自服务入口
*/
@Test
public void selfServiceIdentityChecksFailClosedOnMapperException()
{
TrainTeacherMapper explodingTeacherMapper = createProxy(TrainTeacherMapper.class, (proxy, method, args) -> {
throw new IllegalStateException("模拟数据库查询异常");
});
TrainStudentMapper explodingStudentMapper = createProxy(TrainStudentMapper.class, (proxy, method, args) -> {
throw new IllegalStateException("模拟数据库查询异常");
});
TrainingIdentityServiceImpl failing =
new TrainingIdentityServiceImpl(explodingTeacherMapper, explodingStudentMapper);
assertFalse("查询异常时教师身份必须判定为 false", failing.hasActiveTeacherIdentity(902L));
assertFalse("查询异常时学员身份必须判定为 false", failing.hasActiveStudentIdentity(902L));
}
@Test
public void bindAndUnbindTeacherUserLifecycle()
{