package com.tourguide.integration.unipush; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.tourguide.config.properties.UniPushProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.UUID; @Component public class UniPushClient { private static final Logger log = LoggerFactory.getLogger(UniPushClient.class); private static final long TOKEN_REFRESH_ADVANCE_MILLIS = 60_000L; private final UniPushProperties properties; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final Object tokenMonitor = new Object(); private volatile String token; private volatile long tokenExpireAt; public UniPushClient(UniPushProperties properties, @Qualifier("uniPushRestTemplate") RestTemplate restTemplate, ObjectMapper objectMapper) { this.properties = properties; this.restTemplate = restTemplate; this.objectMapper = objectMapper; } public boolean pushToCid(String cid, String title, String content, String targetUrl) { if (!isConfigured() || !StringUtils.hasText(cid)) return false; try { ObjectNode request = objectMapper.createObjectNode(); request.put("request_id", UUID.randomUUID().toString().replace("-", "")); request.putObject("settings").put("ttl", 3_600_000); ArrayNode cids = request.putObject("audience").putArray("cid"); cids.add(cid); ObjectNode notification = request.putObject("push_message").putObject("notification"); notification.put("title", title); notification.put("body", content); notification.put("click_type", "payload"); notification.put("payload", objectMapper.writeValueAsString( objectMapper.createObjectNode().put("targetUrl", targetUrl == null ? "" : targetUrl))); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("token", accessToken()); JsonNode response = restTemplate.postForObject(endpoint("/push/single/cid"), new HttpEntity<>(request, headers), JsonNode.class); boolean success = response != null && response.path("code").asInt(-1) == 0; if (!success) log.warn("UniPush 单推失败,cid={},响应={}", cid, response); return success; } catch (Exception exception) { log.warn("UniPush 单推异常,cid={}", cid, exception); return false; } } private String accessToken() throws Exception { long now = System.currentTimeMillis(); if (StringUtils.hasText(token) && tokenExpireAt - TOKEN_REFRESH_ADVANCE_MILLIS > now) return token; synchronized (tokenMonitor) { now = System.currentTimeMillis(); if (StringUtils.hasText(token) && tokenExpireAt - TOKEN_REFRESH_ADVANCE_MILLIS > now) return token; long timestamp = Instant.now().toEpochMilli(); ObjectNode request = objectMapper.createObjectNode(); request.put("sign", sha256(properties.getAppKey() + timestamp + properties.getMasterSecret())); request.put("timestamp", timestamp); request.put("appkey", properties.getAppKey()); JsonNode response = restTemplate.postForObject(endpoint("/auth"), request, JsonNode.class); if (response == null || response.path("code").asInt(-1) != 0) { throw new IllegalStateException("UniPush 鉴权失败:" + response); } JsonNode data = response.path("data"); token = data.path("token").asText(); tokenExpireAt = data.path("expire_time").asLong(now + 23 * 60 * 60 * 1000L); if (!StringUtils.hasText(token)) throw new IllegalStateException("UniPush 鉴权响应缺少 token"); return token; } } private boolean isConfigured() { return properties.isEnabled() && StringUtils.hasText(properties.getAppId()) && StringUtils.hasText(properties.getAppKey()) && StringUtils.hasText(properties.getMasterSecret()); } private String endpoint(String path) { String baseUrl = properties.getBaseUrl(); while (baseUrl.endsWith("/")) baseUrl = baseUrl.substring(0, baseUrl.length() - 1); return baseUrl + "/" + properties.getAppId() + path; } private String sha256(String source) throws Exception { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(digest.length * 2); for (byte value : digest) result.append(String.format("%02x", value)); return result.toString(); } }