教师同步派生登录账号

同步教师名册时,若存在手机号则建号:手机号即 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
@@ -240,6 +240,26 @@ public class SyncSupport {
return missing.size(); return missing.size();
} }
/**
* 求出本次未从平台拉到的教工号(差集),供冻结账号使用。
*
* <p>与 {@link #deactivateMissingTeachers} 分开:停用签名册,冻结签账号,两者目标不同,
* 且冻结还要再走一遍比例护栏,不能合并。
*
* @param incomingJgh 本次从平台拉到的教工号
* @return 本次未拉到的教工号
*/
public List<String> selectMissingTeacherJgh(Set<String> incomingJgh) {
List<String> existing = teacherMapper.selectAllJgh(OpenplatSyncConstants.TENANT_ID);
List<String> missing = new ArrayList<>();
for (String jgh : existing) {
if (SyncScope.shouldDeactivate(jgh, incomingJgh.contains(jgh))) {
missing.add(jgh);
}
}
return missing;
}
/** /**
* 收集一列非空值 * 收集一列非空值
*/ */
@@ -0,0 +1,67 @@
package com.gxwebsoft.gxmu.openplat.account;
import lombok.Data;
import java.io.Serializable;
/**
* 教师账号同步结果。
*
* <p>与 {@code SyncResult} 分开:那三个计数表达的是「名册落库」,
* 这里表达的是「账号」这一侧的增删,两者的验收口径不同(见 issue 20)。
*
* @author Codex
* @since 2026-09-19
*/
@Data
public class AccountSyncResult implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 本次新建的账号数
*/
private int created;
/**
* 给已有账号补授 teacher 角色的数量(含新建时一并授权的)
*/
private int roleGranted;
/**
* 无手机号 / 手机号归一化后不足 6 位而跳过的人数
*/
private int skipped;
/**
* 建号或授权失败的人数(手机号撞车等),不中断整批
*/
private int failed;
/**
* 本次被冻结的账号数
*/
private int frozen;
/**
* 冻结是否因比例护栏被跳过
*/
private boolean freezeSkipped;
public void addCreated() {
this.created++;
}
public void addRoleGranted() {
this.roleGranted++;
}
public void addSkipped() {
this.skipped++;
}
public void addFailed() {
this.failed++;
}
}
@@ -0,0 +1,328 @@
package com.gxwebsoft.gxmu.openplat.account;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
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.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
/**
* 教师账号服务:从教师名册派生登录账号。
*
* <p><b>本类推翻 ADR-0001 中「教师不建登录账号」的结论</b>,见
* {@code docs/adr/0008-teacher-roster-creates-accounts.md}。学生仍走认领({@code claim} 包)。
*
* <p>三条约定:
* <ul>
* <li>手机号即 {@code username} 与 {@code phone},密码取手机号后六位;</li>
* <li>反查键是 {@code user_code = jgh}<b>不是手机号</b>——手机号在后台用户管理页可被人工修改,
* 拿它做键会在改号后断链,导致离职冻结失效;{@code user_code} 在后台前端没有任何入口;</li>
* <li><b>只建账号,不建角色</b>。{@code teacher} 角色不存在时静默跳过(不报错、不记日志、不计数),
* 运维在后台建出角色后重跑一次同步即可补齐——因此授角色逻辑同时挂在「新建」与「已存在」
* 两个分支上。</li>
* </ul>
*
* @author Codex
* @since 2026-09-19
*/
@Service
public class TeacherAccountService {
private static final Logger logger = LoggerFactory.getLogger(TeacherAccountService.class);
/**
* 教师角色编码。角色本身由运维在后台创建,代码不建角色、不硬编码 roleId
*/
private static final String ROLE_CODE_TEACHER = "teacher";
/**
* 手机号归一化后允许建号的最短长度。短于此长度取不出「后六位」
*/
private static final int MIN_PHONE_LENGTH = 6;
/**
* 单批停用比例超过该阈值时跳过冻结账号。
*
* <p>误停用名册是「数据不准」(可重跑修复),误冻结是「离职教师仍能登录」(安全问题),
* 所以两侧的取舍不对称:宁可漏冻结,不可误冻结。
*/
private static final double FREEZE_RATIO_LIMIT = 0.2D;
@Value("${gxmu.openplat-sync.teacher-account.enabled:false}")
private boolean enabled;
@Resource
private UserService userService;
@Resource
private RoleService roleService;
@Resource
private UserRoleService userRoleService;
public boolean isEnabled() {
return enabled;
}
/**
* 按名册建号或补授角色。
*
* <p>逐人 try/catch{@code saveUser} 在 username/phone 重复时直接抛异常,
* 而一个人的手机号撞车不该让整批 17,220 人停下。
*
* @param teachers 本次同步的教师名册
* @return 账号侧计数
*/
public AccountSyncResult syncAccounts(List<GxmuTeacher> teachers) {
AccountSyncResult result = new AccountSyncResult();
if (!enabled) {
logger.warn("教师建号开关未打开(gxmu.openplat-sync.teacher-account.enabled),本次跳过建号");
return result;
}
if (teachers == null || teachers.isEmpty()) {
return result;
}
for (GxmuTeacher teacher : teachers) {
try {
syncOne(teacher, result);
} catch (Exception e) {
result.addFailed();
// 日志不回显明文手机号:名册的 sjh 落盘但不外泄,日志同样不外泄
logger.warn("教师建号失败: jgh={}, 原因={}", teacher.getJgh(), e.getMessage());
}
}
logger.warn("教师建号完成: 新建 {} 个, 授权 {} 个, 跳过 {} 个, 失败 {} 个",
result.getCreated(), result.getRoleGranted(), result.getSkipped(), result.getFailed());
return result;
}
private void syncOne(GxmuTeacher teacher, AccountSyncResult result) {
String jgh = StrUtil.trimToNull(teacher.getJgh());
if (jgh == null) {
result.addSkipped();
return;
}
User existing = findAccountByJgh(jgh);
if (existing != null) {
// 已存在:绝不覆盖密码、不动任何字段,只补角色
grantTeacherRole(existing, result);
return;
}
String phone = normalizePhone(teacher.getSjh());
if (phone == null) {
// 无手机号,或归一化后取不出六位。名册照常落库,只是不建号
result.addSkipped();
return;
}
User addUser = new User();
addUser.setStatus(0);
addUser.setUsername(phone);
addUser.setPhone(phone);
addUser.setUserCode(jgh);
addUser.setRealName(teacher.getXm());
addUser.setNickname(StrUtil.blankToDefault(teacher.getXm(), "教师"));
addUser.setPassword(userService.encodePassword(lastSix(phone)));
addUser.setTenantId(OpenplatSyncConstants.TENANT_ID);
addUser.setCreateTime(new Date());
addUser.setUpdateTime(new Date());
if (!userService.saveUser(addUser)) {
result.addFailed();
logger.warn("教师建号未写入: jgh={}", jgh);
return;
}
result.addCreated();
if (addUser.getUserId() == null) {
// saveUser 内部走 baseMapper.insertMyBatis-Plus 会回填自增主键。
// 拿不到主键就没法授权,且这属于实现异常,必须留痕而不是静默跳过。
logger.warn("教师建号后未取到 userId, 本次无法授权: jgh={}", jgh);
return;
}
grantTeacherRole(addUser, result);
}
/**
* 授 teacher 角色。
*
* <p>角色不存在时<b>静默跳过</b>——这是 ADR-0008 明确的两段式:代码只建账号,
* 角色与菜单权限由运维在后台创建,建完后重跑同步即可补齐。
* 这里刻意不抛异常、不记日志、不计数。
*/
private void grantTeacherRole(User user, AccountSyncResult result) {
if (user.getUserId() == null) {
return;
}
Role role = roleService.getOne(
new QueryWrapper<Role>().eq("role_code", ROLE_CODE_TEACHER), false);
if (role == null) {
return;
}
String existRoleCode = null;
List<Role> roles = userRoleService.listByUserId(user.getUserId());
for (Role item : roles) {
if (ROLE_CODE_TEACHER.equals(item.getRoleCode())) {
existRoleCode = item.getRoleCode();
break;
}
}
if (existRoleCode != null) {
// 已有该角色,幂等
result.addRoleGranted();
return;
}
UserRole userRole = new UserRole();
userRole.setUserId(user.getUserId());
userRole.setRoleId(role.getRoleId());
userRole.setTenantId(user.getTenantId() == null
? OpenplatSyncConstants.TENANT_ID : user.getTenantId());
userRole.setCreateTime(new Date());
userRole.setUpdateTime(new Date());
userRoleService.save(userRole);
result.addRoleGranted();
}
/**
* 冻结上游已消失教师的账号。
*
* <p>带比例护栏:单批停用比例超过两成时只记 warn 并完全跳过。
* 全量同步若因平台异常(如 token 失效导致提前结束翻页)拉回部分数据,
* 差集会把大批在职教师误判为消失。
*
* @param missingJghs 本次未从平台拉到的教工号
* @param totalCount 库内教师总数,用于算比例
* @return 账号侧计数
*/
public AccountSyncResult freezeAccounts(List<String> missingJghs, int totalCount) {
AccountSyncResult result = new AccountSyncResult();
if (!enabled || missingJghs == null || missingJghs.isEmpty()) {
return result;
}
if (totalCount > 0 && (double) missingJghs.size() / totalCount > FREEZE_RATIO_LIMIT) {
result.setFreezeSkipped(true);
logger.warn("教师停用比例 {} / {} 超过 {}%,疑似平台数据异常,本次跳过冻结账号",
missingJghs.size(), totalCount, (int) (FREEZE_RATIO_LIMIT * 100));
return result;
}
for (String jgh : missingJghs) {
String code = StrUtil.trimToNull(jgh);
if (code == null) {
continue;
}
try {
// 用字符串列名而非 LambdaUpdateWrapper:后者依赖 MyBatis-Plus 的
// TableInfo 缓存,在容器外的单元测试里会抛 "can not find lambda cache"。
// 该教师可能从未建过号(无手机号),影响 0 行是正常情况。
int rows = userService.getBaseMapper().update(null,
new UpdateWrapper<User>()
.eq("user_code", code)
.eq("tenant_id", OpenplatSyncConstants.TENANT_ID)
.eq("status", 0)
.set("status", 1)
.set("update_time", new Date()));
if (rows > 0) {
result.setFrozen(result.getFrozen() + rows);
}
} catch (Exception e) {
logger.warn("教师账号冻结失败: jgh={}, 原因={}", code, e.getMessage());
}
}
if (result.getFrozen() > 0) {
logger.warn("教师账号冻结完成: {} 个已停用", result.getFrozen());
}
return result;
}
/**
* 手机号归一化:去空白、去非数字字符、剥掉国际区号。
*
* <p>存进 username 的必须是归一化后的值——带空格的 {@code 138 0013 8000}
* 用户永远输入不出来。
*
* <p><b>必须剥 {@code 86} 前缀</b>{@code +86 13800138000} 直接去非数字会得到
* {@code 8613800138000},据此建出的账号用户名与密码<b>都是错的</b>
* 而本人拿自己的手机号登录不上。平台当前实测是 11 位纯数字,
* 但这一步的成本只有一行,而错了要人工逐个订正。
*
* <p>归一化后不足六位返回 null(不建号)。
*/
static String normalizePhone(String raw) {
String trimmed = StrUtil.trimToNull(raw);
if (trimmed == null) {
return null;
}
StringBuilder digits = new StringBuilder(trimmed.length());
for (int i = 0; i < trimmed.length(); i++) {
char c = trimmed.charAt(i);
if (c >= '0' && c <= '9') {
digits.append(c);
}
}
// 剥国际区号:只认「以 + 或 00 开头」的 86,避免误伤 861 开头的 11 位号码。
// 两种写法剥掉的位数不同:+86 剥 2 位(digits 以 86 开头),0086 剥 4 位(digits 以 0086 开头)。
String text = trimmed;
int strip = 0;
if (text.startsWith("+") && digits.length() >= 2
&& digits.charAt(0) == '8' && digits.charAt(1) == '6') {
strip = 2;
} else if (text.startsWith("00") && digits.length() >= 4
&& digits.charAt(0) == '0' && digits.charAt(1) == '0'
&& digits.charAt(2) == '8' && digits.charAt(3) == '6') {
strip = 4;
}
// 剥完必须仍是 11 位手机号,否则宁可不剥
if (strip > 0 && digits.length() == strip + 11) {
digits.delete(0, strip);
}
if (digits.length() < MIN_PHONE_LENGTH) {
return null;
}
return digits.toString();
}
/**
* 取后六位作初始密码
*/
static String lastSix(String phone) {
return phone.substring(phone.length() - MIN_PHONE_LENGTH);
}
/**
* 按 user_code 反查账号。
*
* <p>不加 {@code tenant_id} 条件:{@code user_code} 不是唯一键(学生的学号也在这一列),
* 加了反而可能在跨租户场景漏查。取 limit 1 与 {@code SsoLoginServiceImpl.findByUserCode} 一致。
*/
private User findAccountByJgh(String jgh) {
return userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getUserCode, jgh)
.last("limit 1"));
}
/**
* 脱敏后的手机号,仅供日志使用
*/
static String maskPhone(String phone) {
return DesensitizedUtil.mobilePhone(phone);
}
}
@@ -8,6 +8,8 @@ import com.gxwebsoft.gxmu.openplat.OpenplatCodeService;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.SyncResult; import com.gxwebsoft.gxmu.openplat.SyncResult;
import com.gxwebsoft.gxmu.openplat.SyncSupport; import com.gxwebsoft.gxmu.openplat.SyncSupport;
import com.gxwebsoft.gxmu.openplat.account.AccountSyncResult;
import com.gxwebsoft.gxmu.openplat.account.TeacherAccountService;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -28,6 +30,9 @@ import java.util.Set;
* <p>全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位, * <p>全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位,
* 筛掉会导致班级同步的工号解析失败。 * 筛掉会导致班级同步的工号解析失败。
* *
* <p>名册落库之外还<b>派生登录账号</b>:手机号即 username/phone,后六位即密码,
* 见 {@link TeacherAccountService} 与 docs/adr/0008。
*
* @author Codex * @author Codex
* @since 2026-09-12 * @since 2026-09-12
*/ */
@@ -45,6 +50,9 @@ public class TeacherSyncHandler {
@Resource @Resource
private OpenplatCodeService codeService; private OpenplatCodeService codeService;
@Resource
private TeacherAccountService teacherAccountService;
public SyncResult sync() { public SyncResult sync() {
SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_TEACHER); SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_TEACHER);
JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_TEACHER); JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_TEACHER);
@@ -85,10 +93,20 @@ public class TeacherSyncHandler {
support.upsertTeachers(list); support.upsertTeachers(list);
result.setSuccessCount(incoming.size()); result.setSuccessCount(incoming.size());
// 名册落库之后、差集停用之前派生账号(ADR-0008)
AccountSyncResult accountResult = teacherAccountService.syncAccounts(list);
int deactivated = support.deactivateMissingTeachers(incoming); int deactivated = support.deactivateMissingTeachers(incoming);
result.setSkipCount(deactivated); result.setSkipCount(deactivated);
logger.warn("教师同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条",
incoming.size(), deactivated, result.getFailCount()); AccountSyncResult freezeResult = teacherAccountService
.freezeAccounts(support.selectMissingTeacherJgh(incoming), incoming.size());
logger.warn("教师同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条; "
+ "建号 {} 个, 授权 {} 个, 跳过建号 {} 个, 建号失败 {} 个; 冻结账号 {} 个",
incoming.size(), deactivated, result.getFailCount(),
accountResult.getCreated(), accountResult.getRoleGranted(),
accountResult.getSkipped(), accountResult.getFailed(), freezeResult.getFrozen());
return result; return result;
} }
+5
View File
@@ -119,3 +119,8 @@ gxmu:
enabled: true enabled: true
# 每天 03:30 # 每天 03:30
cron: 0 30 3 * * ? cron: 0 30 3 * * ?
# 教师同步时派生登录账号(手机号即用户名,后六位即密码),见 docs/adr/0008。
# 默认关闭:首次全量会建出上万账号,需要有意打开。
# 打开后重跑一次教师同步,即可给已有账号补授 teacher 角色。
teacher-account:
enabled: false
@@ -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"));
}
}