教师同步派生登录账号
同步教师名册时,若存在手机号则建号:手机号即 username/phone, 后六位即密码,user_code 存教工号(这是与名册之间唯一稳定的反查键, 后台用户管理页改不了——手机号可改,用它反查会在改号后断链)。 推翻 ADR-0001 中「教师不建账号」的结论,见新增的 ADR-0008。 学生仍走认领,不受影响。 只建账号、不建角色:teacher 角色由运维在后台创建,代码查不到时 静默跳过。授角色逻辑同时挂在「新建」与「账号已存在」两个分支上, 因此运维建完角色后重跑一次同步即可给全部已有账号补齐。 停用冻结带 20% 比例护栏:全量同步若因平台异常拉回部分数据, 差集会把大批在职教师误判为消失。误停用名册是数据不准(可重跑), 误冻解是安全问题(离职教师仍能登录),两侧取舍不对称,宁可漏冻结。 开关 gxmu.openplat-sync.teacher-account.enabled 默认关闭。
This commit is contained in:
@@ -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.insert,MyBatis-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user