教师同步派生登录账号
同步教师名册时,若存在手机号则建号:手机号即 username/phone, 后六位即密码,user_code 存教工号(这是与名册之间唯一稳定的反查键, 后台用户管理页改不了——手机号可改,用它反查会在改号后断链)。 推翻 ADR-0001 中「教师不建账号」的结论,见新增的 ADR-0008。 学生仍走认领,不受影响。 只建账号、不建角色:teacher 角色由运维在后台创建,代码查不到时 静默跳过。授角色逻辑同时挂在「新建」与「账号已存在」两个分支上, 因此运维建完角色后重跑一次同步即可给全部已有账号补齐。 停用冻结带 20% 比例护栏:全量同步若因平台异常拉回部分数据, 差集会把大批在职教师误判为消失。误停用名册是数据不准(可重跑), 误冻解是安全问题(离职教师仍能登录),两侧取舍不对称,宁可漏冻结。 开关 gxmu.openplat-sync.teacher-account.enabled 默认关闭。
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
package com.gxwebsoft.gxmu.openplat.account;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.Role;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.entity.UserRole;
|
||||
import com.gxwebsoft.common.system.service.RoleService;
|
||||
import com.gxwebsoft.common.system.service.UserRoleService;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 教师建号的四条行为测试(issue 20)。
|
||||
*
|
||||
* <p>第 3 条「角色不存在时不授角色也不报错」是最容易被后来者「顺手改成抛异常」的地方,
|
||||
* 它钉住的是 ADR-0008 的两段式:代码只建账号,角色由运维在后台创建。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-09-19
|
||||
*/
|
||||
class TeacherAccountServiceTest {
|
||||
|
||||
private UserService userService;
|
||||
private RoleService roleService;
|
||||
private UserRoleService userRoleService;
|
||||
private TeacherAccountService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
userService = mock(UserService.class);
|
||||
roleService = mock(RoleService.class);
|
||||
userRoleService = mock(UserRoleService.class);
|
||||
service = new TeacherAccountService();
|
||||
inject("userService", userService);
|
||||
inject("roleService", roleService);
|
||||
inject("userRoleService", userRoleService);
|
||||
// 开关默认 false,测试里显式打开
|
||||
inject("enabled", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* TeacherAccountService 用字段注入,测试直接反射塞入
|
||||
*/
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
java.lang.reflect.Field f = TeacherAccountService.class.getDeclaredField(field);
|
||||
f.setAccessible(true);
|
||||
f.set(service, value);
|
||||
}
|
||||
|
||||
private static GxmuTeacher teacher(String jgh, String xm, String sjh) {
|
||||
GxmuTeacher t = new GxmuTeacher();
|
||||
t.setJgh(jgh);
|
||||
t.setXm(xm);
|
||||
t.setSjh(sjh);
|
||||
return t;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 1. 无手机号不建号
|
||||
|
||||
@Test
|
||||
void noPhoneMeansNoAccount() {
|
||||
AccountSyncResult result = service.syncAccounts(
|
||||
Collections.singletonList(teacher("219550", "张三", null)));
|
||||
|
||||
assertEquals(0, result.getCreated());
|
||||
assertEquals(1, result.getSkipped());
|
||||
verify(userService, never()).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankOrTooShortPhoneMeansNoAccount() {
|
||||
AccountSyncResult result = service.syncAccounts(Arrays.asList(
|
||||
teacher("219550", "张三", " "),
|
||||
teacher("219551", "李四", "12345")));
|
||||
|
||||
assertEquals(0, result.getCreated());
|
||||
assertEquals(2, result.getSkipped());
|
||||
verify(userService, never()).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号会被归一化后再存:带空格/横线的号码必须能建成账号,
|
||||
* 且存进去的是纯数字(否则用户输入不出来)。
|
||||
*/
|
||||
@Test
|
||||
void phoneIsNormalizedBeforeSaving() {
|
||||
when(roleService.getOne(any(Wrapper.class), eq(false))).thenReturn(null);
|
||||
when(userService.encodePassword(anyString())).thenReturn("encoded");
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> {
|
||||
// 生产里 baseMapper.insert 会回填自增主键,桩必须复现,否则授权分支不可达
|
||||
inv.getArgument(0, User.class).setUserId(1001);
|
||||
return true;
|
||||
});
|
||||
when(userRoleService.listByUserId(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
service.syncAccounts(Collections.singletonList(
|
||||
teacher("219550", "张三", "138 0013-8000")));
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userService).saveUser(captor.capture());
|
||||
User saved = captor.getValue();
|
||||
assertEquals("13800138000", saved.getUsername());
|
||||
assertEquals("13800138000", saved.getPhone());
|
||||
assertEquals("219550", saved.getUserCode());
|
||||
assertEquals("张三", saved.getRealName());
|
||||
verify(userService).encodePassword("138000");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 2. 手机号重复不中断整批
|
||||
|
||||
/**
|
||||
* saveUser 在 username/phone 重复时直接抛异常(全局跨租户唯一校验)。
|
||||
* 一个人撞车不能让整批 17,220 人停下。
|
||||
*/
|
||||
@Test
|
||||
void duplicatePhoneFailsOneButNotTheBatch() {
|
||||
when(roleService.getOne(any(Wrapper.class), eq(false))).thenReturn(null);
|
||||
when(userService.encodePassword(anyString())).thenReturn("encoded");
|
||||
when(userService.getOne(any(Wrapper.class))).thenReturn(null);
|
||||
when(userRoleService.listByUserId(any())).thenReturn(Collections.emptyList());
|
||||
// 第一个人撞车
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenThrow(new RuntimeException("手机号已存在"))
|
||||
.thenAnswer(inv -> {
|
||||
inv.getArgument(0, User.class).setUserId(1001);
|
||||
return true;
|
||||
});
|
||||
|
||||
AccountSyncResult result = service.syncAccounts(Arrays.asList(
|
||||
teacher("219550", "张三", "13800138000"),
|
||||
teacher("219551", "李四", "13800138001"),
|
||||
teacher("219552", "王五", "13800138002")));
|
||||
|
||||
assertEquals(2, result.getCreated());
|
||||
assertEquals(1, result.getFailed());
|
||||
verify(userService, times(3)).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 3. 角色不存在时静默跳过
|
||||
|
||||
/**
|
||||
* 本仓库的 teacher 角色当前在库中并不存在(sys_role 里没有 role_code='teacher')。
|
||||
* 此时必须:账号照建、不授角色、不报错、不计数。
|
||||
* 这是 ADR-0008 两段式的核心——运维建出角色后重跑同步即可补齐。
|
||||
*/
|
||||
@Test
|
||||
void missingTeacherRoleCreatesAccountWithoutRoleAndWithoutError() {
|
||||
when(roleService.getOne(any(Wrapper.class), eq(false))).thenReturn(null);
|
||||
when(userService.encodePassword(anyString())).thenReturn("encoded");
|
||||
when(userService.getOne(any(Wrapper.class))).thenReturn(null);
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> {
|
||||
// 生产里 baseMapper.insert 会回填自增主键,桩必须复现,否则授权分支不可达
|
||||
inv.getArgument(0, User.class).setUserId(1001);
|
||||
return true;
|
||||
});
|
||||
|
||||
AccountSyncResult result = service.syncAccounts(
|
||||
Collections.singletonList(teacher("219550", "张三", "13800138000")));
|
||||
|
||||
assertEquals(1, result.getCreated());
|
||||
assertEquals(0, result.getRoleGranted());
|
||||
assertEquals(0, result.getFailed());
|
||||
verify(userRoleService, never()).save(any(UserRole.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTeacherRoleIsGranted() {
|
||||
Role role = new Role();
|
||||
role.setRoleId(299);
|
||||
role.setRoleCode("teacher");
|
||||
when(roleService.getOne(any(Wrapper.class), eq(false))).thenReturn(role);
|
||||
when(userService.encodePassword(anyString())).thenReturn("encoded");
|
||||
when(userService.getOne(any(Wrapper.class))).thenReturn(null);
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> {
|
||||
// 生产里 baseMapper.insert 会回填自增主键,桩必须复现,否则授权分支不可达
|
||||
inv.getArgument(0, User.class).setUserId(1001);
|
||||
return true;
|
||||
});
|
||||
when(userRoleService.listByUserId(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
AccountSyncResult result = service.syncAccounts(
|
||||
Collections.singletonList(teacher("219550", "张三", "13800138000")));
|
||||
|
||||
assertEquals(1, result.getCreated());
|
||||
assertEquals(1, result.getRoleGranted());
|
||||
ArgumentCaptor<UserRole> captor = ArgumentCaptor.forClass(UserRole.class);
|
||||
verify(userRoleService).save(captor.capture());
|
||||
assertEquals(299, captor.getValue().getRoleId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 已存在的账号(按 user_code=jgh 反查命中):绝不覆盖密码、不写任何字段,只补角色。
|
||||
* 这同时是「运维建完角色重跑同步」能补齐旧账号的机制。
|
||||
*/
|
||||
@Test
|
||||
void existingAccountGetsRoleButNotPasswordOverwrite() {
|
||||
User existing = new User();
|
||||
existing.setUserId(9527);
|
||||
existing.setUserCode("219550");
|
||||
existing.setPassword("keep-me");
|
||||
when(userService.getOne(any(Wrapper.class))).thenReturn(existing);
|
||||
Role role = new Role();
|
||||
role.setRoleId(299);
|
||||
role.setRoleCode("teacher");
|
||||
when(roleService.getOne(any(Wrapper.class), eq(false))).thenReturn(role);
|
||||
when(userRoleService.listByUserId(9527)).thenReturn(Collections.emptyList());
|
||||
|
||||
AccountSyncResult result = service.syncAccounts(
|
||||
Collections.singletonList(teacher("219550", "张三", "13800138000")));
|
||||
|
||||
assertEquals(0, result.getCreated());
|
||||
assertEquals(1, result.getRoleGranted());
|
||||
verify(userService, never()).saveUser(any(User.class));
|
||||
verify(userService, never()).encodePassword(anyString());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 4. 停用时冻结账号
|
||||
|
||||
@Test
|
||||
void freezeDeactivatesAccountByUserCode() {
|
||||
BaseMapper<User> mapper = mock(BaseMapper.class);
|
||||
when(userService.getBaseMapper()).thenReturn(mapper);
|
||||
when(mapper.update(any(), any(Wrapper.class))).thenReturn(1);
|
||||
|
||||
AccountSyncResult result = service.freezeAccounts(
|
||||
Collections.singletonList("219550"), 1000);
|
||||
|
||||
assertEquals(1, result.getFrozen());
|
||||
assertFalse(result.isFreezeSkipped());
|
||||
verify(mapper).update(any(), any(Wrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该教师可能从未建过号(无手机号),update 影响 0 行是正常情况,不能算错。
|
||||
*/
|
||||
@Test
|
||||
void freezeToleratesTeacherWithoutAccount() {
|
||||
BaseMapper<User> mapper = mock(BaseMapper.class);
|
||||
when(userService.getBaseMapper()).thenReturn(mapper);
|
||||
when(mapper.update(any(), any(Wrapper.class))).thenReturn(0);
|
||||
|
||||
AccountSyncResult result = service.freezeAccounts(
|
||||
Collections.singletonList("219550"), 1000);
|
||||
|
||||
assertEquals(0, result.getFrozen());
|
||||
assertFalse(result.isFreezeSkipped());
|
||||
}
|
||||
|
||||
/**
|
||||
* 比例护栏:平台异常导致大批教师「消失」时,只记 warn 并完全跳过冻结。
|
||||
* 误停用名册是数据不准(可重跑),误冻结是安全问题(离职教师仍能登录)。
|
||||
*/
|
||||
@Test
|
||||
void freezeIsSkippedWhenRatioLooksLikePlatformFailure() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (int i = 0; i < 300; i++) {
|
||||
missing.add("219" + i);
|
||||
}
|
||||
AccountSyncResult result = service.freezeAccounts(missing, 1000);
|
||||
|
||||
assertTrue(result.isFreezeSkipped());
|
||||
assertEquals(0, result.getFrozen());
|
||||
verify(userService, never()).getBaseMapper();
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezeProceedsJustBelowRatioLimit() {
|
||||
BaseMapper<User> mapper = mock(BaseMapper.class);
|
||||
when(userService.getBaseMapper()).thenReturn(mapper);
|
||||
when(mapper.update(any(), any(Wrapper.class))).thenReturn(1);
|
||||
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
missing.add("219" + i);
|
||||
}
|
||||
AccountSyncResult result = service.freezeAccounts(missing, 1000);
|
||||
|
||||
assertFalse(result.isFreezeSkipped());
|
||||
assertEquals(200, result.getFrozen());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 开关
|
||||
|
||||
@Test
|
||||
void disabledSwitchSkipsEverything() throws Exception {
|
||||
inject("enabled", false);
|
||||
|
||||
AccountSyncResult result = service.syncAccounts(
|
||||
Collections.singletonList(teacher("219550", "张三", "13800138000")));
|
||||
|
||||
assertEquals(0, result.getCreated());
|
||||
verify(userService, never()).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 纯函数
|
||||
|
||||
@Test
|
||||
void normalizePhoneStripsNonDigits() {
|
||||
assertEquals("13800138000", TeacherAccountService.normalizePhone("138 0013-8000"));
|
||||
assertEquals("13800138000", TeacherAccountService.normalizePhone("+86 13800138000"));
|
||||
assertEquals("13800138000", TeacherAccountService.normalizePhone("008613800138000"));
|
||||
assertEquals("13800138000", TeacherAccountService.normalizePhone("13800138000"));
|
||||
assertNull(TeacherAccountService.normalizePhone(null));
|
||||
assertNull(TeacherAccountService.normalizePhone(" "));
|
||||
assertNull(TeacherAccountService.normalizePhone("12345"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 861 开头的 11 位号码不能被当成「86 区号 + 手机号」误剥。
|
||||
* 只有长度 13 且以 86 开头的才算国际区号。
|
||||
*/
|
||||
@Test
|
||||
void normalizePhoneDoesNotStripNumericPrefixThatIsPartOfTheNumber() {
|
||||
assertEquals("86138001380", TeacherAccountService.normalizePhone("86138001380"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastSixTakesTrailingDigits() {
|
||||
assertEquals("138000", TeacherAccountService.lastSix("13800138000"));
|
||||
assertEquals("123456", TeacherAccountService.lastSix("123456"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user