教师同步派生登录账号

同步教师名册时,若存在手机号则建号:手机号即 username/phone,
后六位即密码,user_code 存教工号(这是与名册之间唯一稳定的反查键,
后台用户管理页改不了——手机号可改,用它反查会在改号后断链)。

推翻 ADR-0001 中「教师不建账号」的结论,见新增的 ADR-0008。
学生仍走认领,不受影响。

只建账号、不建角色:teacher 角色由运维在后台创建,代码查不到时
静默跳过。授角色逻辑同时挂在「新建」与「账号已存在」两个分支上,
因此运维建完角色后重跑一次同步即可给全部已有账号补齐。

停用冻结带 20% 比例护栏:全量同步若因平台异常拉回部分数据,
差集会把大批在职教师误判为消失。误停用名册是数据不准(可重跑),
误冻解是安全问题(离职教师仍能登录),两侧取舍不对称,宁可漏冻结。

开关 gxmu.openplat-sync.teacher-account.enabled 默认关闭。
This commit is contained in:
2026-09-19 20:39:13 +08:00
parent b6c392141e
commit 3e2d7b65a6
6 changed files with 785 additions and 2 deletions
@@ -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"));
}
}