2026-06-16 17:39:13 +08:00
|
|
|
package com.xuqm.bugcollect.service;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
2026-06-16 17:39:13 +08:00
|
|
|
import com.xuqm.bugcollect.dto.WebhookRequest;
|
|
|
|
|
import com.xuqm.bugcollect.dto.WebhookResponse;
|
|
|
|
|
import com.xuqm.bugcollect.entity.LogIssueEntity;
|
|
|
|
|
import com.xuqm.bugcollect.entity.LogWebhookEntity;
|
|
|
|
|
import com.xuqm.bugcollect.repository.LogWebhookRepository;
|
2026-06-16 12:14:53 +08:00
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
|
|
import org.springframework.scheduling.annotation.Async;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
|
|
|
|
import java.net.URI;
|
|
|
|
|
import java.net.http.HttpClient;
|
|
|
|
|
import java.net.http.HttpRequest;
|
|
|
|
|
import java.net.http.HttpResponse;
|
|
|
|
|
import java.time.Duration;
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.concurrent.CompletableFuture;
|
|
|
|
|
|
|
|
|
|
@Service
|
|
|
|
|
public class WebhookService {
|
|
|
|
|
|
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
|
|
|
|
|
private static final String COOLDOWN_KEY_PREFIX = "log:webhook:cooldown:";
|
|
|
|
|
|
|
|
|
|
private final LogWebhookRepository webhookRepository;
|
|
|
|
|
private final StringRedisTemplate redisTemplate;
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
private final HttpClient httpClient;
|
|
|
|
|
|
|
|
|
|
public WebhookService(LogWebhookRepository webhookRepository,
|
|
|
|
|
StringRedisTemplate redisTemplate,
|
|
|
|
|
ObjectMapper objectMapper) {
|
|
|
|
|
this.webhookRepository = webhookRepository;
|
|
|
|
|
this.redisTemplate = redisTemplate;
|
|
|
|
|
this.objectMapper = objectMapper;
|
|
|
|
|
this.httpClient = HttpClient.newBuilder()
|
|
|
|
|
.connectTimeout(Duration.ofMillis(3000))
|
|
|
|
|
.build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void checkAndNotify(LogIssueEntity issue) {
|
|
|
|
|
List<LogWebhookEntity> webhooks = webhookRepository.findByAppKeyAndEnabledTrue(issue.getAppKey());
|
|
|
|
|
|
|
|
|
|
for (LogWebhookEntity webhook : webhooks) {
|
|
|
|
|
if (!matchesEventType(webhook.getEvents(), issue.getType())) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String cooldownKey = COOLDOWN_KEY_PREFIX + webhook.getId() + ":" + issue.getFingerprint();
|
2026-06-16 12:35:17 +08:00
|
|
|
Boolean acquired = redisTemplate.opsForValue()
|
2026-06-16 12:14:53 +08:00
|
|
|
.setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec()));
|
|
|
|
|
|
2026-06-16 12:35:17 +08:00
|
|
|
if (Boolean.TRUE.equals(acquired)) {
|
2026-06-16 12:14:53 +08:00
|
|
|
sendWebhook(webhook, issue);
|
|
|
|
|
} else {
|
|
|
|
|
log.debug("Webhook cooldown active: webhookId={}, fingerprint={}", webhook.getId(), issue.getFingerprint());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void sendWebhook(LogWebhookEntity webhook, LogIssueEntity issue) {
|
|
|
|
|
try {
|
|
|
|
|
Map<String, Object> body = Map.of(
|
|
|
|
|
"event", "issue.new",
|
|
|
|
|
"appKey", issue.getAppKey(),
|
|
|
|
|
"issue", Map.of(
|
|
|
|
|
"fingerprint", issue.getFingerprint(),
|
|
|
|
|
"type", issue.getType(),
|
|
|
|
|
"title", issue.getTitle(),
|
|
|
|
|
"count", issue.getCount(),
|
|
|
|
|
"lastSeenAt", issue.getLastSeenAt().toString(),
|
|
|
|
|
"platform", issue.getPlatform() != null ? issue.getPlatform() : "",
|
|
|
|
|
"appVersion", issue.getAppVersion() != null ? issue.getAppVersion() : ""
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
String jsonBody = objectMapper.writeValueAsString(body);
|
|
|
|
|
|
|
|
|
|
HttpRequest request = HttpRequest.newBuilder()
|
|
|
|
|
.uri(URI.create(webhook.getUrl()))
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
|
|
|
|
.timeout(Duration.ofMillis(5000))
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
|
|
|
|
.thenAccept(response -> {
|
|
|
|
|
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
|
|
|
|
log.info("Webhook sent successfully: webhookId={}, status={}", webhook.getId(), response.statusCode());
|
|
|
|
|
} else {
|
|
|
|
|
log.warn("Webhook returned non-2xx: webhookId={}, status={}", webhook.getId(), response.statusCode());
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.exceptionally(ex -> {
|
|
|
|
|
log.error("Webhook send failed: webhookId={}, url={}", webhook.getId(), webhook.getUrl(), ex);
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.error("Failed to serialize webhook body: webhookId={}", webhook.getId(), e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean matchesEventType(String eventsJson, String eventType) {
|
|
|
|
|
try {
|
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
|
List<String> events = objectMapper.readValue(eventsJson, List.class);
|
|
|
|
|
return events.contains(eventType);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
public List<WebhookResponse> listWebhooks(String appKey) {
|
|
|
|
|
return webhookRepository.findByAppKey(appKey).stream()
|
|
|
|
|
.map(this::toResponse)
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public WebhookResponse createWebhook(WebhookRequest request) {
|
|
|
|
|
LogWebhookEntity entity = new LogWebhookEntity();
|
|
|
|
|
entity.setAppKey(request.appKey());
|
|
|
|
|
entity.setUrl(request.url());
|
|
|
|
|
entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600);
|
|
|
|
|
entity.setEnabled(true);
|
|
|
|
|
entity.setCreatedAt(LocalDateTime.now());
|
|
|
|
|
try {
|
|
|
|
|
entity.setEvents(objectMapper.writeValueAsString(request.events()));
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
entity.setEvents("[]");
|
|
|
|
|
}
|
|
|
|
|
entity = webhookRepository.save(entity);
|
|
|
|
|
return toResponse(entity);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public WebhookResponse updateWebhook(Long id, WebhookRequest request) {
|
|
|
|
|
LogWebhookEntity entity = webhookRepository.findById(id)
|
|
|
|
|
.orElseThrow(() -> new IllegalArgumentException("Webhook not found: " + id));
|
|
|
|
|
entity.setAppKey(request.appKey());
|
|
|
|
|
entity.setUrl(request.url());
|
|
|
|
|
entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600);
|
|
|
|
|
try {
|
|
|
|
|
entity.setEvents(objectMapper.writeValueAsString(request.events()));
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
// keep existing
|
|
|
|
|
}
|
|
|
|
|
entity = webhookRepository.save(entity);
|
|
|
|
|
return toResponse(entity);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public void deleteWebhook(Long id) {
|
|
|
|
|
webhookRepository.deleteById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private WebhookResponse toResponse(LogWebhookEntity entity) {
|
|
|
|
|
List<String> events;
|
|
|
|
|
try {
|
|
|
|
|
events = objectMapper.readValue(entity.getEvents(), List.class);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
events = List.of();
|
|
|
|
|
}
|
|
|
|
|
return new WebhookResponse(
|
|
|
|
|
entity.getId(), entity.getAppKey(), entity.getUrl(),
|
|
|
|
|
events, entity.getCooldownSec(), entity.isEnabled(),
|
|
|
|
|
entity.getCreatedAt()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|