package com.xuqm.bugcollect.service; import com.fasterxml.jackson.databind.ObjectMapper; 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; 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 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(); Boolean acquired = redisTemplate.opsForValue() .setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec())); if (Boolean.TRUE.equals(acquired)) { sendWebhook(webhook, issue); } else { log.debug("Webhook cooldown active: webhookId={}, fingerprint={}", webhook.getId(), issue.getFingerprint()); } } } private void sendWebhook(LogWebhookEntity webhook, LogIssueEntity issue) { try { Map 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 events = objectMapper.readValue(eventsJson, List.class); return events.contains(eventType); } catch (Exception e) { return false; } } @Transactional(readOnly = true) public List 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 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() ); } }