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 与 {@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} 包)。
+ *
+ * 三条约定:
+ * 误停用名册是「数据不准」(可重跑修复),误冻结是「离职教师仍能登录」(安全问题),
+ * 所以两侧的取舍不对称:宁可漏冻结,不可误冻结。
+ */
+ 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 角色不存在时静默跳过——这是 ADR-0008 明确的两段式:代码只建账号,
+ * 角色与菜单权限由运维在后台创建,建完后重跑同步即可补齐。
+ * 这里刻意不抛异常、不记日志、不计数。
+ */
+ private void grantTeacherRole(User user, AccountSyncResult result) {
+ if (user.getUserId() == null) {
+ return;
+ }
+ Role role = roleService.getOne(
+ new QueryWrapper 带比例护栏:单批停用比例超过两成时只记 warn 并完全跳过。
+ * 全量同步若因平台异常(如 token 失效导致提前结束翻页)拉回部分数据,
+ * 差集会把大批在职教师误判为消失。
+ *
+ * @param missingJghs 本次未从平台拉到的教工号
+ * @param totalCount 库内教师总数,用于算比例
+ * @return 账号侧计数
+ */
+ public AccountSyncResult freezeAccounts(List 存进 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 全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位,
* 筛掉会导致班级同步的工号解析失败。
*
+ * 名册落库之外还派生登录账号:手机号即 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
+ *
+ *
+ * @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;
+
+ /**
+ * 单批停用比例超过该阈值时跳过冻结账号。
+ *
+ *