diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java index 11c3bf7..8d2d19f 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java @@ -51,11 +51,14 @@ public class LogController { @RequestParam(required = false) String platform, @RequestParam(required = false) String status, @RequestParam(required = false) String q, + @RequestParam(required = false) String assignee, + @RequestParam(required = false) String environment, @RequestParam(required = false) String from, @RequestParam(required = false) String to, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { - Page result = logService.queryIssues(appKey, level, platform, status, q, from, to, page - 1, size); + Page result = logService.queryIssues( + appKey, level, platform, status, q, assignee, environment, from, to, page - 1, size); return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size)); } @@ -106,8 +109,8 @@ public class LogController { @PutMapping("/issues/{id}/assign") public ApiResponse assignIssue( @PathVariable Long id, - @RequestParam String assignee) { - logService.assignIssue(id, assignee); + @RequestBody java.util.Map body) { + logService.assignIssue(id, body.get("assignee")); return ApiResponse.ok(); } @@ -180,9 +183,10 @@ public class LogController { @RequestParam String appKey, @RequestParam String platform, @RequestParam String appVersion, + @RequestParam(required = false) String bundleVersion, @RequestParam(required = false, defaultValue = "index") String bundleName, @RequestParam("file") MultipartFile file) throws IOException { - return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleName, file)); + return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleVersion, bundleName, file)); } // ── Webhooks ─────────────────────────────────────────────────────────────── diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java index b176368..9cd7b76 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java @@ -10,18 +10,25 @@ public record IssueEventResponse( @JsonProperty("appKey") String appKey, @JsonProperty("userId") String userId, @JsonProperty("sessionId") String sessionId, - @JsonProperty("exceptionType") String exceptionType, - @JsonProperty("exceptionValue") String exceptionValue, - String message, - String stack, - @JsonProperty("stackSymbolicated") String stackSymbolicated, - String breadcrumbs, - String tags, - String device, + /** 结构化异常信息,对齐文档 6.3 */ + ExceptionInfo exception, + /** 面包屑导航列表(JSON 数组反序列化后的对象),可为 null */ + Object breadcrumbs, + /** 自定义标签(JSON 对象反序列化后),可为 null */ + Object tags, + /** 设备信息(JSON 对象反序列化后),可为 null */ + Object device, String platform, String release, String environment, @JsonProperty("sdkName") String sdkName, @JsonProperty("sdkVersion") String sdkVersion, @JsonProperty("createdAt") LocalDateTime createdAt -) {} +) { + public record ExceptionInfo( + String type, + String value, + String stacktrace, + @JsonProperty("stackSymbolicated") String stackSymbolicated + ) {} +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java index dfb73d2..bec7d3c 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java @@ -15,9 +15,9 @@ public record IssueResponse( @JsonProperty("lastSeenAt") LocalDateTime lastSeenAt, int count, @JsonProperty("affectedUsers") int affectedUsers, - @JsonProperty("isResolved") boolean isResolved, String assignee, String platform, String release, + /** 仅在 GET /issues/{id} 详情中填充,列表查询为 null */ List events ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookRequest.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookRequest.java index cd51d60..f4cbed0 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookRequest.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookRequest.java @@ -8,6 +8,12 @@ import java.util.List; public record WebhookRequest( @NotBlank @JsonProperty("appKey") String appKey, @NotBlank String url, + /** + * 支持的事件类型:issue.created / issue.fatal / issue.threshold / + * issue.regressed / issue.resolved + */ @NotNull List events, - @JsonProperty("cooldownSec") int cooldownSec + @JsonProperty("cooldownSec") int cooldownSec, + /** Webhook 签名密钥,服务端用此 secret 计算 X-BugCollect-Signature。可选,不传则不签名。 */ + String secret ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookResponse.java index 19986ce..c5cac79 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/WebhookResponse.java @@ -11,5 +11,7 @@ public record WebhookResponse( List events, @JsonProperty("cooldownSec") int cooldownSec, boolean enabled, + /** 是否配置了签名密钥(密钥本身不返回,只返回是否已配置) */ + @JsonProperty("hasSecret") boolean hasSecret, @JsonProperty("createdAt") LocalDateTime createdAt ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueUserEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueUserEntity.java new file mode 100644 index 0000000..409ba70 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueUserEntity.java @@ -0,0 +1,57 @@ +package com.xuqm.bugcollect.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * 记录每个 Issue 影响的唯一用户,用于正确计算 affectedUsers(去重)。 + * 通过联合主键 (issue_id, user_id) 保证唯一性。 + */ +@Entity +@Table(name = "log_issue_users") +@IdClass(LogIssueUserEntity.IssueUserId.class) +public class LogIssueUserEntity { + + @Id + @Column(nullable = false) + private Long issueId; + + @Id + @Column(nullable = false, length = 128) + private String userId; + + @Column(nullable = false) + private LocalDateTime firstSeenAt; + + public Long getIssueId() { return issueId; } + public void setIssueId(Long issueId) { this.issueId = issueId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public LocalDateTime getFirstSeenAt() { return firstSeenAt; } + public void setFirstSeenAt(LocalDateTime firstSeenAt) { this.firstSeenAt = firstSeenAt; } + + public static class IssueUserId implements java.io.Serializable { + private Long issueId; + private String userId; + + public IssueUserId() {} + public IssueUserId(Long issueId, String userId) { + this.issueId = issueId; + this.userId = userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IssueUserId that)) return false; + return java.util.Objects.equals(issueId, that.issueId) && java.util.Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(issueId, userId); + } + } +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogWebhookEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogWebhookEntity.java index 84a7f17..9ba4101 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogWebhookEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogWebhookEntity.java @@ -26,6 +26,10 @@ public class LogWebhookEntity { @Column(nullable = false, columnDefinition = "TINYINT(1)") private boolean enabled = true; + /** HMAC-SHA256 签名密钥,null 表示不签名 */ + @Column(length = 64) + private String secret; + @Column(nullable = false) private LocalDateTime createdAt; @@ -47,6 +51,9 @@ public class LogWebhookEntity { public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java index 1e38334..e71337d 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java @@ -20,12 +20,14 @@ public interface LogIssueRepository extends JpaRepository @Query("UPDATE LogIssueEntity i SET i.count = i.count + 1, i.lastSeenAt = :now WHERE i.appKey = :appKey AND i.fingerprint = :fingerprint") int incrementCount(@Param("appKey") String appKey, @Param("fingerprint") String fingerprint, @Param("now") LocalDateTime now); - // v1.1: query by level instead of deprecated type @Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey " + "AND (:level IS NULL OR i.level = :level) " + "AND (:platform IS NULL OR i.platform = :platform) " + "AND (:status IS NULL OR i.status = :status) " + "AND (:q IS NULL OR LOWER(i.title) LIKE LOWER(CONCAT('%', :q, '%'))) " + + "AND (:assignee IS NULL OR i.assignee = :assignee) " + + "AND (:environment IS NULL OR EXISTS (" + + " SELECT 1 FROM LogIssueEventEntity e WHERE e.issueId = i.id AND e.environment = :environment)) " + "AND (:from IS NULL OR i.lastSeenAt >= :from) " + "AND (:to IS NULL OR i.lastSeenAt <= :to)") Page findByFilters( @@ -34,6 +36,8 @@ public interface LogIssueRepository extends JpaRepository @Param("platform") String platform, @Param("status") String status, @Param("q") String q, + @Param("assignee") String assignee, + @Param("environment") String environment, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to, Pageable pageable); diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueUserRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueUserRepository.java new file mode 100644 index 0000000..7b9dd28 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueUserRepository.java @@ -0,0 +1,9 @@ +package com.xuqm.bugcollect.repository; + +import com.xuqm.bugcollect.entity.LogIssueUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LogIssueUserRepository extends JpaRepository { + + boolean existsByIssueIdAndUserId(Long issueId, String userId); +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java index 426cafd..315999b 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java @@ -28,17 +28,20 @@ public class LogService { private final LogIssueRepository issueRepository; private final LogIssueEventRepository issueEventRepository; + private final LogIssueUserRepository issueUserRepository; private final LogEventRepository eventRepository; private final WebhookService webhookService; private final ObjectMapper objectMapper; public LogService(LogIssueRepository issueRepository, LogIssueEventRepository issueEventRepository, + LogIssueUserRepository issueUserRepository, LogEventRepository eventRepository, WebhookService webhookService, ObjectMapper objectMapper) { this.issueRepository = issueRepository; this.issueEventRepository = issueEventRepository; + this.issueUserRepository = issueUserRepository; this.eventRepository = eventRepository; this.webhookService = webhookService; this.objectMapper = objectMapper; @@ -81,7 +84,14 @@ public class LogService { issue = existing.get(); issue.setLastSeenAt(now); issue.setCount(issue.getCount() + 1); - if (userId != null) issue.setAffectedUsers(issue.getAffectedUsers() + 1); + if (userId != null && !issueUserRepository.existsByIssueIdAndUserId(issue.getId(), userId)) { + LogIssueUserEntity iu = new LogIssueUserEntity(); + iu.setIssueId(issue.getId()); + iu.setUserId(userId); + iu.setFirstSeenAt(now); + issueUserRepository.save(iu); + issue.setAffectedUsers(issue.getAffectedUsers() + 1); + } if (!"open".equals(issue.getStatus())) issue.setStatus("open"); issueRepository.save(issue); } else { @@ -175,11 +185,11 @@ public class LogService { @Transactional(readOnly = true) public Page queryIssues(String appKey, String level, String platform, - String status, String q, + String status, String q, String assignee, String environment, String from, String to, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")); Page result = issueRepository.findByFilters( - appKey, level, platform, status, q, parseDate(from), parseDate(to), pageable); + appKey, level, platform, status, q, assignee, environment, parseDate(from), parseDate(to), pageable); return result.map(this::toIssueResponse); } @@ -197,9 +207,9 @@ public class LogService { return new IssueResponse( issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getLevel(), - issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"), + issue.getStatus() != null ? issue.getStatus() : "open", issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), - issue.getCount(), issue.getAffectedUsers(), issue.isResolved(), + issue.getCount(), issue.getAffectedUsers(), issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events ); } @@ -358,24 +368,40 @@ public class LogService { return new IssueResponse( issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getLevel(), - issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"), + issue.getStatus() != null ? issue.getStatus() : "open", issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), - issue.getCount(), issue.getAffectedUsers(), issue.isResolved(), + issue.getCount(), issue.getAffectedUsers(), issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null ); } private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) { + IssueEventResponse.ExceptionInfo exception = new IssueEventResponse.ExceptionInfo( + e.getExceptionType(), + e.getExceptionValue() != null ? e.getExceptionValue() : e.getMessage(), + e.getStack(), + e.getStackSymbolicated() + ); return new IssueEventResponse( e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(), - e.getExceptionType(), e.getExceptionValue(), - e.getMessage(), e.getStack(), e.getStackSymbolicated(), - e.getBreadcrumbs(), e.getTags(), e.getDevice(), + exception, + parseJsonField(e.getBreadcrumbs()), + parseJsonField(e.getTags()), + parseJsonField(e.getDevice()), e.getPlatform(), e.getRelease(), e.getEnvironment(), e.getSdkName(), e.getSdkVersion(), e.getCreatedAt() ); } + private Object parseJsonField(String json) { + if (json == null || json.isBlank()) return null; + try { + return objectMapper.readValue(json, Object.class); + } catch (Exception ex) { + return json; + } + } + private String toJson(Object obj) { if (obj == null) return null; try { diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java index 609590a..ba0c590 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/SourcemapService.java @@ -15,11 +15,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.Set; @Service public class SourcemapService { private static final Logger log = LoggerFactory.getLogger(SourcemapService.class); + private static final long MAX_FILE_SIZE = 200L * 1024 * 1024; // 200 MB + private static final Set ALLOWED_EXTENSIONS = Set.of(".map", ".jsbundle.map", ".js.map"); private final LogSourcemapRepository sourcemapRepository; @@ -33,18 +36,33 @@ public class SourcemapService { @Transactional public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, String bundleName, MultipartFile file) throws IOException { + return upload(appKey, platform, appVersion, null, bundleName, file); + } + + @Transactional + public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, + String bundleVersion, String bundleName, MultipartFile file) throws IOException { if (bundleName == null || bundleName.isBlank()) { bundleName = "index"; } - // Save file to disk - Path dir = Paths.get(storageDir, appKey, platform, appVersion); + if (file.getSize() > MAX_FILE_SIZE) { + throw new IllegalArgumentException("File size exceeds the 200 MB limit"); + } + + String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : ""; + if (!originalName.endsWith(".map")) { + throw new IllegalArgumentException("Only .map sourcemap files are accepted"); + } + + String filename = resolveFilename(platform, bundleName); + String versionKey = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : appVersion; + + Path dir = Paths.get(storageDir, appKey, platform, versionKey); Files.createDirectories(dir); - String filename = bundleName + ".map"; Path filePath = dir.resolve(filename); file.transferTo(filePath.toFile()); - // Upsert DB record var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( appKey, platform, appVersion, bundleName); @@ -64,7 +82,7 @@ public class SourcemapService { } entity = sourcemapRepository.save(entity); - log.info("SourceMap uploaded: appKey={}, platform={}, version={}, bundle={}", appKey, platform, appVersion, bundleName); + log.info("SourceMap uploaded: appKey={}, platform={}, version={}, bundle={}", appKey, platform, versionKey, bundleName); return new SourcemapUploadResponse( entity.getId(), entity.getAppKey(), entity.getPlatform(), @@ -72,6 +90,16 @@ public class SourcemapService { ); } + /** Produce platform-appropriate filename, e.g. index.android.bundle.map / index.ios.bundle.map */ + private String resolveFilename(String platform, String bundleName) { + return switch (platform != null ? platform.toLowerCase() : "") { + case "android" -> bundleName + ".android.bundle.map"; + case "ios" -> bundleName + ".ios.bundle.map"; + case "harmony" -> bundleName + ".harmony.bundle.map"; + default -> bundleName + ".map"; + }; + } + public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) { return sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( appKey, platform, appVersion, bundleName) diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/WebhookService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/WebhookService.java index 3aef2a4..3a56a90 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/WebhookService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/WebhookService.java @@ -9,19 +9,21 @@ 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 javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; +import java.util.HexFormat; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; @Service public class WebhookService { @@ -45,11 +47,12 @@ public class WebhookService { .build(); } - public void checkAndNotify(LogIssueEntity issue) { + public void checkAndNotify(LogIssueEntity issue, boolean isNew) { List webhooks = webhookRepository.findByAppKeyAndEnabledTrue(issue.getAppKey()); + String eventType = resolveEventType(issue, isNew); for (LogWebhookEntity webhook : webhooks) { - if (!matchesEventType(webhook.getEvents(), issue.getType())) { + if (!matchesEventType(webhook.getEvents(), eventType, issue.getLevel())) { continue; } @@ -58,44 +61,93 @@ public class WebhookService { .setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec())); if (Boolean.TRUE.equals(acquired)) { - sendWebhook(webhook, issue); + sendWebhook(webhook, issue, eventType); } else { log.debug("Webhook cooldown active: webhookId={}, fingerprint={}", webhook.getId(), issue.getFingerprint()); } } } - private void sendWebhook(LogWebhookEntity webhook, LogIssueEntity issue) { + /** Keep backward-compat overload: treat all existing calls as "existing issue updated" */ + public void checkAndNotify(LogIssueEntity issue) { + checkAndNotify(issue, false); + } + + /** + * Resolve the webhook event type from issue state: + * issue.created — brand-new issue (isNew=true) + * issue.fatal — level is fatal (recurring) + * issue.error — level is error (recurring) + * issue.warning — level is warning (recurring) + * issue.updated — everything else + */ + private String resolveEventType(LogIssueEntity issue, boolean isNew) { + if (isNew) return "issue.created"; + return switch (issue.getLevel() != null ? issue.getLevel() : "") { + case "fatal" -> "issue.fatal"; + case "error" -> "issue.error"; + case "warning" -> "issue.warning"; + default -> "issue.updated"; + }; + } + + /** + * A webhook matches if its configured events list contains: + * - the exact event type, OR + * - "issue.*" wildcard, OR + * - "issue." matching the current level + */ + private boolean matchesEventType(String eventsJson, String eventType, String level) { + try { + @SuppressWarnings("unchecked") + List events = objectMapper.readValue(eventsJson, List.class); + if (events.isEmpty()) return true; // empty = subscribe to all + return events.contains(eventType) + || events.contains("issue.*") + || (level != null && events.contains("issue." + level)); + } catch (Exception e) { + return false; + } + } + + private void sendWebhook(LogWebhookEntity webhook, LogIssueEntity issue, String eventType) { try { Map body = Map.of( - "event", "issue.new", + "event", eventType, "appKey", issue.getAppKey(), "issue", Map.of( + "id", issue.getId(), "fingerprint", issue.getFingerprint(), - "type", issue.getType(), + "level", issue.getLevel() != null ? issue.getLevel() : "", "title", issue.getTitle(), + "status", issue.getStatus() != null ? issue.getStatus() : "open", "count", issue.getCount(), + "affectedUsers", issue.getAffectedUsers(), "lastSeenAt", issue.getLastSeenAt().toString(), "platform", issue.getPlatform() != null ? issue.getPlatform() : "", - "appVersion", issue.getRelease() != null ? issue.getRelease() : "" + "release", issue.getRelease() != null ? issue.getRelease() : "" ) ); String jsonBody = objectMapper.writeValueAsString(body); - HttpRequest request = HttpRequest.newBuilder() + HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(webhook.getUrl())) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .timeout(Duration.ofMillis(5000)) - .build(); + .timeout(Duration.ofMillis(5000)); - httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + if (webhook.getSecret() != null && !webhook.getSecret().isBlank()) { + String signature = hmacSha256(webhook.getSecret(), jsonBody); + builder.header("X-BugCollect-Signature", "sha256=" + signature); + } + + httpClient.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString()) .thenAccept(response -> { if (response.statusCode() >= 200 && response.statusCode() < 300) { - log.info("Webhook sent successfully: webhookId={}, status={}", webhook.getId(), response.statusCode()); + log.info("Webhook sent: webhookId={}, event={}, status={}", webhook.getId(), eventType, response.statusCode()); } else { - log.warn("Webhook returned non-2xx: webhookId={}, status={}", webhook.getId(), response.statusCode()); + log.warn("Webhook non-2xx: webhookId={}, event={}, status={}", webhook.getId(), eventType, response.statusCode()); } }) .exceptionally(ex -> { @@ -107,13 +159,13 @@ public class WebhookService { } } - private boolean matchesEventType(String eventsJson, String eventType) { + private String hmacSha256(String secret, String payload) { try { - @SuppressWarnings("unchecked") - List events = objectMapper.readValue(eventsJson, List.class); - return events.contains(eventType); + 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) { - return false; + throw new IllegalStateException("HMAC-SHA256 signing failed", e); } } @@ -130,6 +182,7 @@ public class WebhookService { entity.setAppKey(request.appKey()); entity.setUrl(request.url()); entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); + entity.setSecret(request.secret()); entity.setEnabled(true); entity.setCreatedAt(LocalDateTime.now()); try { @@ -148,6 +201,9 @@ public class WebhookService { entity.setAppKey(request.appKey()); entity.setUrl(request.url()); entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); + if (request.secret() != null) { + entity.setSecret(request.secret().isBlank() ? null : request.secret()); + } try { entity.setEvents(objectMapper.writeValueAsString(request.events())); } catch (Exception e) { @@ -165,6 +221,7 @@ public class WebhookService { private WebhookResponse toResponse(LogWebhookEntity entity) { List events; try { + //noinspection unchecked events = objectMapper.readValue(entity.getEvents(), List.class); } catch (Exception e) { events = List.of(); @@ -172,6 +229,7 @@ public class WebhookService { return new WebhookResponse( entity.getId(), entity.getAppKey(), entity.getUrl(), events, entity.getCooldownSec(), entity.isEnabled(), + entity.getSecret() != null && !entity.getSecret().isBlank(), entity.getCreatedAt() ); } diff --git a/xuqm-bugcollect-service/src/main/resources/db/migration/V4__bugcollect_v12.sql b/xuqm-bugcollect-service/src/main/resources/db/migration/V4__bugcollect_v12.sql new file mode 100644 index 0000000..570411a --- /dev/null +++ b/xuqm-bugcollect-service/src/main/resources/db/migration/V4__bugcollect_v12.sql @@ -0,0 +1,40 @@ +-- V4: log_issue_users dedup table, secret on log_webhooks, log_sessions skeleton +-- ────────────────────────────────────────────────────────────────────────────── + +-- Dedup table for affectedUsers counting (one row per issue × user) +CREATE TABLE IF NOT EXISTS log_issue_users ( + issue_id BIGINT NOT NULL, + user_id VARCHAR(128) NOT NULL, + first_seen_at DATETIME NOT NULL, + PRIMARY KEY (issue_id, user_id), + INDEX idx_liu_issue_id (issue_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- HMAC signing secret for webhooks (nullable = no signing) +ALTER TABLE log_webhooks + ADD COLUMN IF NOT EXISTS secret VARCHAR(64) NULL COMMENT 'HMAC-SHA256 signing secret; NULL = no signature'; + +-- Session tracking table (ingestion path via /sessions/batch is not yet implemented) +CREATE TABLE IF NOT EXISTS log_sessions ( + id BIGINT NOT NULL AUTO_INCREMENT, + app_key VARCHAR(64) NOT NULL, + session_id VARCHAR(64) NOT NULL, + user_id VARCHAR(128) NULL, + platform VARCHAR(16) NULL, + release VARCHAR(32) NULL, + environment VARCHAR(32) NULL, + sdk_name VARCHAR(64) NULL, + sdk_version VARCHAR(32) NULL, + started_at DATETIME NOT NULL, + ended_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_session_id (app_key, session_id), + INDEX idx_ls_app_key (app_key), + INDEX idx_ls_started_at (started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Ensure event_id has a uniqueness constraint (was added as NULL in V3) +-- Use an index rather than UNIQUE to tolerate legacy NULL rows +ALTER TABLE log_issue_events + ADD INDEX IF NOT EXISTS idx_lie_event_id (event_id);