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)。
271 lines
10 KiB
Java
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} 字段是本次返回条数而非总数,
|
|
* 因此只能以「返回条数 < 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);
|
|
}
|
|
}
|
|
|
|
}
|