package com.xuqm.im.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.model.WebhookCallbackEnvelope; import com.xuqm.im.repository.WebhookConfigRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpClient; import java.time.Duration; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.List; import java.util.UUID; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @Component public class WebhookDispatchService { private final WebhookConfigRepository webhookRepository; private final ImAppSecretClient appSecretClient; private final ObjectMapper objectMapper; @Value("${im.webhook-timeout-ms:3000}") private int webhookTimeoutMs; public WebhookDispatchService(WebhookConfigRepository webhookRepository, ImAppSecretClient appSecretClient, ObjectMapper objectMapper) { this.webhookRepository = webhookRepository; this.appSecretClient = appSecretClient; this.objectMapper = objectMapper; } public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) { List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); if (webhooks.isEmpty()) { return; } try { String appSecret = appSecretClient.getAppSecret(appId); long requestTime = System.currentTimeMillis(); String nonce = UUID.randomUUID().toString().replace("-", ""); String callbackId = UUID.randomUUID().toString(); WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope( callbackId, callbackType, callbackEvent, requestTime, objectMapper.valueToTree(payload), null, appId ); String body = objectMapper.writeValueAsString(envelope); String signature = signWebhook(appId, appSecret, requestTime, nonce, body); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(webhookTimeoutMs)) .build(); for (WebhookConfigEntity webhook : webhooks) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(webhook.getUrl())) .timeout(Duration.ofMillis(webhookTimeoutMs)) .header("Content-Type", "application/json") .header("X-App-Id", appId) .header("X-App-Timestamp", String.valueOf(requestTime)) .header("X-App-Nonce", nonce) .header("X-App-Signature", signature) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); client.send(request, HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { // 回调失败不影响主流程 } } } catch (Exception e) { // 准备失败不影响主流程 } } private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) { String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body); return hmacSha256Hex(appSecret, payload); } private String sha256Hex(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Failed to hash webhook body", e); } } private String hmacSha256Hex(String secret, String payload) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException("Failed to sign webhook body", e); } } }