教师同步派生登录账号

同步教师名册时,若存在手机号则建号:手机号即 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();
}
/**
* 求出本次未从平台拉到的教工号(差集),供冻结账号使用。
*
* <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.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;
* <p>全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位,
* 筛掉会导致班级同步的工号解析失败。
*
* <p>名册落库之外还<b>派生登录账号</b>:手机号即 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;
}