2026-04-29 12:33:25 +08:00
|
|
|
package com.xuqm.im.service;
|
|
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
2026-05-02 22:57:55 +08:00
|
|
|
import com.xuqm.im.entity.WebhookAlertEntity;
|
2026-04-29 12:33:25 +08:00
|
|
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
2026-05-01 21:27:39 +08:00
|
|
|
import com.xuqm.im.entity.WebhookDeliveryEntity;
|
2026-04-29 12:33:25 +08:00
|
|
|
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
2026-05-02 22:57:55 +08:00
|
|
|
import com.xuqm.im.repository.WebhookAlertRepository;
|
2026-04-29 12:33:25 +08:00
|
|
|
import com.xuqm.im.repository.WebhookConfigRepository;
|
2026-05-01 21:27:39 +08:00
|
|
|
import com.xuqm.im.repository.WebhookDeliveryRepository;
|
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
2026-04-29 12:33:25 +08:00
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
2026-05-01 21:27:39 +08:00
|
|
|
import org.springframework.scheduling.annotation.Async;
|
2026-04-29 12:33:25 +08:00
|
|
|
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;
|
2026-05-01 21:27:39 +08:00
|
|
|
import java.time.LocalDateTime;
|
2026-04-29 12:33:25 +08:00
|
|
|
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 {
|
|
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class);
|
|
|
|
|
private static final int MAX_RETRIES = 3;
|
|
|
|
|
private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L};
|
2026-05-02 22:57:55 +08:00
|
|
|
private static final int ALERT_THRESHOLD = 10;
|
2026-05-01 21:27:39 +08:00
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
private final WebhookConfigRepository webhookRepository;
|
2026-05-01 21:27:39 +08:00
|
|
|
private final WebhookDeliveryRepository deliveryRepository;
|
2026-05-02 22:57:55 +08:00
|
|
|
private final WebhookAlertRepository alertRepository;
|
2026-04-29 12:33:25 +08:00
|
|
|
private final ImAppSecretClient appSecretClient;
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
|
|
|
@Value("${im.webhook-timeout-ms:3000}")
|
|
|
|
|
private int webhookTimeoutMs;
|
|
|
|
|
|
|
|
|
|
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
|
2026-05-01 21:27:39 +08:00
|
|
|
WebhookDeliveryRepository deliveryRepository,
|
2026-05-02 22:57:55 +08:00
|
|
|
WebhookAlertRepository alertRepository,
|
2026-04-29 12:33:25 +08:00
|
|
|
ImAppSecretClient appSecretClient,
|
|
|
|
|
ObjectMapper objectMapper) {
|
|
|
|
|
this.webhookRepository = webhookRepository;
|
2026-05-01 21:27:39 +08:00
|
|
|
this.deliveryRepository = deliveryRepository;
|
2026-05-02 22:57:55 +08:00
|
|
|
this.alertRepository = alertRepository;
|
2026-04-29 12:33:25 +08:00
|
|
|
this.appSecretClient = appSecretClient;
|
|
|
|
|
this.objectMapper = objectMapper;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
@Async
|
2026-05-07 19:39:42 +08:00
|
|
|
public void dispatch(String appKey, String callbackType, String callbackEvent, Object payload) {
|
|
|
|
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appKey);
|
2026-04-29 12:33:25 +08:00
|
|
|
if (webhooks.isEmpty()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-05-07 19:39:42 +08:00
|
|
|
String appSecret = appSecretClient.getAppSecret(appKey);
|
2026-04-29 12:33:25 +08:00
|
|
|
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,
|
2026-05-07 19:39:42 +08:00
|
|
|
appKey
|
2026-04-29 12:33:25 +08:00
|
|
|
);
|
|
|
|
|
String body = objectMapper.writeValueAsString(envelope);
|
2026-05-07 19:39:42 +08:00
|
|
|
String signature = signWebhook(appKey, appSecret, requestTime, nonce, body);
|
2026-05-01 21:27:39 +08:00
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
for (WebhookConfigEntity webhook : webhooks) {
|
2026-05-07 19:39:42 +08:00
|
|
|
deliverWithRetry(appKey, callbackId, callbackEvent, webhook, body, signature, requestTime, nonce);
|
2026-05-01 21:27:39 +08:00
|
|
|
}
|
|
|
|
|
} catch (Exception e) {
|
2026-05-07 19:39:42 +08:00
|
|
|
log.warn("Webhook dispatch prepare failed appKey={} event={}: {}", appKey, callbackEvent, e.getMessage());
|
2026-05-01 21:27:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:39:42 +08:00
|
|
|
private void deliverWithRetry(String appKey, String callbackId, String callbackEvent,
|
2026-05-01 21:27:39 +08:00
|
|
|
WebhookConfigEntity webhook, String body, String signature,
|
|
|
|
|
long requestTime, String nonce) {
|
|
|
|
|
HttpClient client = HttpClient.newBuilder()
|
|
|
|
|
.connectTimeout(Duration.ofMillis(webhookTimeoutMs))
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
WebhookDeliveryEntity delivery = new WebhookDeliveryEntity();
|
|
|
|
|
delivery.setId(UUID.randomUUID().toString());
|
2026-05-07 19:39:42 +08:00
|
|
|
delivery.setAppId(appKey);
|
2026-05-01 21:27:39 +08:00
|
|
|
delivery.setCallbackId(callbackId);
|
|
|
|
|
delivery.setCallbackEvent(callbackEvent);
|
|
|
|
|
delivery.setUrl(webhook.getUrl());
|
|
|
|
|
delivery.setAttempt(attempt);
|
|
|
|
|
delivery.setCreatedAt(LocalDateTime.now());
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
HttpRequest request = HttpRequest.newBuilder()
|
|
|
|
|
.uri(URI.create(webhook.getUrl()))
|
|
|
|
|
.timeout(Duration.ofMillis(webhookTimeoutMs))
|
|
|
|
|
.header("Content-Type", "application/json")
|
2026-05-07 19:39:42 +08:00
|
|
|
.header("X-App-Id", appKey)
|
2026-05-01 21:27:39 +08:00
|
|
|
.header("X-App-Timestamp", String.valueOf(requestTime))
|
|
|
|
|
.header("X-App-Nonce", nonce)
|
|
|
|
|
.header("X-App-Signature", signature)
|
|
|
|
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
|
|
|
delivery.setHttpStatus(response.statusCode());
|
|
|
|
|
delivery.setResponseBody(truncate(response.body(), 4000));
|
|
|
|
|
|
|
|
|
|
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
|
|
|
|
delivery.setSuccess(true);
|
|
|
|
|
deliveryRepository.save(delivery);
|
2026-05-02 22:57:55 +08:00
|
|
|
if (webhook.getConsecutiveFailures() > 0) {
|
|
|
|
|
webhook.setConsecutiveFailures(0);
|
|
|
|
|
webhook.setLastFailureAt(null);
|
|
|
|
|
webhookRepository.save(webhook);
|
|
|
|
|
}
|
2026-05-07 19:39:42 +08:00
|
|
|
log.info("Webhook delivered appKey={} event={} url={} attempt={} status={}",
|
|
|
|
|
appKey, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
|
2026-05-01 21:27:39 +08:00
|
|
|
return;
|
|
|
|
|
} else {
|
|
|
|
|
delivery.setSuccess(false);
|
|
|
|
|
delivery.setErrorMessage("HTTP " + response.statusCode());
|
|
|
|
|
deliveryRepository.save(delivery);
|
2026-05-07 19:39:42 +08:00
|
|
|
log.warn("Webhook returned non-2xx appKey={} event={} url={} attempt={} status={}",
|
|
|
|
|
appKey, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
|
2026-05-01 21:27:39 +08:00
|
|
|
}
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
delivery.setSuccess(false);
|
|
|
|
|
delivery.setErrorMessage(truncate(e.getMessage(), 4000));
|
|
|
|
|
deliveryRepository.save(delivery);
|
2026-05-07 19:39:42 +08:00
|
|
|
log.warn("Webhook delivery failed appKey={} event={} url={} attempt={}: {}",
|
|
|
|
|
appKey, callbackEvent, webhook.getUrl(), attempt, e.getMessage());
|
2026-05-01 21:27:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
|
|
|
long delay = RETRY_DELAYS_MS[attempt - 1];
|
2026-05-07 19:39:42 +08:00
|
|
|
log.info("Webhook retry scheduled appKey={} event={} url={} delayMs={}",
|
|
|
|
|
appKey, callbackEvent, webhook.getUrl(), delay);
|
2026-04-29 12:33:25 +08:00
|
|
|
try {
|
2026-05-01 21:27:39 +08:00
|
|
|
Thread.sleep(delay);
|
|
|
|
|
} catch (InterruptedException ie) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
break;
|
2026-04-29 12:33:25 +08:00
|
|
|
}
|
2026-05-01 21:27:39 +08:00
|
|
|
} else {
|
2026-05-07 19:39:42 +08:00
|
|
|
handleMaxRetriesExceeded(appKey, callbackEvent, webhook);
|
2026-04-29 12:33:25 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:39:42 +08:00
|
|
|
private void handleMaxRetriesExceeded(String appKey, String callbackEvent, WebhookConfigEntity webhook) {
|
2026-05-02 22:57:55 +08:00
|
|
|
int failures = webhook.getConsecutiveFailures() + 1;
|
|
|
|
|
webhook.setConsecutiveFailures(failures);
|
|
|
|
|
webhook.setLastFailureAt(LocalDateTime.now());
|
|
|
|
|
webhookRepository.save(webhook);
|
|
|
|
|
|
2026-05-07 19:39:42 +08:00
|
|
|
log.error("Webhook max retries exceeded appKey={} event={} url={} consecutiveFailures={}",
|
|
|
|
|
appKey, callbackEvent, webhook.getUrl(), failures);
|
2026-05-02 22:57:55 +08:00
|
|
|
|
|
|
|
|
if (failures >= ALERT_THRESHOLD && webhook.isEnabled()) {
|
|
|
|
|
webhook.setEnabled(false);
|
|
|
|
|
webhookRepository.save(webhook);
|
2026-05-07 19:39:42 +08:00
|
|
|
log.warn("Webhook auto-disabled after {} consecutive failures appKey={} url={}",
|
|
|
|
|
ALERT_THRESHOLD, appKey, webhook.getUrl());
|
2026-05-02 22:57:55 +08:00
|
|
|
|
|
|
|
|
WebhookAlertEntity alert = new WebhookAlertEntity();
|
|
|
|
|
alert.setId(UUID.randomUUID().toString());
|
2026-05-07 19:39:42 +08:00
|
|
|
alert.setAppId(appKey);
|
2026-05-02 22:57:55 +08:00
|
|
|
alert.setWebhookId(webhook.getId());
|
|
|
|
|
alert.setWebhookUrl(webhook.getUrl());
|
|
|
|
|
alert.setAlertType("AUTO_DISABLED");
|
|
|
|
|
alert.setDescription("Webhook 在连续 " + ALERT_THRESHOLD + " 次投递失败后已自动禁用。事件:" + callbackEvent);
|
|
|
|
|
alert.setAcknowledged(false);
|
|
|
|
|
alert.setCreatedAt(LocalDateTime.now());
|
|
|
|
|
alertRepository.save(alert);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:39:42 +08:00
|
|
|
private String signWebhook(String appKey, String appSecret, long requestTime, String nonce, String body) {
|
|
|
|
|
String payload = appKey + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
2026-04-29 12:33:25 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-01 21:27:39 +08:00
|
|
|
|
|
|
|
|
private String truncate(String value, int maxLength) {
|
|
|
|
|
if (value == null) return null;
|
|
|
|
|
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
|
|
|
|
}
|
2026-04-29 12:33:25 +08:00
|
|
|
}
|