feat: implement GitHub webhook controller and processing service

WebhookService parses YAML-fenced issue body, filters by "weapon-mod"
label, deduplicates via Redis SETNX with 12h TTL, and delegates to
ModificationManager for single/batch creation.

GitHubWebhookController verifies X-GitHub-Event=issues and action=opened
before delegating to the service.

Register GitHubWebhookInterceptor for /webhooks/github in AppConfig.
This commit is contained in:
2026-06-01 15:30:30 +08:00
parent 7fafa0d903
commit c30b5701e4
3 changed files with 231 additions and 10 deletions
@@ -1,7 +1,7 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor;
import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor;
import com.onixbyte.deltaforceguide.properties.AppProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -15,21 +15,21 @@ public class AppConfig implements WebMvcConfigurer {
private final TrafficInterceptor trafficInterceptor;
private final GitLabWebhookInterceptor gitLabWebhookInterceptor;
private final GitHubWebhookInterceptor gitHubWebhookInterceptor;
@Autowired
public AppConfig(
TrafficInterceptor trafficInterceptor,
GitLabWebhookInterceptor gitLabWebhookInterceptor
GitHubWebhookInterceptor gitHubWebhookInterceptor
) {
this.trafficInterceptor = trafficInterceptor;
this.gitLabWebhookInterceptor = gitLabWebhookInterceptor;
this.gitHubWebhookInterceptor = gitHubWebhookInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(trafficInterceptor);
registry.addInterceptor(gitLabWebhookInterceptor)
.addPathPatterns("/webhook/gitlab");
registry.addInterceptor(gitHubWebhookInterceptor)
.addPathPatterns("/webhooks/github");
}
}
@@ -0,0 +1,43 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.deltaforceguide.service.WebhookService;
import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/webhooks/github")
public class GitHubWebhookController {
private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class);
private final WebhookService webhookService;
public GitHubWebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@PostMapping
public ResponseEntity<Void> handleWebhook(
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
@RequestBody GitHubIssueRequest request
) {
if (!"issues".equals(event)) {
log.debug("Ignoring non-issue event: {}", event);
return ResponseEntity.ok().build();
}
if (!"opened".equals(request.action())) {
log.debug("Ignoring issue action: {}", request.action());
return ResponseEntity.ok().build();
}
webhookService.processIssueEvent(request);
return ResponseEntity.ok().build();
}
}
@@ -1,14 +1,192 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.manager.WebhookManager;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
import com.onixbyte.deltaforceguide.manager.ModificationManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.Yaml;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Service
public class WebhookService {
private final WebhookManager webhookManager;
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
private static final String TRIGGER_LABEL = "weapon-mod";
private static final Duration DEDUP_TTL = Duration.ofHours(12);
private static final Pattern YAML_FENCE =
Pattern.compile("```ya?ml\\s*\\n(.*?)```", Pattern.DOTALL);
public WebhookService(WebhookManager webhookManager) {
this.webhookManager = webhookManager;
private final ModificationManager modificationManager;
private final RedisTemplate<String, Object> redisTemplate;
private final Yaml yaml;
public WebhookService(
ModificationManager modificationManager,
RedisTemplate<String, Object> redisTemplate
) {
this.modificationManager = modificationManager;
this.redisTemplate = redisTemplate;
this.yaml = new Yaml();
}
public void processIssueEvent(GitHubIssueRequest request) {
var issue = request.issue();
var repository = request.repository();
if (!hasTriggerLabel(issue.labels())) {
log.debug("Issue #{} lacks trigger label, skipping", issue.number());
return;
}
var dedupKey = "github:webhook:processed:%s:%d"
.formatted(repository.fullName(), issue.number());
var acquired = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", DEDUP_TTL);
if (acquired == null || !acquired) {
log.info("Issue #{} already processed, skipping", issue.number());
return;
}
var parsedYaml = extractYaml(issue.body());
if (parsedYaml == null) {
log.warn("No YAML block found in issue #{} body", issue.number());
return;
}
@SuppressWarnings("unchecked")
var data = (Map<String, Object>) yaml.load(parsedYaml);
if (data == null) {
log.warn("Empty YAML block in issue #{}", issue.number());
return;
}
if (data.containsKey("modifications")) {
processBatch(issue.number(), data);
} else {
processSingle(issue.number(), data);
}
}
private void processSingle(Long issueNumber, Map<String, Object> data) {
var request = mapToRequest(data);
log.info("Creating modification from issue #{}: name={}", issueNumber, request.name());
modificationManager.create(request);
}
@SuppressWarnings("unchecked")
private void processBatch(Long issueNumber, Map<String, Object> data) {
var list = (List<Map<String, Object>>) data.get("modifications");
if (list == null || list.isEmpty()) {
log.warn("Empty modifications list in issue #{}", issueNumber);
return;
}
var requests = list.stream()
.map(this::mapToRequest)
.toList();
log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber);
modificationManager.batchCreate(requests);
}
private ModificationRequest mapToRequest(Map<String, Object> data) {
Long firearmId = toLong(data.get("firearmId"));
String name = (String) data.get("name");
String code = (String) data.get("code");
List<String> tags = toStringList(data.get("tags"));
List<AccessoryRequest> accessories = mapAccessories(data.get("accessories"));
String note = (String) data.get("note");
String author = (String) data.get("author");
String videoUrl = (String) data.get("videoUrl");
return new ModificationRequest(firearmId, name, code, tags, accessories,
note, author, videoUrl);
}
private List<AccessoryRequest> mapAccessories(Object raw) {
if (!(raw instanceof List<?> list)) {
return new ArrayList<>();
}
var result = new ArrayList<AccessoryRequest>();
for (var item : list) {
if (item instanceof Map<?, ?> map) {
result.add(new AccessoryRequest(
(String) map.get("slotName"),
(String) map.get("accessoryName"),
mapTunings(map.get("tunings"))
));
}
}
return result;
}
private List<TuningRequest> mapTunings(Object raw) {
if (!(raw instanceof List<?> list)) {
return new ArrayList<>();
}
var result = new ArrayList<TuningRequest>();
for (var item : list) {
if (item instanceof Map<?, ?> map) {
result.add(new TuningRequest(
(String) map.get("tuningName"),
toDouble(map.get("tuningValue"))
));
}
}
return result;
}
private List<String> toStringList(Object raw) {
if (raw instanceof List<?> list) {
return list.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.toList();
}
return new ArrayList<>();
}
private boolean hasTriggerLabel(List<com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookLabel> labels) {
if (labels == null) {
return false;
}
return labels.stream().anyMatch(label -> TRIGGER_LABEL.equals(label.name()));
}
private String extractYaml(String body) {
if (body == null) {
return null;
}
var matcher = YAML_FENCE.matcher(body);
return matcher.find() ? matcher.group(1) : null;
}
private Long toLong(Object value) {
if (value instanceof Number num) {
return num.longValue();
}
if (value instanceof String s) {
return Long.parseLong(s);
}
return null;
}
private Double toDouble(Object value) {
if (value instanceof Number num) {
return num.doubleValue();
}
if (value instanceof String s) {
return Double.parseDouble(s);
}
return null;
}
}