| New file |
| | |
| | | package com.tcm.training.admin.utils; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.http.HttpEntity; |
| | | import org.springframework.http.HttpHeaders; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.http.client.SimpleClientHttpRequestFactory; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.util.StringUtils; |
| | | import org.springframework.web.client.RestOperations; |
| | | import org.springframework.web.client.RestTemplate; |
| | | |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.time.*; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.time.format.DateTimeParseException; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 微赞开放平台HTTP工具,负责Token获取、缓存和自动刷新。 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class VzanHttpUtil { |
| | | |
| | | private static final String TOKEN_URL = "https://openapi.njyqkj0ksyz.com/api/v2/token/get_token"; |
| | | private static final String ACCOUNT = "Vzan1247381477"; |
| | | private static final String SECRET_KEY = "b1bd524170574dc085a2d75adcd1c644 "; |
| | | private static final long REFRESH_AHEAD_MILLIS = 60_000L; |
| | | private static final DateTimeFormatter DATE_TIME_FORMATTER = |
| | | DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); |
| | | |
| | | private final Object refreshLock = new Object(); |
| | | private final RestOperations restOperations; |
| | | private final ObjectMapper objectMapper; |
| | | private final Clock clock; |
| | | |
| | | private volatile TokenSnapshot tokenSnapshot; |
| | | |
| | | @Autowired |
| | | public VzanHttpUtil(ObjectMapper objectMapper) { |
| | | this(createRestTemplate(), objectMapper, Clock.systemDefaultZone()); |
| | | } |
| | | |
| | | public VzanHttpUtil(RestOperations restOperations, ObjectMapper objectMapper, Clock clock) { |
| | | this.restOperations = restOperations; |
| | | this.objectMapper = objectMapper; |
| | | this.clock = clock; |
| | | } |
| | | |
| | | /** |
| | | * 获取有效Token;Token不存在或即将过期时自动刷新。 |
| | | */ |
| | | public String getToken() { |
| | | TokenSnapshot current = tokenSnapshot; |
| | | if (isUsable(current)) { |
| | | return current.token; |
| | | } |
| | | synchronized (refreshLock) { |
| | | current = tokenSnapshot; |
| | | if (isUsable(current)) { |
| | | return current.token; |
| | | } |
| | | return requestAndCacheToken().token; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 忽略当前缓存并立即获取新Token。 |
| | | */ |
| | | public String refreshToken() { |
| | | synchronized (refreshLock) { |
| | | return requestAndCacheToken().token; |
| | | } |
| | | } |
| | | |
| | | private TokenSnapshot requestAndCacheToken() { |
| | | validateCredentials(); |
| | | long timestamp = clock.millis(); |
| | | |
| | | Map<String, String> body = new LinkedHashMap<String, String>(); |
| | | body.put("account", ACCOUNT.trim()); |
| | | body.put("signkey", md5Lowercase(SECRET_KEY.trim() + timestamp)); |
| | | body.put("timestamp", String.valueOf(timestamp)); |
| | | |
| | | HttpHeaders headers = new HttpHeaders(); |
| | | headers.setContentType(MediaType.APPLICATION_JSON); |
| | | HttpEntity<Map<String, String>> request = new HttpEntity<Map<String, String>>(body, headers); |
| | | ResponseEntity<String> response = restOperations.postForEntity(TOKEN_URL, request, String.class); |
| | | TokenSnapshot received = parseTokenResponse(response.getBody()); |
| | | tokenSnapshot = received; |
| | | return received; |
| | | } |
| | | |
| | | private TokenSnapshot parseTokenResponse(String responseBody) { |
| | | if (!StringUtils.hasText(responseBody)) { |
| | | throw new IllegalStateException("微赞Token接口返回为空"); |
| | | } |
| | | try { |
| | | JsonNode root = objectMapper.readTree(responseBody); |
| | | if (!"0".equals(root.path("code").asText())) { |
| | | throw new IllegalStateException("获取微赞Token失败: " + root.path("msg").asText("未知错误")); |
| | | } |
| | | JsonNode data = root.path("data"); |
| | | String token = data.path("token").asText(); |
| | | String tokenExpires = data.path("tokenExpires").asText(); |
| | | if (!StringUtils.hasText(token) || !StringUtils.hasText(tokenExpires)) { |
| | | throw new IllegalStateException("微赞Token接口返回数据不完整"); |
| | | } |
| | | return new TokenSnapshot(token, parseExpiresAt(tokenExpires)); |
| | | } catch (IllegalStateException ex) { |
| | | throw ex; |
| | | } catch (Exception ex) { |
| | | throw new IllegalStateException("解析微赞Token接口响应失败", ex); |
| | | } |
| | | } |
| | | |
| | | private Instant parseExpiresAt(String value) { |
| | | String normalized = value.trim(); |
| | | try { |
| | | long numericValue = Long.parseLong(normalized); |
| | | if (numericValue < 10_000_000L) { |
| | | return clock.instant().plusSeconds(numericValue); |
| | | } |
| | | return numericValue < 10_000_000_000L |
| | | ? Instant.ofEpochSecond(numericValue) |
| | | : Instant.ofEpochMilli(numericValue); |
| | | } catch (NumberFormatException ignored) { |
| | | // 继续兼容标准日期时间格式。 |
| | | } |
| | | try { |
| | | return Instant.parse(normalized); |
| | | } catch (DateTimeParseException ignored) { |
| | | // 继续兼容带时区和无时区格式。 |
| | | } |
| | | try { |
| | | return OffsetDateTime.parse(normalized).toInstant(); |
| | | } catch (DateTimeParseException ignored) { |
| | | // 继续兼容微赞常用的本地日期时间格式。 |
| | | } |
| | | try { |
| | | LocalDateTime localDateTime = LocalDateTime.parse(normalized, DATE_TIME_FORMATTER); |
| | | return localDateTime.atZone(ZoneId.systemDefault()).toInstant(); |
| | | } catch (DateTimeParseException ex) { |
| | | throw new IllegalStateException("无法识别微赞Token过期时间: " + normalized, ex); |
| | | } |
| | | } |
| | | |
| | | private boolean isUsable(TokenSnapshot snapshot) { |
| | | return snapshot != null |
| | | && snapshot.expiresAt.toEpochMilli() - clock.millis() > REFRESH_AHEAD_MILLIS; |
| | | } |
| | | |
| | | private void validateCredentials() { |
| | | if (!StringUtils.hasText(ACCOUNT) || !StringUtils.hasText(SECRET_KEY)) { |
| | | throw new IllegalStateException("请先配置微赞PAAS账号和客户密钥"); |
| | | } |
| | | } |
| | | |
| | | private static String md5Lowercase(String text) { |
| | | try { |
| | | MessageDigest digest = MessageDigest.getInstance("MD5"); |
| | | byte[] bytes = digest.digest(text.getBytes(StandardCharsets.UTF_8)); |
| | | StringBuilder result = new StringBuilder(bytes.length * 2); |
| | | for (byte value : bytes) { |
| | | result.append(String.format("%02x", value & 0xff)); |
| | | } |
| | | return result.toString(); |
| | | } catch (NoSuchAlgorithmException ex) { |
| | | throw new IllegalStateException("当前运行环境不支持MD5", ex); |
| | | } |
| | | } |
| | | |
| | | private static RestTemplate createRestTemplate() { |
| | | SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); |
| | | requestFactory.setConnectTimeout(10_000); |
| | | requestFactory.setReadTimeout(10_000); |
| | | return new RestTemplate(requestFactory); |
| | | } |
| | | |
| | | private static final class TokenSnapshot { |
| | | private final String token; |
| | | private final Instant expiresAt; |
| | | |
| | | private TokenSnapshot(String token, Instant expiresAt) { |
| | | this.token = token; |
| | | this.expiresAt = expiresAt; |
| | | } |
| | | } |
| | | |
| | | // /** |
| | | // * 启动时验证微赞Token接口配置和连通性,失败仅记录日志,不阻断应用启动。 |
| | | // */ |
| | | // @PostConstruct |
| | | // public void init() { |
| | | // if (!StringUtils.hasText(ACCOUNT) || !StringUtils.hasText(SECRET_KEY)) { |
| | | // log.warn("微赞Token连通性测试已跳过:PAAS账号或客户密钥未配置"); |
| | | // return; |
| | | // } |
| | | // try { |
| | | // String token = getToken(); |
| | | // log.info("微赞Token获取成功:{}", token); |
| | | // } catch (Exception ex) { |
| | | // log.error("微赞Token获取失败", ex); |
| | | // } |
| | | // } |
| | | } |