From 3e2d7b65a6ba0948491dcf520bcf20bc921ee460 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 19 Sep 2026 20:39:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=99=E5=B8=88=E5=90=8C=E6=AD=A5=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步教师名册时,若存在手机号则建号:手机号即 username/phone, 后六位即密码,user_code 存教工号(这是与名册之间唯一稳定的反查键, 后台用户管理页改不了——手机号可改,用它反查会在改号后断链)。 推翻 ADR-0001 中「教师不建账号」的结论,见新增的 ADR-0008。 学生仍走认领,不受影响。 只建账号、不建角色:teacher 角色由运维在后台创建,代码查不到时 静默跳过。授角色逻辑同时挂在「新建」与「账号已存在」两个分支上, 因此运维建完角色后重跑一次同步即可给全部已有账号补齐。 停用冻结带 20% 比例护栏:全量同步若因平台异常拉回部分数据, 差集会把大批在职教师误判为消失。误停用名册是数据不准(可重跑), 误冻解是安全问题(离职教师仍能登录),两侧取舍不对称,宁可漏冻结。 开关 gxmu.openplat-sync.teacher-account.enabled 默认关闭。 --- .../gxwebsoft/gxmu/openplat/SyncSupport.java | 20 + .../openplat/account/AccountSyncResult.java | 67 ++++ .../account/TeacherAccountService.java | 328 +++++++++++++++++ .../openplat/sync/TeacherSyncHandler.java | 22 +- src/main/resources/application.yml | 5 + .../account/TeacherAccountServiceTest.java | 345 ++++++++++++++++++ 6 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/account/AccountSyncResult.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountService.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountServiceTest.java diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java index a2356d1..2375578 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java @@ -240,6 +240,26 @@ public class SyncSupport { return missing.size(); } + /** + * 求出本次未从平台拉到的教工号(差集),供冻结账号使用。 + * + *

与 {@link #deactivateMissingTeachers} 分开:停用签名册,冻结签账号,两者目标不同, + * 且冻结还要再走一遍比例护栏,不能合并。 + * + * @param incomingJgh 本次从平台拉到的教工号 + * @return 本次未拉到的教工号 + */ + public List selectMissingTeacherJgh(Set incomingJgh) { + List existing = teacherMapper.selectAllJgh(OpenplatSyncConstants.TENANT_ID); + List missing = new ArrayList<>(); + for (String jgh : existing) { + if (SyncScope.shouldDeactivate(jgh, incomingJgh.contains(jgh))) { + missing.add(jgh); + } + } + return missing; + } + /** * 收集一列非空值 */ diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/account/AccountSyncResult.java b/src/main/java/com/gxwebsoft/gxmu/openplat/account/AccountSyncResult.java new file mode 100644 index 0000000..b45527c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/account/AccountSyncResult.java @@ -0,0 +1,67 @@ +package com.gxwebsoft.gxmu.openplat.account; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 教师账号同步结果。 + * + *

与 {@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++; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountService.java b/src/main/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountService.java new file mode 100644 index 0000000..8289b18 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountService.java @@ -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; + +/** + * 教师账号服务:从教师名册派生登录账号。 + * + *

本类推翻 ADR-0001 中「教师不建登录账号」的结论,见 + * {@code docs/adr/0008-teacher-roster-creates-accounts.md}。学生仍走认领({@code claim} 包)。 + * + *

三条约定: + *

+ * + * @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; + + /** + * 单批停用比例超过该阈值时跳过冻结账号。 + * + *

误停用名册是「数据不准」(可重跑修复),误冻结是「离职教师仍能登录」(安全问题), + * 所以两侧的取舍不对称:宁可漏冻结,不可误冻结。 + */ + 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; + } + + /** + * 按名册建号或补授角色。 + * + *

逐人 try/catch:{@code saveUser} 在 username/phone 重复时直接抛异常, + * 而一个人的手机号撞车不该让整批 17,220 人停下。 + * + * @param teachers 本次同步的教师名册 + * @return 账号侧计数 + */ + public AccountSyncResult syncAccounts(List 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.insert,MyBatis-Plus 会回填自增主键。 + // 拿不到主键就没法授权,且这属于实现异常,必须留痕而不是静默跳过。 + logger.warn("教师建号后未取到 userId, 本次无法授权: jgh={}", jgh); + return; + } + grantTeacherRole(addUser, result); + } + + /** + * 授 teacher 角色。 + * + *

角色不存在时静默跳过——这是 ADR-0008 明确的两段式:代码只建账号, + * 角色与菜单权限由运维在后台创建,建完后重跑同步即可补齐。 + * 这里刻意不抛异常、不记日志、不计数。 + */ + private void grantTeacherRole(User user, AccountSyncResult result) { + if (user.getUserId() == null) { + return; + } + Role role = roleService.getOne( + new QueryWrapper().eq("role_code", ROLE_CODE_TEACHER), false); + if (role == null) { + return; + } + String existRoleCode = null; + List 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(); + } + + /** + * 冻结上游已消失教师的账号。 + * + *

带比例护栏:单批停用比例超过两成时只记 warn 并完全跳过。 + * 全量同步若因平台异常(如 token 失效导致提前结束翻页)拉回部分数据, + * 差集会把大批在职教师误判为消失。 + * + * @param missingJghs 本次未从平台拉到的教工号 + * @param totalCount 库内教师总数,用于算比例 + * @return 账号侧计数 + */ + public AccountSyncResult freezeAccounts(List 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() + .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; + } + + /** + * 手机号归一化:去空白、去非数字字符、剥掉国际区号。 + * + *

存进 username 的必须是归一化后的值——带空格的 {@code 138 0013 8000} + * 用户永远输入不出来。 + * + *

必须剥 {@code 86} 前缀:{@code +86 13800138000} 直接去非数字会得到 + * {@code 8613800138000},据此建出的账号用户名与密码都是错的, + * 而本人拿自己的手机号登录不上。平台当前实测是 11 位纯数字, + * 但这一步的成本只有一行,而错了要人工逐个订正。 + * + *

归一化后不足六位返回 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 反查账号。 + * + *

不加 {@code tenant_id} 条件:{@code user_code} 不是唯一键(学生的学号也在这一列), + * 加了反而可能在跨租户场景漏查。取 limit 1 与 {@code SsoLoginServiceImpl.findByUserCode} 一致。 + */ + private User findAccountByJgh(String jgh) { + return userService.getOne(new LambdaQueryWrapper() + .eq(User::getUserCode, jgh) + .last("limit 1")); + } + + /** + * 脱敏后的手机号,仅供日志使用 + */ + static String maskPhone(String phone) { + return DesensitizedUtil.mobilePhone(phone); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java index 6dc1f1f..62e3836 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java @@ -8,6 +8,8 @@ import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; import com.gxwebsoft.gxmu.openplat.SyncResult; 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.GxmuTeacher; import org.slf4j.Logger; @@ -28,6 +30,9 @@ import java.util.Set; *

全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位, * 筛掉会导致班级同步的工号解析失败。 * + *

名册落库之外还派生登录账号:手机号即 username/phone,后六位即密码, + * 见 {@link TeacherAccountService} 与 docs/adr/0008。 + * * @author Codex * @since 2026-09-12 */ @@ -45,6 +50,9 @@ public class TeacherSyncHandler { @Resource private OpenplatCodeService codeService; + @Resource + private TeacherAccountService teacherAccountService; + public SyncResult sync() { SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_TEACHER); JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_TEACHER); @@ -85,10 +93,20 @@ public class TeacherSyncHandler { support.upsertTeachers(list); result.setSuccessCount(incoming.size()); + // 名册落库之后、差集停用之前派生账号(ADR-0008) + AccountSyncResult accountResult = teacherAccountService.syncAccounts(list); + int deactivated = support.deactivateMissingTeachers(incoming); 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; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index db7d819..57f4689 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -119,3 +119,8 @@ gxmu: enabled: true # 每天 03:30 cron: 0 30 3 * * ? + # 教师同步时派生登录账号(手机号即用户名,后六位即密码),见 docs/adr/0008。 + # 默认关闭:首次全量会建出上万账号,需要有意打开。 + # 打开后重跑一次教师同步,即可给已有账号补授 teacher 角色。 + teacher-account: + enabled: false diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountServiceTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountServiceTest.java new file mode 100644 index 0000000..3ec053d --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/account/TeacherAccountServiceTest.java @@ -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)。 + * + *

第 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 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 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 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 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 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 mapper = mock(BaseMapper.class); + when(userService.getBaseMapper()).thenReturn(mapper); + when(mapper.update(any(), any(Wrapper.class))).thenReturn(1); + + List 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")); + } + +}