接入医科大开放平台数据同步

新增 com.gxwebsoft.gxmu.openplat:开放平台客户端(oauth 鉴权、token 缓存与失效
重取、分页拉取)、部门/教师/班级/学生四段同步、异步编排与手动同步接口、学生认领、
学生名册查询与导出、每天 03:30 的定时任务、同步记录。

关键设计:
- 名册与登录账号分离,学生通过「认领」(手机号自动 / 学号姓名自助)关联 sys_user
- 组织数据以 sys_organization 为唯一载体,gxmu_college 废弃
- 平台无可用增量字段,只做全量拉取 + 幂等 upsert
- 上游消失只停用不删除,且只作用于同步来源的记录(避免误停用人工维护的研究生班)
- 平台权威字段按白名单覆盖,人工字段不触碰
- 码表可配置并保留原始码,码值实测存在两套编码混用,不做硬编码
- 手动同步为异步:提交返回批次号,前端轮询 /status 看分段进度

性能:
- 班级/部门由逐条 update 改为多行 upsert,班级 1584 条 41s -> 0.19s
- 码表解析加进程内缓存,学生同步由 >17 分钟不完成 -> 170 秒完成 98187 行

迁移脚本需手动执行(可重复执行):
  sql/20260912_create_openplat_sync_tables.sql
  sql/20260912_add_openplat_menu.sql

离线单测 43 个;另有联网集成测试 OpenPlatformClientIT(需 -Dopenplat.it=true)。
This commit is contained in:
2026-09-15 15:54:49 +08:00
parent f379291e4f
commit cd54039f28
51 changed files with 4586 additions and 3 deletions
@@ -0,0 +1,56 @@
package com.gxwebsoft.gxmu.openplat;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 医科大开放平台对接配置
*
* @author Codex
* @since 2026-09-12
*/
@Data
@ConfigurationProperties(prefix = "gxmu.openplat")
public class GxmuOpenplatProperties {
/**
* 开放平台接口地址前缀
*/
private String baseUrl = "http://210.36.49.10:15031/api/bd-api";
/**
* 应用编号
*/
private String appId;
/**
* 应用认证码
*/
private String appSecret;
/**
* 开发者账号
*/
private String kfzzh;
/**
* 开发者密码
*/
private String kfzmm;
/**
* 单次请求超时时间(毫秒)
*/
private Integer timeoutMs = 30000;
/**
* 单接口请求失败重试次数
*/
private Integer maxRetries = 2;
/**
* 分页单页大小(平台实测上限 10000)
*/
private Integer pageSize = 10000;
}
@@ -0,0 +1,270 @@
package com.gxwebsoft.gxmu.openplat;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.DES;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.constants.RedisConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 医科大开放平台客户端。
*
* <p>负责 oauth 鉴权、token 缓存与失效重取、分页拉取全量数据。
* 平台 token 会被提前失效(实测声称 14399 秒、不到 10 分钟即失效),
* 因此任何一次请求遇到 token 失效都必须清缓存重取后重试。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class OpenPlatformClient {
private static final Logger logger = LoggerFactory.getLogger(OpenPlatformClient.class);
/**
* DES 密钥。与平台文档 DesUtil 一致,java DESKeySpec 只取前 8 字节,
* 此处显式取前 8 字节,效果等价且避免依赖具体 JDK 实现。
*/
private static final String DES_KEY = "LianYi37621040";
/**
* 分页保护上限,避免异常情况下无限翻页
*/
private static final int MAX_PAGES = 50;
/**
* token 缓存提前过期时间(秒)
*/
private static final long TOKEN_EXPIRE_AHEAD_SECONDS = 300L;
@Resource
private GxmuOpenplatProperties properties;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 获取 access_token,优先取缓存
*/
public String getToken() {
String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
return refreshToken();
}
/**
* 强制重新获取 access_token 并写入缓存
*/
public synchronized String refreshToken() {
String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("grant_type", "password");
params.put("scope", "read");
params.put("username", encrypt(properties.getKfzzh()));
params.put("password", encrypt(properties.getKfzmm()));
String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/oauth/token", params,
CharsetUtil.CHARSET_UTF_8, true);
String basic = Base64.encode(properties.getAppId() + ":" + properties.getAppSecret(),
CharsetUtil.CHARSET_UTF_8);
String body;
try {
HttpResponse response = HttpRequest.post(url)
.timeout(properties.getTimeoutMs())
.header(Header.AUTHORIZATION, "Basic " + basic)
.body("")
.header(Header.CONTENT_TYPE, "application/json")
.execute();
body = response.body();
} catch (Exception e) {
throw new OpenPlatformException("开放平台鉴权请求失败", e);
}
JSONObject json = parseObject(body);
String token = json.getString("access_token");
if (StrUtil.isBlank(token)) {
throw new OpenPlatformException("开放平台鉴权失败: " + messageOf(json));
}
long expiresIn = json.getLongValue("expires_in");
long ttl = expiresIn > TOKEN_EXPIRE_AHEAD_SECONDS ? expiresIn - TOKEN_EXPIRE_AHEAD_SECONDS : 60L;
stringRedisTemplate.opsForValue().set(RedisConstants.OPENPLAT_TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
logger.warn("开放平台 token 已刷新, 有效期 {} 秒, 缓存 {} 秒", expiresIn, ttl);
return token;
}
/**
* 清除 token 缓存
*/
public void clearToken() {
stringRedisTemplate.delete(RedisConstants.OPENPLAT_TOKEN_KEY);
}
/**
* 分页拉取某接口的全量数据。
*
* <p>注意:平台返回的 {@code total} 字段是本次返回条数而非总数,
* 因此只能以「返回条数 &lt; pageSize」作为结束条件。
*
* @param jkbq 接口标签,即 /get/{jkbq} 的路径段
* @return 全量记录
*/
public JSONArray fetchAll(String jkbq) {
JSONArray all = new JSONArray();
int pageSize = properties.getPageSize() == null || properties.getPageSize() <= 0
? 10000 : properties.getPageSize();
for (int page = 1; page <= MAX_PAGES; page++) {
JSONArray rows = fetchPage(jkbq, page, pageSize);
all.addAll(rows);
logger.warn("开放平台拉取 {}(page={}) 返回 {} 条, 累计 {} 条", jkbq, page, rows.size(), all.size());
if (rows.size() < pageSize) {
break;
}
}
return all;
}
/**
* 拉取某一页
*/
public JSONArray fetchPage(String jkbq, int page, int pageSize) {
int attempts = properties.getMaxRetries() == null || properties.getMaxRetries() < 1
? 1 : properties.getMaxRetries();
RuntimeException lastException = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
Map<String, Object> params = new LinkedHashMap<>();
params.put("access_token", getToken());
params.put("page", page);
params.put("pageSize", pageSize);
String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/get/" + jkbq, params,
CharsetUtil.CHARSET_UTF_8, true);
String body = HttpRequest.get(url)
.timeout(properties.getTimeoutMs())
.header(Header.ACCEPT, "application/json")
.execute()
.body();
JSONObject json = parseObject(body);
if (isTokenInvalid(json)) {
logger.warn("开放平台 token 失效, 重新获取后重试: {} page={}", jkbq, page);
clearToken();
continue;
}
int code = json.getIntValue("code");
if (code != 200) {
throw new OpenPlatformException("开放平台接口 " + jkbq + " 返回异常: " + messageOf(json));
}
JSONArray data = json.getJSONArray("data");
return data == null ? new JSONArray() : data;
} catch (OpenPlatformException e) {
throw e;
} catch (Exception e) {
lastException = new OpenPlatformException(
"调用开放平台接口失败: " + jkbq + " page=" + page, e);
logger.warn("调用开放平台接口失败, 第 {}/{} 次: {} page={}", attempt, attempts, jkbq, page);
}
}
clearToken();
if (lastException != null) {
throw lastException;
}
throw new OpenPlatformException("开放平台接口调用失败(可能为 token 持续失效): " + jkbq);
}
/**
* DES 加密,输出大写十六进制。与平台文档 DesUtil.encrypt 等价。
*/
public static String encrypt(String plain) {
if (plain == null) {
return null;
}
byte[] key = DES_KEY.substring(0, 8).getBytes(StandardCharsets.UTF_8);
DES des = SecureUtil.des(key);
return des.encryptHex(plain).toUpperCase(Locale.ROOT);
}
/**
* 大小写不敏感地取字段值。
*
* <p>平台的 findColumn 元数据返回小写字段名,实际数据却是大写(如 XH),
* 因此不能直接按元数据的字段名取值。
*/
public static String field(JSONObject row, String name) {
if (row == null || name == null) {
return null;
}
Object value = row.get(name);
if (value == null) {
value = row.get(name.toUpperCase(Locale.ROOT));
}
if (value == null) {
value = row.get(name.toLowerCase(Locale.ROOT));
}
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return "NULL".equalsIgnoreCase(text) ? null : text;
}
private boolean isTokenInvalid(JSONObject json) {
if (json == null) {
return false;
}
String code = json.getString("code");
if ("401".equals(code)) {
return true;
}
String message = json.getString("message");
if (message != null && message.contains("过期或无效")) {
return true;
}
String description = json.getString("error_description");
return description != null && description.toLowerCase(Locale.ROOT).contains("invalid access token");
}
private String messageOf(JSONObject json) {
if (json == null) {
return "响应为空";
}
String message = json.getString("message");
if (StrUtil.isNotBlank(message)) {
return message;
}
String description = json.getString("error_description");
return StrUtil.isNotBlank(description) ? description : json.toJSONString();
}
private JSONObject parseObject(String body) {
if (StrUtil.isBlank(body)) {
throw new OpenPlatformException("开放平台返回空响应");
}
try {
return JSON.parseObject(body);
} catch (Exception e) {
throw new OpenPlatformException("开放平台返回内容无法解析为 JSON", e);
}
}
}
@@ -0,0 +1,21 @@
package com.gxwebsoft.gxmu.openplat;
/**
* 开放平台调用异常
*
* @author Codex
* @since 2026-09-12
*/
public class OpenPlatformException extends RuntimeException {
private static final long serialVersionUID = 1L;
public OpenPlatformException(String message) {
super(message);
}
public OpenPlatformException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,247 @@
package com.gxwebsoft.gxmu.openplat;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.system.entity.Dict;
import com.gxwebsoft.common.system.entity.DictData;
import com.gxwebsoft.common.system.service.DictDataService;
import com.gxwebsoft.common.system.service.DictService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* 开放平台码值映射。
*
* <p>平台只返回代码不返回名称,且平台本身没有任何码表接口(见 docs/adr/0006)。
* 映射放在 sys_dict / sys_dict_data 中,key 为 dictCodedict_data_code 存平台原始码,
* dict_data_name 存解析后的名称。
*
* <p><b>性能要点</b>:一次全量同步要解码近三十万次(性别/民族/政治面貌 × 9.8 万行)。
* 早期实现每次解码都去查一次 Redis、缓存未命中再查两次数据库,导致学生同步跑了二十分钟仍未完成。
* 因此这里加了<b>进程内缓存</b>:每本字典在一次运行内只真正加载一次,之后都是本地 Map 查找。
*
* <p><b>容错</b>:码表加载失败只让当次解析返回 null,绝不让异常冒泡——
* 否则一次瞬时抖动就会中断整个 9.8 万行的同步。
*
* <p>实测平台代码存在两套编码混用(民族码 `1` 与 `01` 并存、政治面貌码 `3` 与 `03` 并存),
* 因此匹配前会做归一化。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class OpenplatCodeService {
private static final Logger logger = LoggerFactory.getLogger(OpenplatCodeService.class);
/**
* 性别码字典
*/
public static final String DICT_SEX = "openplat_sex";
/**
* 民族码字典
*/
public static final String DICT_NATION = "openplat_nation";
/**
* 政治面貌码字典
*/
public static final String DICT_POLITICS = "openplat_politics";
private static final String CACHE_KEY_PREFIX = "gxmu:openplat:dict:";
private static final long CACHE_TTL_SECONDS = 3600L;
/**
* 进程内缓存有效期。码表改动后最多 10 分钟生效,无需重启。
*/
private static final long LOCAL_TTL_MILLIS = 10 * 60 * 1000L;
@Resource
private DictService dictService;
@Resource
private DictDataService dictDataService;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 码表进程内缓存
*/
private final Map<String, CachedDict> localCache = new ConcurrentHashMap<>();
/**
* 已经告警过的「字典 + 码」组合。
*
* <p>民族/政治面貌码表在 RS_JZGXX 授权前本就是空的,若每行都告警会产生十几万条日志。
*/
private final Set<String> warnedCodes = ConcurrentHashMap.newKeySet();
/**
* 把平台原始码解析为名称。
*
* @param dictCode 字典编码,如 openplat_nation
* @param rawCode 平台返回的原始码
* @return 解析出的名称;解析不出或码表不可用时返回 null
*/
public String decode(String dictCode, String rawCode) {
if (StrUtil.isBlank(dictCode) || StrUtil.isBlank(rawCode)) {
return null;
}
Map<String, String> dict;
try {
dict = loadDict(dictCode);
} catch (Exception e) {
logger.warn("加载码表失败, 本次解析跳过: dict={}, err={}", dictCode, e.getMessage());
return null;
}
if (dict.isEmpty()) {
return null;
}
String name = lookup(dict, rawCode.trim());
if (name == null && warnedCodes.add(dictCode + '=' + rawCode)) {
logger.warn("开放平台码值无法解析(同一码只告警一次): dict={} code={}", dictCode, rawCode);
}
return name;
}
/**
* 读取码表。优先进程内缓存,其次 Redis,最后数据库。
*/
public Map<String, String> loadDict(String dictCode) {
CachedDict cached = localCache.get(dictCode);
long now = System.currentTimeMillis();
if (cached != null && now - cached.loadedAt < LOCAL_TTL_MILLIS) {
return cached.dict;
}
return reload(dictCode);
}
/**
* 清除某本字典的缓存(进程内 + Redis)
*/
public void evict(String dictCode) {
localCache.remove(dictCode);
try {
stringRedisTemplate.delete(CACHE_KEY_PREFIX + dictCode);
} catch (Exception e) {
logger.warn("清除码表缓存失败: {}", dictCode);
}
}
/**
* 清除全部码表缓存
*/
public void evictAll() {
localCache.clear();
warnedCodes.clear();
}
private synchronized Map<String, String> reload(String dictCode) {
// 双重检查:并发进入时可能已被别的线程加载
CachedDict cached = localCache.get(dictCode);
long now = System.currentTimeMillis();
if (cached != null && now - cached.loadedAt < LOCAL_TTL_MILLIS) {
return cached.dict;
}
String cacheKey = CACHE_KEY_PREFIX + dictCode;
Map<String, String> dict = null;
try {
String redisValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(redisValue)) {
dict = JSON.parseObject(redisValue, new TypeReference<LinkedHashMap<String, String>>() {
});
}
} catch (Exception e) {
logger.warn("读取码表 Redis 缓存失败, 回退数据库: {}", dictCode);
}
if (dict == null) {
dict = loadFromDatabase(dictCode);
try {
stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dict),
CACHE_TTL_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
logger.warn("写入码表 Redis 缓存失败: {}", dictCode);
}
}
localCache.put(dictCode, new CachedDict(dict, now));
return dict;
}
/**
* 大小写与前导零不敏感地查表。
*
* <p>先精确匹配(若字典里 `1` 与 `01` 都配了不同名称,以精确者为准),
* 再尝试去前导零与补零到两位。
*/
static String lookup(Map<String, String> dict, String rawCode) {
if (StrUtil.isBlank(rawCode)) {
return null;
}
String exact = dict.get(rawCode);
if (exact != null) {
return exact;
}
String stripped = rawCode.replaceFirst("^0+(?=[0-9])", "");
if (!stripped.equals(rawCode)) {
String hit = dict.get(stripped);
if (hit != null) {
return hit;
}
}
if (rawCode.length() == 1 && Character.isDigit(rawCode.charAt(0))) {
return dict.get("0" + rawCode);
}
return null;
}
private Map<String, String> loadFromDatabase(String dictCode) {
Map<String, String> dict = new HashMap<>();
Dict dictEntity = dictService.getOne(new LambdaQueryWrapper<Dict>()
.eq(Dict::getDictCode, dictCode)
.eq(Dict::getDeleted, 0)
.last("limit 1"));
if (dictEntity == null) {
logger.warn("码表字典不存在: {}", dictCode);
return dict;
}
List<DictData> items = dictDataService.list(new LambdaQueryWrapper<DictData>()
.eq(DictData::getDictId, dictEntity.getDictId())
.eq(DictData::getDeleted, 0));
for (DictData item : items) {
if (StrUtil.isNotBlank(item.getDictDataCode())) {
dict.put(item.getDictDataCode().trim(), item.getDictDataName());
}
}
return dict;
}
/**
* 带加载时间的码表快照
*/
private static final class CachedDict {
private final Map<String, String> dict;
private final long loadedAt;
private CachedDict(Map<String, String> dict, long loadedAt) {
this.dict = dict;
this.loadedAt = loadedAt;
}
}
}
@@ -0,0 +1,63 @@
package com.gxwebsoft.gxmu.openplat;
/**
* 开放平台同步常量
*
* @author Codex
* @since 2026-09-12
*/
public final class OpenplatSyncConstants {
private OpenplatSyncConstants() {
}
/**
* 租户ID。定时任务线程没有 SecurityContext,必须显式使用该常量,
* 不能依赖 BaseController.getTenantId()。
*/
public static final int TENANT_ID = 10049;
/**
* 学院节点挂载的父节点:广西医科大学
*/
public static final int ORG_PARENT_ID = 24;
/**
* 必须排除的组织节点:校团委(id=18)。
*
* <p>它的 parent_id 同样是 24,与院系节点平级,但被 gxmu_review_flow 与
* gxmu_review_flow_config 引用,停用逻辑必须避开它。
*/
public static final int ORG_KEEP_ID = 18;
/**
* 单批写入条数,避免万级记录一个长事务
*/
public static final int BATCH_SIZE = 800;
public static final String EP_DEPT = "RS_BMXX";
public static final String EP_CLASS = "bjxxb";
public static final String EP_TEACHER = "scjk_jsyhxx";
public static final String EP_STUDENT_ALL = "xsjbxxq";
public static final String EP_STUDENT_BK = "LY_XXBZ_JW_XSXXB";
public static final String EP_STUDENT_YKT = "yhxx_xsqtykt";
public static final String EP_STUDENT_YKT_INSCHOOL = "xsyhxx_ykt";
/**
* 班级辅导员原始工号在 remarks 中的前缀,便于以后重算
*/
public static final String REMARK_FDYH_PREFIX = "辅导员号:";
/**
* 全部同步段的固定执行顺序。
*
* <p>顺序不可调换:后者的学院/班级归属依赖前者写入的组织与班级 id。
*/
public static final java.util.List<String> ALL_SECTIONS = java.util.Collections.unmodifiableList(
java.util.Arrays.asList(
com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_DEPT,
com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_TEACHER,
com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_CLASS,
com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_STUDENT));
}
@@ -0,0 +1,37 @@
package com.gxwebsoft.gxmu.openplat;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 同步批次进度,供前端轮询。
*
* @author Codex
* @since 2026-09-14
*/
@Data
public class SyncBatchStatus implements Serializable {
private static final long serialVersionUID = 1L;
/** 是否有同步任务在执行 */
private boolean running;
private String batchId;
private String triggerType;
private String triggerUser;
private LocalDateTime startTime;
/** 正在执行的段,未执行时为 null */
private String currentSection;
/** 本次批次包含的各段状态,按执行顺序 */
private List<SyncSectionStatus> sections;
}
@@ -0,0 +1,43 @@
package com.gxwebsoft.gxmu.openplat;
import lombok.Data;
import java.io.Serializable;
/**
* 单段同步结果
*
* @author Codex
* @since 2026-09-12
*/
@Data
public class SyncResult implements Serializable {
private static final long serialVersionUID = 1L;
private String syncType;
private int successCount;
private int failCount;
private int skipCount;
private boolean success = true;
private String errorMsg;
public static SyncResult of(String syncType) {
SyncResult result = new SyncResult();
result.setSyncType(syncType);
return result;
}
public static SyncResult fail(String syncType, String errorMsg) {
SyncResult result = of(syncType);
result.setSuccess(false);
result.setErrorMsg(errorMsg);
return result;
}
}
@@ -0,0 +1,44 @@
package com.gxwebsoft.gxmu.openplat;
import cn.hutool.core.util.StrUtil;
/**
* 同步作用域判定。
*
* <p>把 docs/adr/0004 的核心规则收在一处:<b>只有「有外部键(同步来源)」且「本次未从平台拉到」
* 的记录才置为停用</b>;人工创建的记录(没有外部键)永不被停用。
*
* <p>这条规则写错的代价是实打实的:平台 1584 个班级里一个研究生班都没有,
* 而库里研究生班是团委手工维护的、没有班级码——一旦按「同步没拉到就停用」无条件执行,
* 会一次性废掉全部研究生班。
*
* @author Codex
* @since 2026-09-12
*/
public final class SyncScope {
private SyncScope() {
}
/**
* 是否应把该记录置为停用。
*
* @param externalCode 记录的外部键(单位号/班级码/学号/教工号);为空表示人工创建
* @param presentInPlatform 本次是否从开放平台拉到
* @return true 表示应停用
*/
public static boolean shouldDeactivate(String externalCode, boolean presentInPlatform) {
if (StrUtil.isBlank(externalCode)) {
return false;
}
return !presentInPlatform;
}
/**
* 该记录是否属于同步来源
*/
public static boolean isSyncSourced(String externalCode) {
return StrUtil.isNotBlank(externalCode);
}
}
@@ -0,0 +1,57 @@
package com.gxwebsoft.gxmu.openplat;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 同步批次中某一段的状态。
*
* <p>段在开始执行前先落一条「进行中」的记录,因此前端能实时看到进度,
* 而不是对着一个卡住的 loading 干等。
*
* @author Codex
* @since 2026-09-14
*/
@Data
public class SyncSectionStatus implements Serializable {
private static final long serialVersionUID = 1L;
/** 未开始 */
public static final String STATE_PENDING = "pending";
/** 进行中 */
public static final String STATE_RUNNING = "running";
/** 成功 */
public static final String STATE_SUCCESS = "success";
/** 失败 */
public static final String STATE_FAIL = "fail";
private String syncType;
private String state;
private Integer successCount;
private Integer failCount;
private Integer skipCount;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String errorMsg;
public static SyncSectionStatus pending(String syncType) {
SyncSectionStatus status = new SyncSectionStatus();
status.setSyncType(syncType);
status.setState(STATE_PENDING);
status.setSuccessCount(0);
status.setFailCount(0);
status.setSkipCount(0);
return status;
}
}
@@ -0,0 +1,236 @@
package com.gxwebsoft.gxmu.openplat;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.mapper.OrganizationMapper;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.gxmu.entity.ClassInfo;
import com.gxwebsoft.gxmu.mapper.ClassInfoMapper;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuTeacherMapper;
import com.gxwebsoft.gxmu.service.ClassInfoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 同步过程的公共支撑:码表映射、分批写入、差集停用。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class SyncSupport {
private static final Logger logger = LoggerFactory.getLogger(SyncSupport.class);
@Resource
private OrganizationService organizationService;
@Resource
private ClassInfoService classInfoService;
@Resource
private OrganizationMapper organizationMapper;
@Resource
private ClassInfoMapper classInfoMapper;
@Resource
private GxmuStudentMapper studentMapper;
@Resource
private GxmuTeacherMapper teacherMapper;
public static LocalDateTime now() {
return LocalDateTime.now();
}
/**
* 单位号 -> 组织ID
*/
public Map<String, Integer> orgIdByCode() {
Map<String, Integer> map = new HashMap<>();
List<Organization> list = organizationService.list(new LambdaQueryWrapper<Organization>()
.eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID)
.isNotNull(Organization::getOrganizationCode));
for (Organization org : list) {
String code = StrUtil.trimToNull(org.getOrganizationCode());
if (code != null) {
map.put(code, org.getOrganizationId());
}
}
return map;
}
/**
* 班级码 -> 班级ID
*/
public Map<String, Integer> classIdByCode() {
Map<String, Integer> map = new HashMap<>();
List<ClassInfo> list = classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
.eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID)
.isNotNull(ClassInfo::getClassCode));
for (ClassInfo item : list) {
String code = StrUtil.trimToNull(item.getClassCode());
if (code != null) {
map.put(code, item.getId());
}
}
return map;
}
/**
* 组织ID -> 组织名称
*/
public Map<Integer, String> orgNameById() {
Map<Integer, String> map = new HashMap<>();
List<Organization> list = organizationService.list(new LambdaQueryWrapper<Organization>()
.eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID));
for (Organization org : list) {
map.put(org.getOrganizationId(), org.getOrganizationName());
}
return map;
}
/**
* 班级ID -> 班级名称
*/
public Map<Integer, String> classNameById() {
Map<Integer, String> map = new HashMap<>();
List<ClassInfo> list = classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
.eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID));
for (ClassInfo item : list) {
map.put(item.getId(), item.getClassName());
}
return map;
}
/**
* 教工号 -> 姓名
*/
public Map<String, String> teacherNameByJgh() {
Map<String, String> map = new HashMap<>();
List<GxmuTeacher> list = teacherMapper.selectAllForNameLookup(OpenplatSyncConstants.TENANT_ID);
for (GxmuTeacher item : list) {
String jgh = StrUtil.trimToNull(item.getJgh());
if (jgh != null) {
map.put(jgh, item.getXm());
}
}
return map;
}
/**
* 分批写入学生名册
*/
public void upsertStudents(List<GxmuStudent> list) {
for (List<GxmuStudent> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
studentMapper.batchUpsert(chunk);
}
}
/**
* 分批写入教师名册
*/
public void upsertTeachers(List<GxmuTeacher> list) {
for (List<GxmuTeacher> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
teacherMapper.batchUpsert(chunk);
}
}
/**
* 分批写入班级。
*
* <p>用多行 upsert 而不是 MyBatis-Plus 的 saveBatch/updateBatchById:后者逐条发语句,
* 在公网数据库上被往返延迟主导——实测单条 25.9ms,1584 条就是 41 秒,
* 而多行 upsert 只要 0.2 秒。
*/
public void upsertClasses(List<ClassInfo> list) {
for (List<ClassInfo> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
classInfoMapper.batchUpsert(chunk);
}
}
/**
* 分批写入部门
*/
public void upsertOrganizations(List<Organization> list) {
for (List<Organization> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
organizationMapper.batchUpsert(chunk);
}
}
/**
* 学生差集停用。
*
* <p>在 Java 侧求差集而不是拼 NOT IN,避免 9 万多个参数。
* 只处理同步来源的记录(xh 非空),见 docs/adr/0004。
*/
public int deactivateMissingStudents(Set<String> incomingXh) {
List<String> existing = studentMapper.selectAllXh(OpenplatSyncConstants.TENANT_ID);
List<String> missing = new ArrayList<>();
for (String xh : existing) {
if (SyncScope.shouldDeactivate(xh, incomingXh.contains(xh))) {
missing.add(xh);
}
}
if (missing.isEmpty()) {
return 0;
}
logger.warn("学生名册中有 {} 条记录本次未从开放平台拉到, 置为停用", missing.size());
for (List<String> chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) {
studentMapper.deactivateByXh(OpenplatSyncConstants.TENANT_ID, chunk);
}
return missing.size();
}
/**
* 教师差集停用
*/
public int deactivateMissingTeachers(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);
}
}
if (missing.isEmpty()) {
return 0;
}
logger.warn("教师名册中有 {} 条记录本次未从开放平台拉到, 置为停用", missing.size());
for (List<String> chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) {
teacherMapper.deactivateByJgh(OpenplatSyncConstants.TENANT_ID, chunk);
}
return missing.size();
}
/**
* 收集一列非空值
*/
public static Set<String> collectNonBlank(Iterable<String> values) {
Set<String> set = new HashSet<>();
for (String value : values) {
String text = StrUtil.trimToNull(value);
if (text != null) {
set.add(text);
}
}
return set;
}
}
@@ -0,0 +1,38 @@
package com.gxwebsoft.gxmu.openplat.claim;
import lombok.Data;
import java.io.Serializable;
/**
* 学生认领结果
*
* @author Codex
* @since 2026-09-12
*/
@Data
public class ClaimResult implements Serializable {
private static final long serialVersionUID = 1L;
private boolean success;
private String message;
private String studentName;
public static ClaimResult ok(String message) {
ClaimResult result = new ClaimResult();
result.setSuccess(true);
result.setMessage(message);
return result;
}
public static ClaimResult fail(String message) {
ClaimResult result = new ClaimResult();
result.setSuccess(false);
result.setMessage(message);
return result;
}
}
@@ -0,0 +1,60 @@
package com.gxwebsoft.gxmu.openplat.claim;
import com.gxwebsoft.common.system.entity.User;
/**
* 学生认领服务。
*
* <p>名册与登录账号分离(docs/adr/0001),认领负责把两者关联起来。
* 学生登录仍走微信授权手机号,认领只回填 sys_user 的 user_code / real_name / sex / organization_id
* <b>不修改 username 与 phone</b>。
*
* @author Codex
* @since 2026-09-12
*/
public interface StudentClaimService {
/**
* 按手机号自动认领。
*
* <p>命中且唯一才认领;命中多条或命中不到都返回 false,由自助认领兜底。
*
* @param user 登录用户
* @return 是否认领成功
*/
boolean autoClaimByPhone(User user);
/**
* 自助认领:学号 + 姓名校验。
*
* @param user 登录用户
* @param xh 学号
* @param xm 姓名
* @return 认领结果
*/
ClaimResult claimByXhAndName(User user, String xh, String xm);
/**
* 人工认领/改绑。
*
* <p>优先用 userId;未给 userId 时按 phone 查账号;phone 也未给时用名册上的手机号查。
* 三者都定位不到账号则失败。
*
* @param studentId 名册记录ID
* @param userId 目标用户ID,可为空
* @param phone 目标用户手机号,可为空
* @param tenantId 租户ID
* @return 是否成功
*/
boolean bind(Integer studentId, Integer userId, String phone, Integer tenantId);
/**
* 解除认领
*
* @param studentId 名册记录ID
* @param tenantId 租户ID
* @return 是否成功
*/
boolean unbind(Integer studentId, Integer tenantId);
}
@@ -0,0 +1,179 @@
package com.gxwebsoft.gxmu.openplat.claim;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
/**
* 学生认领服务实现。
*
* @author Codex
* @since 2026-09-12
*/
@Service
public class StudentClaimServiceImpl implements StudentClaimService {
private static final Logger logger = LoggerFactory.getLogger(StudentClaimServiceImpl.class);
@Resource
private GxmuStudentMapper studentMapper;
@Resource
private UserService userService;
@Override
public boolean autoClaimByPhone(User user) {
if (user == null || user.getUserId() == null) {
return false;
}
String phone = StrUtil.trimToNull(user.getPhone());
if (phone == null) {
return false;
}
List<GxmuStudent> candidates = studentMapper.selectList(new LambdaQueryWrapper<GxmuStudent>()
.eq(GxmuStudent::getTenantId, OpenplatSyncConstants.TENANT_ID)
.eq(GxmuStudent::getSjh, phone)
.isNull(GxmuStudent::getUserId));
if (candidates.isEmpty()) {
return false;
}
if (candidates.size() > 1) {
// 一个手机号对应多个学号,不自动认领,交由自助认领处理
logger.warn("手机号匹配到 {} 条学生名册记录, 不自动认领 userId={}", candidates.size(), user.getUserId());
return false;
}
return doClaim(user, candidates.get(0));
}
@Override
public ClaimResult claimByXhAndName(User user, String xh, String xm) {
if (user == null || user.getUserId() == null) {
return ClaimResult.fail("请先登录");
}
String studentNo = StrUtil.trimToNull(xh);
String name = StrUtil.trimToNull(xm);
if (studentNo == null || name == null) {
return ClaimResult.fail("请填写学号与姓名");
}
GxmuStudent student = studentMapper.selectOne(new LambdaQueryWrapper<GxmuStudent>()
.eq(GxmuStudent::getTenantId, OpenplatSyncConstants.TENANT_ID)
.eq(GxmuStudent::getXh, studentNo)
.last("limit 1"));
if (student == null) {
return ClaimResult.fail("学号不存在");
}
if (!name.equals(StrUtil.trim(student.getXm()))) {
return ClaimResult.fail("姓名与学号不匹配");
}
if (student.getUserId() != null) {
if (student.getUserId().equals(user.getUserId())) {
return ClaimResult.ok("该学号已绑定到当前账号");
}
return ClaimResult.fail("该学号已被其他账号认领");
}
return doClaim(user, student)
? ClaimResult.ok("认领成功")
: ClaimResult.fail("认领失败");
}
@Override
public boolean bind(Integer studentId, Integer userId, String phone, Integer tenantId) {
GxmuStudent student = getStudent(studentId, tenantId);
if (student == null) {
return false;
}
User user = null;
if (userId != null) {
user = userService.getById(userId);
}
if (user == null) {
String target = StrUtil.trimToNull(phone);
if (target == null) {
target = StrUtil.trimToNull(student.getSjh());
}
if (target != null) {
user = userService.getByPhone(target);
}
}
if (user == null) {
logger.warn("手动认领失败: 未找到目标账号 studentId={}", studentId);
return false;
}
if (student.getUserId() != null && !student.getUserId().equals(user.getUserId())) {
// 改绑:先解除旧关联
unbind(studentId, tenantId);
}
return doClaim(user, student);
}
@Override
public boolean unbind(Integer studentId, Integer tenantId) {
GxmuStudent student = getStudent(studentId, tenantId);
if (student == null) {
return false;
}
// userId 为 null 时 updateById 不会清空该列,用 UpdateWrapper 显式置空
studentMapper.update(null, new LambdaUpdateWrapper<GxmuStudent>()
.eq(GxmuStudent::getId, student.getId())
.set(GxmuStudent::getUserId, null)
.set(GxmuStudent::getClaimTime, null)
.set(GxmuStudent::getUpdateTime, LocalDateTime.now()));
return true;
}
private GxmuStudent getStudent(Integer studentId, Integer tenantId) {
if (studentId == null) {
return null;
}
return studentMapper.selectOne(new LambdaQueryWrapper<GxmuStudent>()
.eq(GxmuStudent::getId, studentId)
.eq(GxmuStudent::getTenantId, tenantId == null ? OpenplatSyncConstants.TENANT_ID : tenantId)
.last("limit 1"));
}
/**
* 执行认领:回填 sys_user 的四个字段,并回写名册的 user_id / claim_time。
*
* <p>用 updateById 做局部更新,只写这四个字段,绝不触碰 username 与 phone
* 从而绕开 UserServiceImpl 中全局的唯一性校验。
*/
private boolean doClaim(User user, GxmuStudent student) {
if (student.getUserId() != null && !student.getUserId().equals(user.getUserId())) {
logger.warn("名册记录已被其他账号认领, 跳过: studentId={}", student.getId());
return false;
}
User patch = new User();
patch.setUserId(user.getUserId());
patch.setUserCode(student.getXh());
patch.setRealName(student.getXm());
if (StrUtil.isNotBlank(student.getSex())) {
patch.setSex(student.getSex());
}
if (student.getCollegeId() != null) {
patch.setOrganizationId(student.getCollegeId());
}
userService.updateById(patch);
GxmuStudent studentPatch = new GxmuStudent();
studentPatch.setId(student.getId());
studentPatch.setUserId(user.getUserId());
studentPatch.setClaimTime(LocalDateTime.now());
studentPatch.setUpdateTime(LocalDateTime.now());
studentMapper.updateById(studentPatch);
logger.warn("学生认领成功: userId={} xh={}", user.getUserId(), student.getXh());
return true;
}
}
@@ -0,0 +1,235 @@
package com.gxwebsoft.gxmu.openplat.controller;
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.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ClassInfo;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.SyncSupport;
import com.gxwebsoft.gxmu.openplat.claim.ClaimResult;
import com.gxwebsoft.gxmu.openplat.claim.StudentClaimService;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
/**
* 学生名册与认领控制器。
*
* <p>响应中的手机号与身份证一律脱敏,原始值不出本控制器。
*
* @author Codex
* @since 2026-09-12
*/
@Api(tags = "学生名册")
@RestController
@RequestMapping("/api/gxmu/student")
public class GxmuStudentController extends BaseController {
@Resource
private GxmuStudentMapper studentMapper;
@Resource
private StudentClaimService studentClaimService;
@Resource
private SyncSupport syncSupport;
@PreAuthorize("hasAuthority('gxmu:student:list')")
@ApiOperation("学生名册分页查询")
@GetMapping("/page")
public ApiResult<PageResult<GxmuStudent>> page(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
@RequestParam(value = "collegeId", required = false) Integer collegeId,
@RequestParam(value = "classId", required = false) Integer classId,
@RequestParam(value = "xh", required = false) String xh,
@RequestParam(value = "xm", required = false) String xm,
@RequestParam(value = "pyccmc", required = false) String pyccmc,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "claimed", required = false) Boolean claimed) {
LambdaQueryWrapper<GxmuStudent> wrapper = buildWrapper(collegeId, classId, xh, xm, pyccmc, status, claimed);
Page<GxmuStudent> pageParam = new Page<>(page, limit);
IPage<GxmuStudent> result = studentMapper.selectPage(pageParam, wrapper);
List<GxmuStudent> records = result.getRecords();
desensitize(records);
enrich(records);
return success(new PageResult<>(records, result.getTotal()));
}
@PreAuthorize("hasAuthority('gxmu:student:list')")
@ApiOperation("学生名册详情")
@GetMapping("/{id}")
public ApiResult<GxmuStudent> get(@PathVariable("id") Integer id) {
GxmuStudent student = studentMapper.selectById(id);
if (student == null) {
return fail("记录不存在", null);
}
desensitize(java.util.Collections.singletonList(student));
enrich(java.util.Collections.singletonList(student));
return success(student);
}
@ApiOperation("导出学生名册(脱敏)")
@PreAuthorize("hasAuthority('gxmu:student:export')")
@OperationLog
@PostMapping("/export")
public ApiResult<List<GxmuStudent>> export(
@RequestParam(value = "collegeId", required = false) Integer collegeId,
@RequestParam(value = "classId", required = false) Integer classId,
@RequestParam(value = "status", required = false) Integer status) {
LambdaQueryWrapper<GxmuStudent> wrapper = buildWrapper(collegeId, classId, null, null, null, status, null);
List<GxmuStudent> records = studentMapper.selectList(wrapper);
desensitize(records);
enrich(records);
return success(records);
}
@ApiOperation("自助认领(学号+姓名)")
@PostMapping("/claim")
public ApiResult<ClaimResult> claim(@RequestBody ClaimParam param) {
User user = getLoginUser();
ClaimResult result = studentClaimService.claimByXhAndName(user,
param == null ? null : param.getXh(), param == null ? null : param.getXm());
return result.isSuccess() ? success(result.getMessage(), result) : fail(result.getMessage(), result);
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:student:list')")
@ApiOperation("手动认领/改绑")
@PutMapping("/{id}/claim")
public ApiResult<?> bind(@PathVariable("id") Integer id, @RequestBody ClaimParam param) {
boolean ok = studentClaimService.bind(id, param == null ? null : param.getUserId(),
param == null ? null : param.getPhone(), getTenantId());
return ok ? success("认领成功") : fail("认领失败:未找到匹配的账号");
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:student:list')")
@ApiOperation("解除认领")
@PutMapping("/{id}/unclaim")
public ApiResult<?> unbind(@PathVariable("id") Integer id) {
return studentClaimService.unbind(id, getTenantId()) ? success("已解除认领") : fail("解除失败");
}
private LambdaQueryWrapper<GxmuStudent> buildWrapper(Integer collegeId, Integer classId, String xh,
String xm, String pyccmc, Integer status, Boolean claimed) {
LambdaQueryWrapper<GxmuStudent> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(GxmuStudent::getTenantId, getTenantId());
wrapper.eq(collegeId != null, GxmuStudent::getCollegeId, collegeId);
wrapper.eq(classId != null, GxmuStudent::getClassId, classId);
wrapper.eq(StrUtil.isNotBlank(xh), GxmuStudent::getXh, StrUtil.trim(xh));
wrapper.like(StrUtil.isNotBlank(xm), GxmuStudent::getXm, StrUtil.trim(xm));
wrapper.eq(StrUtil.isNotBlank(pyccmc), GxmuStudent::getPyccmc, pyccmc);
// 默认只看在校生
wrapper.eq(status != null, GxmuStudent::getStatus, status);
wrapper.eq(status == null, GxmuStudent::getStatus, 1);
if (claimed != null) {
if (claimed) {
wrapper.isNotNull(GxmuStudent::getUserId);
} else {
wrapper.isNull(GxmuStudent::getUserId);
}
}
wrapper.orderByAsc(GxmuStudent::getCollegeId).orderByAsc(GxmuStudent::getXh);
return wrapper;
}
/**
* 脱敏:手机号与身份证明文绝不离开后端。
*/
private void desensitize(List<GxmuStudent> records) {
for (GxmuStudent student : records) {
if (StrUtil.isNotBlank(student.getSjh())) {
student.setSjh(DesensitizedUtil.mobilePhone(student.getSjh()));
}
if (StrUtil.isNotBlank(student.getSfzjh()) && student.getSfzjh().length() >= 8) {
student.setSfzjh(DesensitizedUtil.idCardNum(student.getSfzjh(), 4, 4));
}
}
}
private void enrich(List<GxmuStudent> records) {
if (records.isEmpty()) {
return;
}
Map<Integer, String> orgNames = syncSupport.orgNameById();
Map<Integer, String> classNames = syncSupport.classNameById();
for (GxmuStudent student : records) {
if (student.getCollegeId() != null) {
student.setCollegeName(orgNames.get(student.getCollegeId()));
}
if (student.getClassId() != null) {
student.setClassName(classNames.get(student.getClassId()));
}
}
}
/**
* 认领参数
*/
@io.swagger.annotations.ApiModel("学生认领参数")
public static class ClaimParam {
@io.swagger.annotations.ApiModelProperty("学号")
private String xh;
@io.swagger.annotations.ApiModelProperty("姓名")
private String xm;
@io.swagger.annotations.ApiModelProperty("目标用户ID(手动认领时)")
private Integer userId;
@io.swagger.annotations.ApiModelProperty("目标用户手机号(手动认领时, 可不传则用名册手机号)")
private String phone;
public String getXh() {
return xh;
}
public void setXh(String xh) {
this.xh = xh;
}
public String getXm() {
return xm;
}
public void setXm(String xm) {
this.xm = xm;
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
}
}
@@ -0,0 +1,129 @@
package com.gxwebsoft.gxmu.openplat.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.openplat.SyncBatchStatus;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuSyncRecordMapper;
import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
/**
* 开放平台数据同步控制器。
*
* <p>手动同步一律是<b>异步</b>的:提交后立即返回批次号,前端轮询 {@code /status} 看进度。
* 学生全量约 9.8 万条、耗时 75~90 秒,同步阻塞式 HTTP 会先撞上网关或浏览器超时,
* 页面看起来像卡死而其实后端还在跑,容易被误当成失败而重复点击。
*
* @author Codex
* @since 2026-09-12
*/
@Api(tags = "开放平台数据同步")
@RestController
@RequestMapping("/api/gxmu/sync")
public class GxmuSyncController extends BaseController {
@Resource
private OpenplatSyncService openplatSyncService;
@Resource
private GxmuSyncRecordMapper gxmuSyncRecordMapper;
@OperationLog
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步部门(异步, 返回批次号)")
@PostMapping("/dept")
public ApiResult<String> dept() {
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_DEPT));
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步教师(异步, 返回批次号)")
@PostMapping("/teacher")
public ApiResult<String> teacher() {
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_TEACHER));
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步班级(异步, 返回批次号)")
@PostMapping("/class")
public ApiResult<String> classInfo() {
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_CLASS));
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步学生(异步, 返回批次号)")
@PostMapping("/student")
public ApiResult<String> student() {
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_STUDENT));
}
@OperationLog
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("全部同步(异步, 返回批次号)")
@PostMapping("/all")
public ApiResult<String> all() {
return submit(null);
}
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步进度(当前或最近一次批次)")
@GetMapping("/status")
public ApiResult<SyncBatchStatus> status() {
return success(openplatSyncService.currentStatus());
}
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
@ApiOperation("同步记录分页查询")
@GetMapping("/record/page")
public ApiResult<PageResult<GxmuSyncRecord>> recordPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "limit", defaultValue = "20") Integer limit,
@RequestParam(value = "syncType", required = false) String syncType,
@RequestParam(value = "batchId", required = false) String batchId) {
Page<GxmuSyncRecord> pageParam = new Page<>(page, limit);
LambdaQueryWrapper<GxmuSyncRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(GxmuSyncRecord::getTenantId, getTenantId());
wrapper.eq(StrUtil.isNotBlank(syncType), GxmuSyncRecord::getSyncType, syncType);
wrapper.eq(StrUtil.isNotBlank(batchId), GxmuSyncRecord::getBatchId, batchId);
wrapper.orderByDesc(GxmuSyncRecord::getStartTime).orderByDesc(GxmuSyncRecord::getId);
IPage<GxmuSyncRecord> result = gxmuSyncRecordMapper.selectPage(pageParam, wrapper);
return success(result);
}
private ApiResult<String> submit(List<String> sections) {
String batchId = openplatSyncService.submitAsync(
GxmuSyncRecord.TRIGGER_MANUAL, currentUsername(), sections);
if (batchId == null) {
return fail("已有同步任务正在执行,请等待其完成后再试", null);
}
return success("已提交同步任务", batchId);
}
private String currentUsername() {
User user = getLoginUser();
return user == null ? null : user.getUsername();
}
}
@@ -0,0 +1,135 @@
package com.gxwebsoft.gxmu.openplat.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 学生名册(开放平台同步)。
*
* <p>与 sys_user 登录账号分离,见 docs/adr/0001。
* mzm/nation、zzmmm/politics 成对保存「原始码 + 解析名称」,见 docs/adr/0006。
*
* @author Codex
* @since 2026-09-12
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "GxmuStudent对象", description = "学生名册")
@TableName("gxmu_student")
public class GxmuStudent implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "学号(开放平台外部键)")
private String xh;
@ApiModelProperty(value = "姓名")
private String xm;
@ApiModelProperty(value = "性别码(平台原始)")
private String xbm;
@ApiModelProperty(value = "性别名称")
private String sex;
@ApiModelProperty(value = "民族码(平台原始)")
private String mzm;
@ApiModelProperty(value = "民族名称")
private String nation;
@ApiModelProperty(value = "政治面貌码(平台原始)")
private String zzmmm;
@ApiModelProperty(value = "政治面貌名称")
private String politics;
@ApiModelProperty(value = "身份证件号")
private String sfzjh;
@ApiModelProperty(value = "手机号(认领键)")
private String sjh;
@ApiModelProperty(value = "平台班级码(原始)")
private String bjm;
@ApiModelProperty(value = "平台班号(学籍库原始)")
private String bh;
@ApiModelProperty(value = "解析出的班级ID")
private Integer classId;
@ApiModelProperty(value = "解析出的学院ID")
private Integer collegeId;
@ApiModelProperty(value = "单位号(平台原始)")
private String dwh;
@ApiModelProperty(value = "专业号")
private String zyh;
@ApiModelProperty(value = "专业名称")
private String zymc;
@ApiModelProperty(value = "培养层次")
private String pyccmc;
@ApiModelProperty(value = "是否在校")
private String sfzx;
@ApiModelProperty(value = "学籍状态")
private String xjzt;
@ApiModelProperty(value = "学生当前状态")
private String xsdqzt;
@ApiModelProperty(value = "出生日期")
private String csrq;
@ApiModelProperty(value = "认领后的 sys_user.user_id")
private Integer userId;
@ApiModelProperty(value = "认领时间")
private LocalDateTime claimTime;
@ApiModelProperty(value = "状态:1在校 0毕业/结业/休学")
private Integer status;
@ApiModelProperty(value = "最近同步时间")
private LocalDateTime syncTime;
@ApiModelProperty(value = "租户ID")
private Integer tenantId;
@ApiModelProperty(value = "创建时间")
private LocalDateTime createTime;
@ApiModelProperty(value = "更新时间")
private LocalDateTime updateTime;
@ApiModelProperty(value = "删除标记:0未删 1已删")
@TableLogic
private Integer deleted;
@TableField(exist = false)
@ApiModelProperty(value = "学院名称")
private String collegeName;
@TableField(exist = false)
@ApiModelProperty(value = "班级名称")
private String className;
}
@@ -0,0 +1,90 @@
package com.gxwebsoft.gxmu.openplat.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 开放平台同步记录。一接口一次一行,batch_id 关联同一次触发。
*
* <p>注意:error_msg 中不得写入手机号或身份证号。
*
* @author Codex
* @since 2026-09-12
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "GxmuSyncRecord对象", description = "开放平台同步记录")
@TableName("gxmu_sync_record")
public class GxmuSyncRecord implements Serializable {
private static final long serialVersionUID = 1L;
public static final String TYPE_DEPT = "dept";
public static final String TYPE_TEACHER = "teacher";
public static final String TYPE_CLASS = "class";
public static final String TYPE_STUDENT = "student";
public static final String TRIGGER_SCHEDULE = "schedule";
public static final String TRIGGER_MANUAL = "manual";
public static final int STATUS_SUCCESS = 0;
public static final int STATUS_FAIL = 1;
/**
* 段已开始、尚未结束。用于让前端看到实时进度,也能识别出被中断的批次。
*/
public static final int STATUS_RUNNING = 2;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@ApiModelProperty(value = "批次号(同一次触发的各段共享)")
private String batchId;
@ApiModelProperty(value = "同步类型:dept/teacher/class/student")
private String syncType;
@ApiModelProperty(value = "触发方式:schedule/manual")
private String triggerType;
@ApiModelProperty(value = "触发人(手动触发时)")
private String triggerUser;
@ApiModelProperty(value = "开始时间")
private LocalDateTime startTime;
@ApiModelProperty(value = "结束时间")
private LocalDateTime endTime;
@ApiModelProperty(value = "成功条数")
private Integer successCount;
@ApiModelProperty(value = "失败条数")
private Integer failCount;
@ApiModelProperty(value = "跳过条数")
private Integer skipCount;
@ApiModelProperty(value = "状态:0成功 1失败")
private Integer status;
@ApiModelProperty(value = "错误摘要(不含手机号/身份证)")
private String errorMsg;
@ApiModelProperty(value = "租户ID")
private Integer tenantId;
@ApiModelProperty(value = "创建时间")
private LocalDateTime createTime;
@ApiModelProperty(value = "更新时间")
private LocalDateTime updateTime;
}
@@ -0,0 +1,82 @@
package com.gxwebsoft.gxmu.openplat.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 教师名册(开放平台同步)。
*
* <p>教师不建登录账号,只用于解析班级的辅导员工号与后台选人。
*
* @author Codex
* @since 2026-09-12
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "GxmuTeacher对象", description = "教师名册")
@TableName("gxmu_teacher")
public class GxmuTeacher implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "教工号(开放平台外部键)")
private String jgh;
@ApiModelProperty(value = "姓名")
private String xm;
@ApiModelProperty(value = "性别码(平台原始)")
private String xbm;
@ApiModelProperty(value = "性别名称")
private String sex;
@ApiModelProperty(value = "单位号(平台原始)")
private String dwh;
@ApiModelProperty(value = "解析出的学院ID")
private Integer collegeId;
@ApiModelProperty(value = "科室教研室编号")
private String ksjybh;
@ApiModelProperty(value = "手机号(敏感字段, 不对外输出)")
private String sjh;
@ApiModelProperty(value = "状态:1启用 0停用")
private Integer status;
@ApiModelProperty(value = "最近同步时间")
private LocalDateTime syncTime;
@ApiModelProperty(value = "租户ID")
private Integer tenantId;
@ApiModelProperty(value = "创建时间")
private LocalDateTime createTime;
@ApiModelProperty(value = "更新时间")
private LocalDateTime updateTime;
@ApiModelProperty(value = "删除标记:0未删 1已删")
@TableLogic
private Integer deleted;
@TableField(exist = false)
@ApiModelProperty(value = "学院名称")
private String collegeName;
}
@@ -0,0 +1,50 @@
package com.gxwebsoft.gxmu.openplat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 学生名册Mapper
*
* @author Codex
* @since 2026-09-12
*/
public interface GxmuStudentMapper extends BaseMapper<GxmuStudent> {
/**
* 按 (tenant_id, xh) 批量新增或更新。
*
* <p>只覆盖平台权威字段;user_id / claim_time 由认领流程维护,不在此处覆盖。
* create_time 不在 UPDATE 子句中,重复执行不会刷新创建时间。
*
* @param list 学生名册
* @return 影响行数
*/
int batchUpsert(@Param("list") List<GxmuStudent> list);
/**
* 查询全部同步来源的学号。
*
* <p>用于在 Java 侧与本次拉取到的学号求差集——上游消失的记录通常为零,
* 这样就不必用 9 万多个参数去拼 NOT IN。
*
* @param tenantId 租户ID
* @return 学号列表
*/
List<String> selectAllXh(@Param("tenantId") Integer tenantId);
/**
* 按学号批量置为停用。
*
* <p>只作用于同步来源的记录(xh 非空),人工创建的记录不在其中,见 docs/adr/0004。
*
* @param tenantId 租户ID
* @param xhs 学号列表(调用方需自行分批)
* @return 影响行数
*/
int deactivateByXh(@Param("tenantId") Integer tenantId, @Param("xhs") List<String> xhs);
}
@@ -0,0 +1,14 @@
package com.gxwebsoft.gxmu.openplat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
/**
* 开放平台同步记录Mapper
*
* @author Codex
* @since 2026-09-12
*/
public interface GxmuSyncRecordMapper extends BaseMapper<GxmuSyncRecord> {
}
@@ -0,0 +1,50 @@
package com.gxwebsoft.gxmu.openplat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 教师名册Mapper
*
* @author Codex
* @since 2026-09-12
*/
public interface GxmuTeacherMapper extends BaseMapper<GxmuTeacher> {
/**
* 按 (tenant_id, jgh) 批量新增或更新
*
* @param list 教师名册
* @return 影响行数
*/
int batchUpsert(@Param("list") List<GxmuTeacher> list);
/**
* 查询全部同步来源的教工号,用于在 Java 侧求差集
*
* @param tenantId 租户ID
* @return 教工号列表
*/
List<String> selectAllJgh(@Param("tenantId") Integer tenantId);
/**
* 按教工号批量置为停用
*
* @param tenantId 租户ID
* @param jghs 教工号列表(调用方需自行分批)
* @return 影响行数
*/
int deactivateByJgh(@Param("tenantId") Integer tenantId, @Param("jghs") List<String> jghs);
/**
* 查询全部 教工号 -> 姓名,用于解析班级的辅导员/班主任
*
* @param tenantId 租户ID
* @return 列表,元素含 jgh 与 xm
*/
List<GxmuTeacher> selectAllForNameLookup(@Param("tenantId") Integer tenantId);
}
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper">
<insert id="batchUpsert">
INSERT INTO gxmu_student (
xh, xm, xbm, sex, mzm, nation, zzmmm, politics, sfzjh, sjh,
bjm, bh, class_id, college_id, dwh, zyh, zymc, pyccmc,
sfzx, xjzt, xsdqzt, csrq, status, sync_time, tenant_id,
create_time, update_time, deleted
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.xh}, #{item.xm}, #{item.xbm}, #{item.sex}, #{item.mzm}, #{item.nation},
#{item.zzmmm}, #{item.politics}, #{item.sfzjh}, #{item.sjh},
#{item.bjm}, #{item.bh}, #{item.classId}, #{item.collegeId}, #{item.dwh},
#{item.zyh}, #{item.zymc}, #{item.pyccmc},
#{item.sfzx}, #{item.xjzt}, #{item.xsdqzt}, #{item.csrq},
#{item.status}, #{item.syncTime}, #{item.tenantId},
#{item.createTime}, #{item.updateTime}, #{item.deleted}
)
</foreach>
ON DUPLICATE KEY UPDATE
xm = VALUES(xm),
xbm = VALUES(xbm),
sex = VALUES(sex),
mzm = VALUES(mzm),
nation = VALUES(nation),
zzmmm = VALUES(zzmmm),
politics = VALUES(politics),
sfzjh = VALUES(sfzjh),
sjh = VALUES(sjh),
bjm = VALUES(bjm),
bh = VALUES(bh),
class_id = VALUES(class_id),
college_id = VALUES(college_id),
dwh = VALUES(dwh),
zyh = VALUES(zyh),
zymc = VALUES(zymc),
pyccmc = VALUES(pyccmc),
sfzx = VALUES(sfzx),
xjzt = VALUES(xjzt),
xsdqzt = VALUES(xsdqzt),
csrq = VALUES(csrq),
status = VALUES(status),
sync_time = VALUES(sync_time),
update_time = VALUES(update_time),
deleted = VALUES(deleted)
</insert>
<select id="selectAllXh" resultType="java.lang.String">
SELECT xh FROM gxmu_student
WHERE tenant_id = #{tenantId} AND xh IS NOT NULL AND xh &lt;&gt; ''
</select>
<update id="deactivateByXh">
UPDATE gxmu_student
SET status = 0, update_time = NOW()
WHERE tenant_id = #{tenantId} AND status &lt;&gt; 0
AND xh IN
<foreach collection="xhs" item="xh" open="(" separator="," close=")">
#{xh}
</foreach>
</update>
<select id="selectClassIdByCode" resultType="java.lang.Integer">
SELECT id FROM gxmu_class
WHERE tenant_id = #{tenantId} AND class_code = #{classCode} AND deleted = 0
LIMIT 1
</select>
</mapper>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.gxmu.openplat.mapper.GxmuTeacherMapper">
<insert id="batchUpsert">
INSERT INTO gxmu_teacher (
jgh, xm, xbm, sex, dwh, college_id, ksjybh, sjh,
status, sync_time, tenant_id, create_time, update_time, deleted
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.jgh}, #{item.xm}, #{item.xbm}, #{item.sex}, #{item.dwh},
#{item.collegeId}, #{item.ksjybh}, #{item.sjh},
#{item.status}, #{item.syncTime}, #{item.tenantId},
#{item.createTime}, #{item.updateTime}, #{item.deleted}
)
</foreach>
ON DUPLICATE KEY UPDATE
xm = VALUES(xm),
xbm = VALUES(xbm),
sex = VALUES(sex),
dwh = VALUES(dwh),
college_id = VALUES(college_id),
ksjybh = VALUES(ksjybh),
sjh = VALUES(sjh),
status = VALUES(status),
sync_time = VALUES(sync_time),
update_time = VALUES(update_time),
deleted = VALUES(deleted)
</insert>
<select id="selectAllJgh" resultType="java.lang.String">
SELECT jgh FROM gxmu_teacher
WHERE tenant_id = #{tenantId} AND jgh IS NOT NULL AND jgh &lt;&gt; ''
</select>
<update id="deactivateByJgh">
UPDATE gxmu_teacher
SET status = 0, update_time = NOW()
WHERE tenant_id = #{tenantId} AND status &lt;&gt; 0
AND jgh IN
<foreach collection="jghs" item="jgh" open="(" separator="," close=")">
#{jgh}
</foreach>
</update>
<select id="selectAllForNameLookup" resultType="com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher">
SELECT jgh, xm FROM gxmu_teacher
WHERE tenant_id = #{tenantId} AND deleted = 0 AND jgh IS NOT NULL
</select>
</mapper>
@@ -0,0 +1,39 @@
package com.gxwebsoft.gxmu.openplat.service;
import com.gxwebsoft.gxmu.openplat.SyncBatchStatus;
import java.util.List;
/**
* 开放平台数据同步服务
*
* @author Codex
* @since 2026-09-12
*/
public interface OpenplatSyncService {
/**
* 异步提交一次同步并立即返回批次号。
*
* <p>学生全量约 9.8 万条、耗时 75~90 秒(其中平台拉取约 50 秒),
* 用同步阻塞 HTTP 会先撞上网关或浏览器超时,页面看起来像卡死而其实后端还在跑。
* 因此改为提交即返回,前端轮询 {@link #currentStatus()} 看进度。
*
* @param triggerType 触发方式,见 GxmuSyncRecord.TRIGGER_*
* @param triggerUser 触发人,定时任务可为空
* @param sections 要执行的段;为空表示全部,顺序固定为 部门 → 教师 → 班级 → 学生
* @return 批次号;已有同步在跑时返回 null
*/
String submitAsync(String triggerType, String triggerUser, List<String> sections);
/**
* 当前(或最近一次)批次的进度
*/
SyncBatchStatus currentStatus();
/**
* 是否有同步任务在执行
*/
boolean isRunning();
}
@@ -0,0 +1,300 @@
package com.gxwebsoft.gxmu.openplat.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.SyncBatchStatus;
import com.gxwebsoft.gxmu.openplat.SyncResult;
import com.gxwebsoft.gxmu.openplat.SyncSectionStatus;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.openplat.mapper.GxmuSyncRecordMapper;
import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService;
import com.gxwebsoft.gxmu.openplat.sync.ClassSyncHandler;
import com.gxwebsoft.gxmu.openplat.sync.DeptSyncHandler;
import com.gxwebsoft.gxmu.openplat.sync.StudentSyncHandler;
import com.gxwebsoft.gxmu.openplat.sync.TeacherSyncHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 开放平台数据同步服务实现。
*
* <p><b>异步执行</b>:提交后立即返回批次号,实际同步跑在单线程执行器上。
* 单线程保证同一时刻只有一个批次在跑,配合 {@link AtomicBoolean} 拒绝重复提交。
*
* <p><b>进度可见</b>:每段开始执行前先落一条 status=2(进行中) 的记录,结束时回填结果,
* 前端据此展示进度,也能识别被中断的批次。
*
* <p><b>相互隔离</b>:单段失败不影响其余段,照抄 TzbProjectLibraryCrawlerServiceImpl 的写法。
* 此处刻意不加 {@code @Transactional}:学生量级近十万,一个覆盖整段的事务会长时间持锁,
* 与「分批提交」相冲突;同步是幂等的,失败重跑即可。
*
* @author Codex
* @since 2026-09-12
*/
@Service
public class OpenplatSyncServiceImpl implements OpenplatSyncService {
private static final Logger logger = LoggerFactory.getLogger(OpenplatSyncServiceImpl.class);
@Resource
private DeptSyncHandler deptSyncHandler;
@Resource
private TeacherSyncHandler teacherSyncHandler;
@Resource
private ClassSyncHandler classSyncHandler;
@Resource
private StudentSyncHandler studentSyncHandler;
@Resource
private GxmuSyncRecordMapper syncRecordMapper;
private final AtomicBoolean running = new AtomicBoolean(false);
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "openplat-sync");
thread.setDaemon(true);
return thread;
});
private volatile String currentBatchId;
private volatile String currentTriggerType;
private volatile String currentTriggerUser;
private volatile LocalDateTime currentStartTime;
private volatile List<String> currentSections = Collections.emptyList();
/**
* 应用重启会把「进行中」的记录留在库里,启动时标为中断,避免前端一直显示在跑。
*/
@PostConstruct
public void markStaleRunningRecords() {
markRunningAsInterrupted("应用重启导致中断");
}
@PreDestroy
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
@Override
public String submitAsync(String triggerType, String triggerUser, List<String> sections) {
List<String> target = (sections == null || sections.isEmpty())
? OpenplatSyncConstants.ALL_SECTIONS
: new ArrayList<>(sections);
if (!running.compareAndSet(false, true)) {
return null;
}
// 上一批次若异常退出,库里可能残留「进行中」,先收尾
markRunningAsInterrupted("被新的同步批次中断");
final String batchId = UUID.randomUUID().toString().replace("-", "");
currentBatchId = batchId;
currentTriggerType = triggerType;
currentTriggerUser = triggerUser;
currentStartTime = LocalDateTime.now();
currentSections = target;
logger.warn("开放平台同步批次 {} 已提交, 触发方式={}, 段={}", batchId, triggerType, target);
try {
executor.submit(() -> executeBatch(batchId, triggerType, triggerUser, target));
} catch (RuntimeException e) {
running.set(false);
throw e;
}
return batchId;
}
@Override
public SyncBatchStatus currentStatus() {
SyncBatchStatus status = new SyncBatchStatus();
status.setRunning(running.get());
status.setBatchId(currentBatchId);
status.setTriggerType(currentTriggerType);
status.setTriggerUser(currentTriggerUser);
status.setStartTime(currentStartTime);
Map<String, GxmuSyncRecord> rows = new HashMap<>();
if (StrUtil.isNotBlank(currentBatchId)) {
List<GxmuSyncRecord> list = syncRecordMapper.selectList(
new LambdaQueryWrapper<GxmuSyncRecord>()
.eq(GxmuSyncRecord::getBatchId, currentBatchId)
.orderByAsc(GxmuSyncRecord::getId));
for (GxmuSyncRecord record : list) {
rows.put(record.getSyncType(), record);
}
}
List<SyncSectionStatus> sections = new ArrayList<>();
for (String type : currentSections) {
GxmuSyncRecord record = rows.get(type);
sections.add(record == null ? SyncSectionStatus.pending(type) : toSectionStatus(type, record));
if (record != null && record.getStatus() != null
&& record.getStatus() == GxmuSyncRecord.STATUS_RUNNING) {
status.setCurrentSection(type);
}
}
status.setSections(sections);
return status;
}
@Override
public boolean isRunning() {
return running.get();
}
private void executeBatch(String batchId, String triggerType, String triggerUser, List<String> sections) {
try {
for (String section : sections) {
if (!runSection(batchId, section, triggerType, triggerUser)) {
break;
}
}
logger.warn("开放平台同步批次 {} 执行完毕", batchId);
} catch (Exception e) {
logger.error("开放平台同步批次 " + batchId + " 执行失败", e);
} finally {
running.set(false);
}
}
/**
* 执行一段。单段失败不回滚其他段,返回 false 表示批次应终止。
*/
private boolean runSection(String batchId, String syncType, String triggerType, String triggerUser) {
LocalDateTime start = LocalDateTime.now();
Long recordId = insertRunningRecord(batchId, syncType, triggerType, triggerUser, start);
SyncResult result;
try {
result = dispatch(syncType);
} catch (Exception e) {
logger.error("开放平台同步段失败: " + syncType, e);
result = SyncResult.fail(syncType, StrUtil.maxLength(
StrUtil.blankToDefault(e.getMessage(), e.getClass().getSimpleName()), 900));
}
finishRunningRecord(recordId, result);
return true;
}
private SyncResult dispatch(String syncType) {
if (GxmuSyncRecord.TYPE_DEPT.equals(syncType)) {
return deptSyncHandler.sync();
}
if (GxmuSyncRecord.TYPE_TEACHER.equals(syncType)) {
return teacherSyncHandler.sync();
}
if (GxmuSyncRecord.TYPE_CLASS.equals(syncType)) {
return classSyncHandler.sync();
}
if (GxmuSyncRecord.TYPE_STUDENT.equals(syncType)) {
return studentSyncHandler.sync();
}
return SyncResult.fail(syncType, "未知的同步类型: " + syncType);
}
private Long insertRunningRecord(String batchId, String syncType, String triggerType,
String triggerUser, LocalDateTime start) {
GxmuSyncRecord record = new GxmuSyncRecord();
record.setBatchId(batchId);
record.setSyncType(syncType);
record.setTriggerType(triggerType);
record.setTriggerUser(triggerUser);
record.setStartTime(start);
record.setSuccessCount(0);
record.setFailCount(0);
record.setSkipCount(0);
record.setStatus(GxmuSyncRecord.STATUS_RUNNING);
record.setTenantId(OpenplatSyncConstants.TENANT_ID);
record.setCreateTime(LocalDateTime.now());
record.setUpdateTime(LocalDateTime.now());
syncRecordMapper.insert(record);
return record.getId();
}
private void finishRunningRecord(Long recordId, SyncResult result) {
if (recordId == null) {
return;
}
try {
GxmuSyncRecord patch = new GxmuSyncRecord();
patch.setId(recordId);
patch.setEndTime(LocalDateTime.now());
patch.setSuccessCount(result.getSuccessCount());
patch.setFailCount(result.getFailCount());
patch.setSkipCount(result.getSkipCount());
patch.setStatus(result.isSuccess()
? GxmuSyncRecord.STATUS_SUCCESS : GxmuSyncRecord.STATUS_FAIL);
patch.setErrorMsg(result.getErrorMsg());
patch.setUpdateTime(LocalDateTime.now());
syncRecordMapper.updateById(patch);
} catch (Exception e) {
logger.error("回填同步记录失败: {}", result.getSyncType(), e);
}
}
private void markRunningAsInterrupted(String reason) {
try {
int rows = syncRecordMapper.update(null, new LambdaUpdateWrapper<GxmuSyncRecord>()
.eq(GxmuSyncRecord::getStatus, GxmuSyncRecord.STATUS_RUNNING)
.set(GxmuSyncRecord::getStatus, GxmuSyncRecord.STATUS_FAIL)
.set(GxmuSyncRecord::getErrorMsg, reason)
.set(GxmuSyncRecord::getEndTime, LocalDateTime.now()));
if (rows > 0) {
logger.warn("清理 {} 条未收尾的同步记录: {}", rows, reason);
}
} catch (Exception e) {
logger.warn("清理未收尾的同步记录失败: {}", e.getMessage());
}
}
private SyncSectionStatus toSectionStatus(String syncType, GxmuSyncRecord record) {
SyncSectionStatus section = new SyncSectionStatus();
section.setSyncType(syncType);
int status = record.getStatus() == null ? GxmuSyncRecord.STATUS_FAIL : record.getStatus();
if (status == GxmuSyncRecord.STATUS_SUCCESS) {
section.setState(SyncSectionStatus.STATE_SUCCESS);
} else if (status == GxmuSyncRecord.STATUS_RUNNING) {
section.setState(SyncSectionStatus.STATE_RUNNING);
} else {
section.setState(SyncSectionStatus.STATE_FAIL);
}
section.setSuccessCount(record.getSuccessCount());
section.setFailCount(record.getFailCount());
section.setSkipCount(record.getSkipCount());
section.setStartTime(record.getStartTime());
section.setEndTime(record.getEndTime());
section.setErrorMsg(record.getErrorMsg());
return section;
}
}
@@ -0,0 +1,218 @@
package com.gxwebsoft.gxmu.openplat.sync;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.gxmu.entity.ClassInfo;
import com.gxwebsoft.gxmu.openplat.OpenPlatformClient;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.SyncResult;
import com.gxwebsoft.gxmu.openplat.SyncScope;
import com.gxwebsoft.gxmu.openplat.SyncSupport;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.service.ClassInfoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.Year;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 班级同步:bjxxb -> gxmu_class。
*
* <p>只按 class_code 匹配,<b>绝不做名称模糊匹配</b>——实测激进归一化会把
* 「22级本科护理学1班」误配到「2022级临床医学1班」。
*
* <p>停用只作用于有 class_code 的记录。团委手工维护的研究生班没有 class_code
* 平台上一个研究生班都没有,若按名称或无条件停用会一次性废掉全部研究生班,
* 见 docs/adr/0004。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class ClassSyncHandler {
private static final Logger logger = LoggerFactory.getLogger(ClassSyncHandler.class);
private static final int COUNSELOR_NAME_MAX = 50;
@Resource
private OpenPlatformClient client;
@Resource
private ClassInfoService classInfoService;
@Resource
private SyncSupport support;
public SyncResult sync() {
SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_CLASS);
JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_CLASS);
Map<String, Integer> orgIds = support.orgIdByCode();
Map<String, String> teacherNames = support.teacherNameByJgh();
Map<String, ClassInfo> existing = new HashMap<>();
List<ClassInfo> existingList = listSyncedClasses();
for (ClassInfo item : existingList) {
existing.put(StrUtil.trim(item.getClassCode()), item);
}
Set<String> incoming = new HashSet<>();
List<ClassInfo> toUpsert = new ArrayList<>();
LocalDateTime now = SyncSupport.now();
int unresolvedCollege = 0;
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String bjm = OpenPlatformClient.field(row, "BJM");
String bjmc = OpenPlatformClient.field(row, "BJMC");
if (StrUtil.isBlank(bjm) || StrUtil.isBlank(bjmc)) {
result.setFailCount(result.getFailCount() + 1);
continue;
}
incoming.add(bjm);
String dwh = OpenPlatformClient.field(row, "DWH");
Integer collegeId = StrUtil.isBlank(dwh) ? null : orgIds.get(dwh);
if (collegeId == null) {
unresolvedCollege++;
}
ClassInfo classInfo = existing.get(bjm);
boolean isNew = classInfo == null;
if (isNew) {
classInfo = new ClassInfo();
classInfo.setTenantId(OpenplatSyncConstants.TENANT_ID);
classInfo.setSortNumber(0);
classInfo.setCreateTime(now);
classInfo.setDeleted(0);
}
classInfo.setClassCode(bjm);
classInfo.setClassName(bjmc);
classInfo.setCollegeId(collegeId);
classInfo.setGradeYear(parseGradeYear(row));
classInfo.setStudentCount(parseInteger(row, "BJRS"));
classInfo.setStatus("2".equals(OpenPlatformClient.field(row, "ZT")) ? 0 : 1);
classInfo.setCounselorName(buildCounselorName(row, teacherNames));
// remark 属人工维护字段:仅当为空或仍是本同步写入的原始工号时才覆盖
String rawFdyh = OpenPlatformClient.field(row, "FDYH");
if (StrUtil.isNotBlank(rawFdyh)) {
String current = classInfo.getRemark();
if (StrUtil.isBlank(current) || current.startsWith(OpenplatSyncConstants.REMARK_FDYH_PREFIX)) {
classInfo.setRemark(OpenplatSyncConstants.REMARK_FDYH_PREFIX + rawFdyh);
}
}
classInfo.setUpdateTime(now);
toUpsert.add(classInfo);
}
// 一条多行 upsert 代替逐条 save/update1584 条从 41 秒降到 0.2 秒
support.upsertClasses(toUpsert);
result.setSuccessCount(incoming.size());
if (unresolvedCollege > 0) {
logger.warn("班级同步: {} 个班级的学院未能解析出组织节点", unresolvedCollege);
}
int deactivated = deactivateMissing(incoming, existingList);
result.setSkipCount(deactivated);
logger.warn("班级同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条",
incoming.size(), deactivated, result.getFailCount());
return result;
}
/**
* RXNF 入学年份,带合理性校验。
*
* <p>实测存在 RXNF=2030 这类脏数据,越界时记 warn 并落 null,单条脏数据不得中断整批。
*/
Integer parseGradeYear(JSONObject row) {
Integer year = parseInteger(row, "RXNF");
if (year == null) {
return null;
}
int max = Year.now().getValue() + 1;
if (year < 2000 || year > max) {
logger.warn("班级入学年份越界, 置空: RXNF={}", year);
return null;
}
return year;
}
/**
* 解析辅导员姓名。
*
* <p>FDYH 可能是一组逗号分隔的工号(实测 32 个班级如此),需要逐个解析后用「、」连接。
*/
String buildCounselorName(JSONObject row, Map<String, String> teacherNames) {
String raw = OpenPlatformClient.field(row, "FDYH");
if (StrUtil.isBlank(raw)) {
return null;
}
Set<String> names = new LinkedHashSet<>();
for (String part : raw.split("[,]")) {
String jgh = StrUtil.trimToNull(part);
if (jgh == null) {
continue;
}
String name = teacherNames.get(jgh);
names.add(StrUtil.isBlank(name) ? jgh : name);
}
if (names.isEmpty()) {
return null;
}
String joined = String.join("", names);
return joined.length() > COUNSELOR_NAME_MAX ? joined.substring(0, COUNSELOR_NAME_MAX) : joined;
}
/**
* 本次未从平台拉到的同步来源班级置为停用。
*
* <p>只处理 class_code 非空的记录,人工创建的研究生班不受影响。
* 复用同步开头已加载的列表,避免再全表查一次。
*/
private int deactivateMissing(Set<String> incoming, List<ClassInfo> existingList) {
List<Integer> ids = new ArrayList<>();
for (ClassInfo item : existingList) {
if (!incoming.contains(StrUtil.trim(item.getClassCode()))
&& SyncScope.shouldDeactivate(item.getClassCode(), false)) {
ids.add(item.getId());
}
}
if (ids.isEmpty()) {
return 0;
}
logger.warn("班级中有 {} 条记录本次未从开放平台拉到, 置为停用", ids.size());
classInfoService.removeByIds(ids);
return ids.size();
}
private List<ClassInfo> listSyncedClasses() {
return classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
.eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID)
.isNotNull(ClassInfo::getClassCode));
}
/**
* 容错地把平台字段解析为整数。平台的人数形如 "1.0"。
*/
static Integer parseInteger(JSONObject row, String key) {
String text = OpenPlatformClient.field(row, key);
if (StrUtil.isBlank(text)) {
return null;
}
try {
return (int) Math.round(Double.parseDouble(text));
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,142 @@
package com.gxwebsoft.gxmu.openplat.sync;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.gxmu.openplat.OpenPlatformClient;
import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants;
import com.gxwebsoft.gxmu.openplat.SyncResult;
import com.gxwebsoft.gxmu.openplat.SyncScope;
import com.gxwebsoft.gxmu.openplat.SyncSupport;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 部门同步:RS_BMXX -> sys_organization。
*
* <p>全部部门扁平挂在「广西医科大学」(id=24) 下;实测 75 个部门中仅
* 「附属小学」「幼儿园」有可推出的父节点,DWH 前缀推不出真实层级,因此不做层级推导。
*
* <p>只操作 organization_code 非空的记录(同步来源)。校团委等人工创建的节点
* 没有 organization_code,天然不会被停用,见 docs/adr/0004。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class DeptSyncHandler {
private static final Logger logger = LoggerFactory.getLogger(DeptSyncHandler.class);
@Resource
private OpenPlatformClient client;
@Resource
private OrganizationService organizationService;
@Resource
private SyncSupport support;
public SyncResult sync() {
SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_DEPT);
JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_DEPT);
Map<String, String> teacherNames = support.teacherNameByJgh();
Map<String, Organization> existing = new HashMap<>();
List<Organization> existingList = listSyncedOrganizations();
for (Organization org : existingList) {
existing.put(StrUtil.trim(org.getOrganizationCode()), org);
}
Set<String> incoming = new HashSet<>();
List<Organization> toUpsert = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String dwh = OpenPlatformClient.field(row, "DWH");
String dwmc = OpenPlatformClient.field(row, "DWMC");
if (StrUtil.isBlank(dwh) || StrUtil.isBlank(dwmc)) {
result.setFailCount(result.getFailCount() + 1);
continue;
}
incoming.add(dwh);
Organization org = existing.get(dwh);
boolean isNew = org == null;
if (isNew) {
org = new Organization();
org.setTenantId(OpenplatSyncConstants.TENANT_ID);
org.setParentId(OpenplatSyncConstants.ORG_PARENT_ID);
org.setSortNumber(0);
org.setCreateTime(new Date());
org.setDeleted(0);
}
org.setOrganizationCode(dwh);
org.setOrganizationName(dwmc);
org.setOrganizationFullName(dwmc);
org.setOrganizationType(StrUtil.blankToDefault(OpenPlatformClient.field(row, "DWLB"), "0"));
// FZR 实际是工号而非姓名;用教师名册解析,解析不到就保留工号
String fzr = OpenPlatformClient.field(row, "FZR");
if (StrUtil.isNotBlank(fzr)) {
org.setComments("负责人:" + teacherNames.getOrDefault(fzr, fzr));
}
org.setUpdateTime(new Date());
toUpsert.add(org);
}
// 一条多行 upsert 代替逐条 save/update
support.upsertOrganizations(toUpsert);
result.setSuccessCount(incoming.size());
int deactivated = deactivateMissing(incoming, existingList);
result.setSkipCount(deactivated);
logger.warn("部门同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条",
incoming.size(), deactivated, result.getFailCount());
return result;
}
/**
* 本次未从平台拉到的同步来源部门置为停用。
*
* <p>只处理 organization_code 非空的记录;并显式跳过校团委(id=18)作为双保险。
* 复用同步开头已加载的列表,避免再全表查一次。
*/
private int deactivateMissing(Set<String> incoming, List<Organization> existingList) {
List<Integer> ids = new ArrayList<>();
for (Organization org : existingList) {
if (org.getOrganizationId() != null
&& org.getOrganizationId() == OpenplatSyncConstants.ORG_KEEP_ID) {
continue;
}
if (!incoming.contains(StrUtil.trim(org.getOrganizationCode()))) {
if (SyncScope.shouldDeactivate(org.getOrganizationCode(), false)) {
ids.add(org.getOrganizationId());
}
}
}
if (ids.isEmpty()) {
return 0;
}
logger.warn("部门中有 {} 条记录本次未从开放平台拉到, 置为停用", ids.size());
organizationService.removeByIds(ids);
return ids.size();
}
private List<Organization> listSyncedOrganizations() {
return organizationService.list(new LambdaQueryWrapper<Organization>()
.eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID)
.isNotNull(Organization::getOrganizationCode));
}
}
@@ -0,0 +1,91 @@
package com.gxwebsoft.gxmu.openplat.sync;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.gxmu.openplat.OpenPlatformClient;
import com.gxwebsoft.gxmu.openplat.OpenplatCodeService;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
/**
* 学生三源合并的字段优先级规则。
*
* <p>从 StudentSyncHandler 中抽出来单独成类,是为了让「谁覆盖谁」这条规则成为
* 可单测的纯逻辑——写反了不会报错,只会静默产生错误的人事数据。
*
* <pre>
* xsjbxxq 主源:姓名/性别/学院/班级/身份证/专业(在校生,含研究生)
* LY_XXBZ_JW_XSXXB 补源:民族/政治面貌/出生日期/学籍状态/班号(含已毕业)
* </pre>
*
* @author Codex
* @since 2026-09-12
*/
public final class StudentSourceMerger {
private StudentSourceMerger() {
}
/**
* 码值解析器:字典编码 + 平台原始码 -> 名称
*/
@FunctionalInterface
public interface CodeDecoder {
String decode(String dictCode, String rawCode);
}
/**
* 主源 xsjbxxq:这些字段以它为准,直接覆盖。
*
* <p>xsjbxxq 是在校生接口,因此并入的学生默认置为在校(status=1)。
*/
public static void applyMain(GxmuStudent student, JSONObject row, CodeDecoder decoder) {
student.setXm(OpenPlatformClient.field(row, "XM"));
student.setXbm(OpenPlatformClient.field(row, "XBM"));
student.setSex(decoder.decode(OpenplatCodeService.DICT_SEX, student.getXbm()));
student.setSfzjh(OpenPlatformClient.field(row, "SFZJH"));
student.setDwh(OpenPlatformClient.field(row, "DWH"));
student.setBjm(OpenPlatformClient.field(row, "BJM"));
student.setZyh(OpenPlatformClient.field(row, "ZYH"));
student.setZymc(OpenPlatformClient.field(row, "ZYMC"));
student.setPyccmc(OpenPlatformClient.field(row, "PYCCMC"));
if (student.getStatus() == null) {
student.setStatus(1);
}
}
/**
* 补源 LY_XXBZ_JW_XSXXB:姓名/性别/学院/班级**只补空缺,绝不覆盖主源**;
* 民族/政治面貌/出生日期/学籍状态等主源没有的字段直接写入。
*
* <p>注意 BH(学籍库班号) 与 BJM(平台班级码) 实测是同一套编码:
* 43,117 名两接口都有的学生 BH == BJM 100% 一致,因此可以互为兜底。
*/
public static void applyAcademic(GxmuStudent student, JSONObject row, CodeDecoder decoder) {
if (StrUtil.isBlank(student.getXm())) {
student.setXm(OpenPlatformClient.field(row, "XM"));
}
if (StrUtil.isBlank(student.getXbm())) {
student.setXbm(OpenPlatformClient.field(row, "XBM"));
student.setSex(decoder.decode(OpenplatCodeService.DICT_SEX, student.getXbm()));
}
if (StrUtil.isBlank(student.getDwh())) {
student.setDwh(OpenPlatformClient.field(row, "DWH"));
}
student.setBh(OpenPlatformClient.field(row, "BH"));
if (StrUtil.isBlank(student.getBjm())) {
student.setBjm(student.getBh());
}
student.setMzm(OpenPlatformClient.field(row, "MZM"));
student.setNation(decoder.decode(OpenplatCodeService.DICT_NATION, student.getMzm()));
student.setZzmmm(OpenPlatformClient.field(row, "ZZMMM"));
student.setPolitics(decoder.decode(OpenplatCodeService.DICT_POLITICS, student.getZzmmm()));
student.setCsrq(OpenPlatformClient.field(row, "CSRQ"));
student.setSfzx(OpenPlatformClient.field(row, "SFZX"));
student.setXjzt(OpenPlatformClient.field(row, "XJZT"));
student.setXsdqzt(OpenPlatformClient.field(row, "XSDQZT"));
if (student.getStatus() == null && "0".equals(student.getSfzx())) {
student.setStatus(0);
}
}
}
@@ -0,0 +1,196 @@
package com.gxwebsoft.gxmu.openplat.sync;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.gxmu.openplat.OpenPlatformClient;
import com.gxwebsoft.gxmu.openplat.OpenPlatformException;
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.entity.GxmuStudent;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 学生同步:三个接口按学号合并 -> gxmu_student。
*
* <pre>
* xsjbxxq 在校生(含研究生) 58,655 姓名/性别/学院/班级/身份证/专业
* LY_XXBZ_JW_XSXXB 本专科(含毕业) 82,649 民族/政治面貌/出生日期/学籍状态/班号
* yhxx_xsqtykt 已毕业 14,059 手机号
* xsyhxx_ykt 在校生(未授权) 手机号(优先)
* 并集 98,187
* </pre>
*
* <p>实测 BH(学籍库班号) 与 BJM(平台班级码) 是同一套编码:43,117 名两接口都有的学生
* BH == BJM 100% 一致,因此 class_id 统一按班级码解析。
*
* <p>研究生(15,538 人)在平台上没有班级码,class_id 落 null,只归属学院。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class StudentSyncHandler {
private static final Logger logger = LoggerFactory.getLogger(StudentSyncHandler.class);
@Resource
private OpenPlatformClient client;
@Resource
private SyncSupport support;
@Resource
private OpenplatCodeService codeService;
public SyncResult sync() {
SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_STUDENT);
Map<String, Integer> orgIds = support.orgIdByCode();
Map<String, Integer> classIds = support.classIdByCode();
Map<String, GxmuStudent> merged = new HashMap<>(131072);
// 1) 在校生(含研究生):主源
mergeSource(merged, OpenplatSyncConstants.EP_STUDENT_ALL, Source.MAIN, orgIds, classIds);
// 2) 本专科(含毕业):补民族/政治面貌/出生日期/学籍状态,并纳入已毕业人群
mergeSource(merged, OpenplatSyncConstants.EP_STUDENT_BK, Source.ACADEMIC, orgIds, classIds);
// 3) 在校生手机号(未授权时静默降级)
Map<String, String> inSchoolPhone = loadPhoneQuietly(
OpenplatSyncConstants.EP_STUDENT_YKT_INSCHOOL);
// 4) 已毕业手机号
Map<String, String> graduatePhone = loadPhoneQuietly(OpenplatSyncConstants.EP_STUDENT_YKT);
applyPhones(merged, inSchoolPhone, graduatePhone);
LocalDateTime now = SyncSupport.now();
Set<String> incoming = new HashSet<>(merged.size());
java.util.List<GxmuStudent> list = new java.util.ArrayList<>(merged.size());
for (Map.Entry<String, GxmuStudent> entry : merged.entrySet()) {
GxmuStudent student = entry.getValue();
student.setXh(entry.getKey());
student.setTenantId(OpenplatSyncConstants.TENANT_ID);
student.setSyncTime(now);
student.setCreateTime(now);
student.setUpdateTime(now);
student.setDeleted(0);
if (student.getStatus() == null) {
student.setStatus(0);
}
incoming.add(entry.getKey());
list.add(student);
}
support.upsertStudents(list);
result.setSuccessCount(list.size());
int deactivated = support.deactivateMissingStudents(incoming);
result.setSkipCount(deactivated);
logger.warn("学生同步完成: 写入 {} 条, 停用 {} 条, 失败 {} 条",
list.size(), deactivated, result.getFailCount());
return result;
}
/**
* 拉取某接口并合并进结果集。
*
* <p>逐个接口处理而不是把三份原始 JSON 同时留在内存里,避免十几万个 JSONObject 峰值。
*/
private void mergeSource(Map<String, GxmuStudent> merged, String endpoint, Source source,
Map<String, Integer> orgIds, Map<String, Integer> classIds) {
JSONArray rows = client.fetchAll(endpoint);
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String xh = OpenPlatformClient.field(row, "XH");
if (StrUtil.isBlank(xh)) {
continue;
}
GxmuStudent student = merged.computeIfAbsent(xh, k -> new GxmuStudent());
if (source == Source.MAIN) {
StudentSourceMerger.applyMain(student, row, codeService::decode);
} else {
StudentSourceMerger.applyAcademic(student, row, codeService::decode);
}
resolveRelations(student, orgIds, classIds);
}
logger.warn("学生同步: 合并 {} 共 {} 条, 累计 {} 人", endpoint, rows.size(), merged.size());
}
/**
* 拉取手机号来源;接口未授权时记 warn 并返回空表,不阻断整批同步。
*/
private Map<String, String> loadPhoneQuietly(String endpoint) {
Map<String, String> map = new HashMap<>();
try {
JSONArray rows = client.fetchAll(endpoint);
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String xh = OpenPlatformClient.field(row, "XH");
String sjh = OpenPlatformClient.field(row, "SJH");
if (StrUtil.isNotBlank(xh) && StrUtil.isNotBlank(sjh)) {
map.put(xh, sjh);
}
}
logger.warn("手机号来源 {} 拉到 {} 条带号码记录", endpoint, map.size());
} catch (OpenPlatformException e) {
logger.warn("手机号来源 {} 暂不可用, 跳过: {}", endpoint, e.getMessage());
}
return map;
}
private void applyPhones(Map<String, GxmuStudent> merged,
Map<String, String> inSchoolPhone,
Map<String, String> graduatePhone) {
// 在校生手机号优先
inSchoolPhone.forEach((xh, sjh) -> {
GxmuStudent student = merged.get(xh);
if (student != null) {
student.setSjh(sjh);
}
});
graduatePhone.forEach((xh, sjh) -> {
GxmuStudent student = merged.get(xh);
if (student != null && StrUtil.isBlank(student.getSjh())) {
student.setSjh(sjh);
}
});
}
private void resolveRelations(GxmuStudent student,
Map<String, Integer> orgIds,
Map<String, Integer> classIds) {
String dwh = student.getDwh();
if (StrUtil.isNotBlank(dwh)) {
student.setCollegeId(orgIds.get(dwh));
}
String bjm = student.getBjm();
if (StrUtil.isNotBlank(bjm)) {
student.setClassId(classIds.get(bjm));
}
}
private enum Source {
/**
* 在校生主源 xsjbxxq
*/
MAIN,
/**
* 本专科学籍库 LY_XXBZ_JW_XSXXB
*/
ACADEMIC
}
}
@@ -0,0 +1,95 @@
package com.gxwebsoft.gxmu.openplat.sync;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.gxmu.openplat.OpenPlatformClient;
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.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 教师同步:scjk_jsyhxx -> gxmu_teacher。
*
* <p>全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位,
* 筛掉会导致班级同步的工号解析失败。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class TeacherSyncHandler {
private static final Logger logger = LoggerFactory.getLogger(TeacherSyncHandler.class);
@Resource
private OpenPlatformClient client;
@Resource
private SyncSupport support;
@Resource
private OpenplatCodeService codeService;
public SyncResult sync() {
SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_TEACHER);
JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_TEACHER);
Map<String, Integer> orgIds = support.orgIdByCode();
LocalDateTime now = SyncSupport.now();
List<GxmuTeacher> list = new ArrayList<>();
Set<String> incoming = new HashSet<>();
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String jgh = OpenPlatformClient.field(row, "JGH");
String xm = OpenPlatformClient.field(row, "XM");
if (StrUtil.isBlank(jgh) || StrUtil.isBlank(xm)) {
result.setFailCount(result.getFailCount() + 1);
continue;
}
incoming.add(jgh);
String dwh = OpenPlatformClient.field(row, "DWH");
String xbm = OpenPlatformClient.field(row, "XBM");
GxmuTeacher teacher = new GxmuTeacher();
teacher.setJgh(jgh);
teacher.setXm(xm);
teacher.setXbm(xbm);
teacher.setSex(codeService.decode(OpenplatCodeService.DICT_SEX, xbm));
teacher.setDwh(dwh);
teacher.setCollegeId(StrUtil.isBlank(dwh) ? null : orgIds.get(dwh));
teacher.setKsjybh(OpenPlatformClient.field(row, "KSJYBH"));
teacher.setSjh(OpenPlatformClient.field(row, "SJH"));
teacher.setStatus(1);
teacher.setSyncTime(now);
teacher.setTenantId(OpenplatSyncConstants.TENANT_ID);
teacher.setCreateTime(now);
teacher.setUpdateTime(now);
teacher.setDeleted(0);
list.add(teacher);
}
support.upsertTeachers(list);
result.setSuccessCount(incoming.size());
int deactivated = support.deactivateMissingTeachers(incoming);
result.setSkipCount(deactivated);
logger.warn("教师同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条",
incoming.size(), deactivated, result.getFailCount());
return result;
}
}
@@ -0,0 +1,56 @@
package com.gxwebsoft.gxmu.openplat.task;
import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord;
import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 医科大开放平台定时同步任务。
*
* <p>每天 03:30 全量同步部门、教师、班级、学生。
*
* <p>与手动同步共用同一把「单批次」锁:定时到点时若手动同步还在跑,
* {@code submitAsync} 会返回 null,本轮调度直接跳过。
*
* <p>注意:{@code @Scheduled} 线程没有 SecurityContexttenantId 由同步逻辑
* 显式使用 OpenplatSyncConstants.TENANT_ID,不依赖 BaseController.getTenantId()。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class OpenplatSyncTask {
private static final Logger logger = LoggerFactory.getLogger(OpenplatSyncTask.class);
@Resource
private OpenplatSyncService openplatSyncService;
@Value("${gxmu.openplat-sync.enabled:true}")
private boolean enabled;
@Scheduled(cron = "${gxmu.openplat-sync.cron:0 30 3 * * ?}")
public void syncAll() {
if (!enabled) {
return;
}
try {
String batchId = openplatSyncService.submitAsync(
GxmuSyncRecord.TRIGGER_SCHEDULE, null, null);
if (batchId == null) {
logger.warn("开放平台同步任务仍在执行,跳过本轮调度");
return;
}
logger.warn("开放平台定时同步已提交, 批次 {}", batchId);
} catch (Exception e) {
logger.error("开放平台定时同步任务提交失败", e);
}
}
}