修复权限:首页按真实业务身份选择工作台
菜单层与 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:
1 parent
eb8ce6f85c
commit
b7eab13a83
13 files changed
+811
-54
No files matched your search
+52
-17
@@ -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)
|
||||
|
||||
+16
-2
@@ -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());
|
||||
|
||||
+302
@@ -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"));
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
|
||||
/**
|
||||
* 校验系统用户绑定的跨表互斥性与唯一性
|
||||
*
|
||||
|
||||
+32
@@ -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)
|
||||
{
|
||||
|
||||
+80
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user