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; /** * 医科大开放平台客户端。 * *
负责 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 注意:平台返回的 {@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 平台的 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);
}
}
}