接入医科大开放平台数据同步
新增 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:
@@ -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} 字段是本次返回条数而非总数,
|
||||
* 因此只能以「返回条数 < 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 为 dictCode,dict_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 <> ''
|
||||
</select>
|
||||
|
||||
<update id="deactivateByXh">
|
||||
UPDATE gxmu_student
|
||||
SET status = 0, update_time = NOW()
|
||||
WHERE tenant_id = #{tenantId} AND status <> 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 <> ''
|
||||
</select>
|
||||
|
||||
<update id="deactivateByJgh">
|
||||
UPDATE gxmu_teacher
|
||||
SET status = 0, update_time = NOW()
|
||||
WHERE tenant_id = #{tenantId} AND status <> 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/update:1584 条从 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} 线程没有 SecurityContext,tenantId 由同步逻辑
|
||||
* 显式使用 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user