Files
tuanwei-java/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClient.java
T
weicw1996 cd54039f28 接入医科大开放平台数据同步
新增 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)。
2026-09-15 15:54:49 +08:00

271 lines
10 KiB
Java

package com.gxwebsoft.gxmu.openplat;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.DES;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.constants.RedisConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 医科大开放平台客户端。
*
* <p>负责 oauth 鉴权、token 缓存与失效重取、分页拉取全量数据。
* 平台 token 会被提前失效(实测声称 14399 秒、不到 10 分钟即失效),
* 因此任何一次请求遇到 token 失效都必须清缓存重取后重试。
*
* @author Codex
* @since 2026-09-12
*/
@Component
public class OpenPlatformClient {
private static final Logger logger = LoggerFactory.getLogger(OpenPlatformClient.class);
/**
* DES 密钥。与平台文档 DesUtil 一致,java DESKeySpec 只取前 8 字节,
* 此处显式取前 8 字节,效果等价且避免依赖具体 JDK 实现。
*/
private static final String DES_KEY = "LianYi37621040";
/**
* 分页保护上限,避免异常情况下无限翻页
*/
private static final int MAX_PAGES = 50;
/**
* token 缓存提前过期时间(秒)
*/
private static final long TOKEN_EXPIRE_AHEAD_SECONDS = 300L;
@Resource
private GxmuOpenplatProperties properties;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 获取 access_token,优先取缓存
*/
public String getToken() {
String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
return refreshToken();
}
/**
* 强制重新获取 access_token 并写入缓存
*/
public synchronized String refreshToken() {
String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY);
if (StrUtil.isNotBlank(cached)) {
return cached;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("grant_type", "password");
params.put("scope", "read");
params.put("username", encrypt(properties.getKfzzh()));
params.put("password", encrypt(properties.getKfzmm()));
String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/oauth/token", params,
CharsetUtil.CHARSET_UTF_8, true);
String basic = Base64.encode(properties.getAppId() + ":" + properties.getAppSecret(),
CharsetUtil.CHARSET_UTF_8);
String body;
try {
HttpResponse response = HttpRequest.post(url)
.timeout(properties.getTimeoutMs())
.header(Header.AUTHORIZATION, "Basic " + basic)
.body("")
.header(Header.CONTENT_TYPE, "application/json")
.execute();
body = response.body();
} catch (Exception e) {
throw new OpenPlatformException("开放平台鉴权请求失败", e);
}
JSONObject json = parseObject(body);
String token = json.getString("access_token");
if (StrUtil.isBlank(token)) {
throw new OpenPlatformException("开放平台鉴权失败: " + messageOf(json));
}
long expiresIn = json.getLongValue("expires_in");
long ttl = expiresIn > TOKEN_EXPIRE_AHEAD_SECONDS ? expiresIn - TOKEN_EXPIRE_AHEAD_SECONDS : 60L;
stringRedisTemplate.opsForValue().set(RedisConstants.OPENPLAT_TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
logger.warn("开放平台 token 已刷新, 有效期 {} 秒, 缓存 {} 秒", expiresIn, ttl);
return token;
}
/**
* 清除 token 缓存
*/
public void clearToken() {
stringRedisTemplate.delete(RedisConstants.OPENPLAT_TOKEN_KEY);
}
/**
* 分页拉取某接口的全量数据。
*
* <p>注意:平台返回的 {@code total} 字段是本次返回条数而非总数,
* 因此只能以「返回条数 &lt; pageSize」作为结束条件。
*
* @param jkbq 接口标签,即 /get/{jkbq} 的路径段
* @return 全量记录
*/
public JSONArray fetchAll(String jkbq) {
JSONArray all = new JSONArray();
int pageSize = properties.getPageSize() == null || properties.getPageSize() <= 0
? 10000 : properties.getPageSize();
for (int page = 1; page <= MAX_PAGES; page++) {
JSONArray rows = fetchPage(jkbq, page, pageSize);
all.addAll(rows);
logger.warn("开放平台拉取 {}(page={}) 返回 {} 条, 累计 {} 条", jkbq, page, rows.size(), all.size());
if (rows.size() < pageSize) {
break;
}
}
return all;
}
/**
* 拉取某一页
*/
public JSONArray fetchPage(String jkbq, int page, int pageSize) {
int attempts = properties.getMaxRetries() == null || properties.getMaxRetries() < 1
? 1 : properties.getMaxRetries();
RuntimeException lastException = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
Map<String, Object> params = new LinkedHashMap<>();
params.put("access_token", getToken());
params.put("page", page);
params.put("pageSize", pageSize);
String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/get/" + jkbq, params,
CharsetUtil.CHARSET_UTF_8, true);
String body = HttpRequest.get(url)
.timeout(properties.getTimeoutMs())
.header(Header.ACCEPT, "application/json")
.execute()
.body();
JSONObject json = parseObject(body);
if (isTokenInvalid(json)) {
logger.warn("开放平台 token 失效, 重新获取后重试: {} page={}", jkbq, page);
clearToken();
continue;
}
int code = json.getIntValue("code");
if (code != 200) {
throw new OpenPlatformException("开放平台接口 " + jkbq + " 返回异常: " + messageOf(json));
}
JSONArray data = json.getJSONArray("data");
return data == null ? new JSONArray() : data;
} catch (OpenPlatformException e) {
throw e;
} catch (Exception e) {
lastException = new OpenPlatformException(
"调用开放平台接口失败: " + jkbq + " page=" + page, e);
logger.warn("调用开放平台接口失败, 第 {}/{} 次: {} page={}", attempt, attempts, jkbq, page);
}
}
clearToken();
if (lastException != null) {
throw lastException;
}
throw new OpenPlatformException("开放平台接口调用失败(可能为 token 持续失效): " + jkbq);
}
/**
* DES 加密,输出大写十六进制。与平台文档 DesUtil.encrypt 等价。
*/
public static String encrypt(String plain) {
if (plain == null) {
return null;
}
byte[] key = DES_KEY.substring(0, 8).getBytes(StandardCharsets.UTF_8);
DES des = SecureUtil.des(key);
return des.encryptHex(plain).toUpperCase(Locale.ROOT);
}
/**
* 大小写不敏感地取字段值。
*
* <p>平台的 findColumn 元数据返回小写字段名,实际数据却是大写(如 XH),
* 因此不能直接按元数据的字段名取值。
*/
public static String field(JSONObject row, String name) {
if (row == null || name == null) {
return null;
}
Object value = row.get(name);
if (value == null) {
value = row.get(name.toUpperCase(Locale.ROOT));
}
if (value == null) {
value = row.get(name.toLowerCase(Locale.ROOT));
}
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return "NULL".equalsIgnoreCase(text) ? null : text;
}
private boolean isTokenInvalid(JSONObject json) {
if (json == null) {
return false;
}
String code = json.getString("code");
if ("401".equals(code)) {
return true;
}
String message = json.getString("message");
if (message != null && message.contains("过期或无效")) {
return true;
}
String description = json.getString("error_description");
return description != null && description.toLowerCase(Locale.ROOT).contains("invalid access token");
}
private String messageOf(JSONObject json) {
if (json == null) {
return "响应为空";
}
String message = json.getString("message");
if (StrUtil.isNotBlank(message)) {
return message;
}
String description = json.getString("error_description");
return StrUtil.isNotBlank(description) ? description : json.toJSONString();
}
private JSONObject parseObject(String body) {
if (StrUtil.isBlank(body)) {
throw new OpenPlatformException("开放平台返回空响应");
}
try {
return JSON.parseObject(body);
} catch (Exception e) {
throw new OpenPlatformException("开放平台返回内容无法解析为 JSON", e);
}
}
}