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 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); } /** * 分页拉取某接口的全量数据。 * *

注意:平台返回的 {@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 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); } /** * 大小写不敏感地取字段值。 * *

平台的 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); } } }