fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题

服务端:
- IssueResponse 移除 isResolved,统一使用 status 字段
- IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object
- IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数
- WebhookResponse 将 secret 替换为 hasSecret boolean
- WebhookRequest 新增 secret 字段
- LogWebhookEntity 新增 secret 列
- 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重
- LogIssueRepository.findByFilters 新增 assignee、environment 过滤
- LogService.queryIssues 新增 assignee、environment 参数
- LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved)
- LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object
- LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数
- WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret
- SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验
- V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-18 09:26:12 +08:00
父节点 696a50aae8
当前提交 8770f505f7
共有 13 个文件被更改,包括 299 次插入51 次删除

查看文件

@ -51,11 +51,14 @@ public class LogController {
@RequestParam(required = false) String platform, @RequestParam(required = false) String platform,
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@RequestParam(required = false) String assignee,
@RequestParam(required = false) String environment,
@RequestParam(required = false) String from, @RequestParam(required = false) String from,
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Page<IssueResponse> result = logService.queryIssues(appKey, level, platform, status, q, from, to, page - 1, size); Page<IssueResponse> 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)); return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size));
} }
@ -106,8 +109,8 @@ public class LogController {
@PutMapping("/issues/{id}/assign") @PutMapping("/issues/{id}/assign")
public ApiResponse<Void> assignIssue( public ApiResponse<Void> assignIssue(
@PathVariable Long id, @PathVariable Long id,
@RequestParam String assignee) { @RequestBody java.util.Map<String, String> body) {
logService.assignIssue(id, assignee); logService.assignIssue(id, body.get("assignee"));
return ApiResponse.ok(); return ApiResponse.ok();
} }
@ -180,9 +183,10 @@ public class LogController {
@RequestParam String appKey, @RequestParam String appKey,
@RequestParam String platform, @RequestParam String platform,
@RequestParam String appVersion, @RequestParam String appVersion,
@RequestParam(required = false) String bundleVersion,
@RequestParam(required = false, defaultValue = "index") String bundleName, @RequestParam(required = false, defaultValue = "index") String bundleName,
@RequestParam("file") MultipartFile file) throws IOException { @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 // Webhooks

查看文件

@ -10,18 +10,25 @@ public record IssueEventResponse(
@JsonProperty("appKey") String appKey, @JsonProperty("appKey") String appKey,
@JsonProperty("userId") String userId, @JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId, @JsonProperty("sessionId") String sessionId,
@JsonProperty("exceptionType") String exceptionType, /** 结构化异常信息,对齐文档 6.3 */
@JsonProperty("exceptionValue") String exceptionValue, ExceptionInfo exception,
String message, /** 面包屑导航列表JSON 数组反序列化后的对象),可为 null */
String stack, Object breadcrumbs,
@JsonProperty("stackSymbolicated") String stackSymbolicated, /** 自定义标签JSON 对象反序列化后),可为 null */
String breadcrumbs, Object tags,
String tags, /** 设备信息JSON 对象反序列化后),可为 null */
String device, Object device,
String platform, String platform,
String release, String release,
String environment, String environment,
@JsonProperty("sdkName") String sdkName, @JsonProperty("sdkName") String sdkName,
@JsonProperty("sdkVersion") String sdkVersion, @JsonProperty("sdkVersion") String sdkVersion,
@JsonProperty("createdAt") LocalDateTime createdAt @JsonProperty("createdAt") LocalDateTime createdAt
) {} ) {
public record ExceptionInfo(
String type,
String value,
String stacktrace,
@JsonProperty("stackSymbolicated") String stackSymbolicated
) {}
}

查看文件

@ -15,9 +15,9 @@ public record IssueResponse(
@JsonProperty("lastSeenAt") LocalDateTime lastSeenAt, @JsonProperty("lastSeenAt") LocalDateTime lastSeenAt,
int count, int count,
@JsonProperty("affectedUsers") int affectedUsers, @JsonProperty("affectedUsers") int affectedUsers,
@JsonProperty("isResolved") boolean isResolved,
String assignee, String assignee,
String platform, String platform,
String release, String release,
/** 仅在 GET /issues/{id} 详情中填充,列表查询为 null */
List<IssueEventResponse> events List<IssueEventResponse> events
) {} ) {}

查看文件

@ -8,6 +8,12 @@ import java.util.List;
public record WebhookRequest( public record WebhookRequest(
@NotBlank @JsonProperty("appKey") String appKey, @NotBlank @JsonProperty("appKey") String appKey,
@NotBlank String url, @NotBlank String url,
/**
* 支持的事件类型issue.created / issue.fatal / issue.threshold /
* issue.regressed / issue.resolved
*/
@NotNull List<String> events, @NotNull List<String> events,
@JsonProperty("cooldownSec") int cooldownSec @JsonProperty("cooldownSec") int cooldownSec,
/** Webhook 签名密钥,服务端用此 secret 计算 X-BugCollect-Signature。可选,不传则不签名。 */
String secret
) {} ) {}

查看文件

@ -11,5 +11,7 @@ public record WebhookResponse(
List<String> events, List<String> events,
@JsonProperty("cooldownSec") int cooldownSec, @JsonProperty("cooldownSec") int cooldownSec,
boolean enabled, boolean enabled,
/** 是否配置了签名密钥(密钥本身不返回,只返回是否已配置) */
@JsonProperty("hasSecret") boolean hasSecret,
@JsonProperty("createdAt") LocalDateTime createdAt @JsonProperty("createdAt") LocalDateTime createdAt
) {} ) {}

查看文件

@ -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);
}
}
}

查看文件

@ -26,6 +26,10 @@ public class LogWebhookEntity {
@Column(nullable = false, columnDefinition = "TINYINT(1)") @Column(nullable = false, columnDefinition = "TINYINT(1)")
private boolean enabled = true; private boolean enabled = true;
/** HMAC-SHA256 签名密钥,null 表示不签名 */
@Column(length = 64)
private String secret;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -47,6 +51,9 @@ public class LogWebhookEntity {
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = 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 LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
} }

查看文件

@ -20,12 +20,14 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
@Query("UPDATE LogIssueEntity i SET i.count = i.count + 1, i.lastSeenAt = :now WHERE i.appKey = :appKey AND i.fingerprint = :fingerprint") @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); 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 " + @Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey " +
"AND (:level IS NULL OR i.level = :level) " + "AND (:level IS NULL OR i.level = :level) " +
"AND (:platform IS NULL OR i.platform = :platform) " + "AND (:platform IS NULL OR i.platform = :platform) " +
"AND (:status IS NULL OR i.status = :status) " + "AND (:status IS NULL OR i.status = :status) " +
"AND (:q IS NULL OR LOWER(i.title) LIKE LOWER(CONCAT('%', :q, '%'))) " + "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 (:from IS NULL OR i.lastSeenAt >= :from) " +
"AND (:to IS NULL OR i.lastSeenAt <= :to)") "AND (:to IS NULL OR i.lastSeenAt <= :to)")
Page<LogIssueEntity> findByFilters( Page<LogIssueEntity> findByFilters(
@ -34,6 +36,8 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
@Param("platform") String platform, @Param("platform") String platform,
@Param("status") String status, @Param("status") String status,
@Param("q") String q, @Param("q") String q,
@Param("assignee") String assignee,
@Param("environment") String environment,
@Param("from") LocalDateTime from, @Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, @Param("to") LocalDateTime to,
Pageable pageable); Pageable pageable);

查看文件

@ -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<LogIssueUserEntity, LogIssueUserEntity.IssueUserId> {
boolean existsByIssueIdAndUserId(Long issueId, String userId);
}

查看文件

@ -28,17 +28,20 @@ public class LogService {
private final LogIssueRepository issueRepository; private final LogIssueRepository issueRepository;
private final LogIssueEventRepository issueEventRepository; private final LogIssueEventRepository issueEventRepository;
private final LogIssueUserRepository issueUserRepository;
private final LogEventRepository eventRepository; private final LogEventRepository eventRepository;
private final WebhookService webhookService; private final WebhookService webhookService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public LogService(LogIssueRepository issueRepository, public LogService(LogIssueRepository issueRepository,
LogIssueEventRepository issueEventRepository, LogIssueEventRepository issueEventRepository,
LogIssueUserRepository issueUserRepository,
LogEventRepository eventRepository, LogEventRepository eventRepository,
WebhookService webhookService, WebhookService webhookService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.issueRepository = issueRepository; this.issueRepository = issueRepository;
this.issueEventRepository = issueEventRepository; this.issueEventRepository = issueEventRepository;
this.issueUserRepository = issueUserRepository;
this.eventRepository = eventRepository; this.eventRepository = eventRepository;
this.webhookService = webhookService; this.webhookService = webhookService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@ -81,7 +84,14 @@ public class LogService {
issue = existing.get(); issue = existing.get();
issue.setLastSeenAt(now); issue.setLastSeenAt(now);
issue.setCount(issue.getCount() + 1); 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"); if (!"open".equals(issue.getStatus())) issue.setStatus("open");
issueRepository.save(issue); issueRepository.save(issue);
} else { } else {
@ -175,11 +185,11 @@ public class LogService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<IssueResponse> queryIssues(String appKey, String level, String platform, public Page<IssueResponse> 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) { String from, String to, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
Page<LogIssueEntity> result = issueRepository.findByFilters( Page<LogIssueEntity> 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); return result.map(this::toIssueResponse);
} }
@ -197,9 +207,9 @@ public class LogService {
return new IssueResponse( return new IssueResponse(
issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getId(), issue.getAppKey(), issue.getFingerprint(),
issue.getLevel(), issue.getLevel(),
issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"), issue.getStatus() != null ? issue.getStatus() : "open",
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
issue.getCount(), issue.getAffectedUsers(), issue.isResolved(), issue.getCount(), issue.getAffectedUsers(),
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events
); );
} }
@ -358,24 +368,40 @@ public class LogService {
return new IssueResponse( return new IssueResponse(
issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getId(), issue.getAppKey(), issue.getFingerprint(),
issue.getLevel(), issue.getLevel(),
issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"), issue.getStatus() != null ? issue.getStatus() : "open",
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
issue.getCount(), issue.getAffectedUsers(), issue.isResolved(), issue.getCount(), issue.getAffectedUsers(),
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null
); );
} }
private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) { 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( return new IssueEventResponse(
e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(), e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
e.getExceptionType(), e.getExceptionValue(), exception,
e.getMessage(), e.getStack(), e.getStackSymbolicated(), parseJsonField(e.getBreadcrumbs()),
e.getBreadcrumbs(), e.getTags(), e.getDevice(), parseJsonField(e.getTags()),
parseJsonField(e.getDevice()),
e.getPlatform(), e.getRelease(), e.getEnvironment(), e.getPlatform(), e.getRelease(), e.getEnvironment(),
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt() 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) { private String toJson(Object obj) {
if (obj == null) return null; if (obj == null) return null;
try { try {

查看文件

@ -15,11 +15,14 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
@Service @Service
public class SourcemapService { public class SourcemapService {
private static final Logger log = LoggerFactory.getLogger(SourcemapService.class); 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<String> ALLOWED_EXTENSIONS = Set.of(".map", ".jsbundle.map", ".js.map");
private final LogSourcemapRepository sourcemapRepository; private final LogSourcemapRepository sourcemapRepository;
@ -33,18 +36,33 @@ public class SourcemapService {
@Transactional @Transactional
public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, public SourcemapUploadResponse upload(String appKey, String platform, String appVersion,
String bundleName, MultipartFile file) throws IOException { 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()) { if (bundleName == null || bundleName.isBlank()) {
bundleName = "index"; bundleName = "index";
} }
// Save file to disk if (file.getSize() > MAX_FILE_SIZE) {
Path dir = Paths.get(storageDir, appKey, platform, appVersion); 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); Files.createDirectories(dir);
String filename = bundleName + ".map";
Path filePath = dir.resolve(filename); Path filePath = dir.resolve(filename);
file.transferTo(filePath.toFile()); file.transferTo(filePath.toFile());
// Upsert DB record
var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName(
appKey, platform, appVersion, bundleName); appKey, platform, appVersion, bundleName);
@ -64,7 +82,7 @@ public class SourcemapService {
} }
entity = sourcemapRepository.save(entity); 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( return new SourcemapUploadResponse(
entity.getId(), entity.getAppKey(), entity.getPlatform(), 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) { public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) {
return sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( return sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName(
appKey, platform, appVersion, bundleName) appKey, platform, appVersion, bundleName)

查看文件

@ -9,19 +9,21 @@ import com.xuqm.bugcollect.repository.LogWebhookRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Service @Service
public class WebhookService { public class WebhookService {
@ -45,11 +47,12 @@ public class WebhookService {
.build(); .build();
} }
public void checkAndNotify(LogIssueEntity issue) { public void checkAndNotify(LogIssueEntity issue, boolean isNew) {
List<LogWebhookEntity> webhooks = webhookRepository.findByAppKeyAndEnabledTrue(issue.getAppKey()); List<LogWebhookEntity> webhooks = webhookRepository.findByAppKeyAndEnabledTrue(issue.getAppKey());
String eventType = resolveEventType(issue, isNew);
for (LogWebhookEntity webhook : webhooks) { for (LogWebhookEntity webhook : webhooks) {
if (!matchesEventType(webhook.getEvents(), issue.getType())) { if (!matchesEventType(webhook.getEvents(), eventType, issue.getLevel())) {
continue; continue;
} }
@ -58,44 +61,93 @@ public class WebhookService {
.setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec())); .setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec()));
if (Boolean.TRUE.equals(acquired)) { if (Boolean.TRUE.equals(acquired)) {
sendWebhook(webhook, issue); sendWebhook(webhook, issue, eventType);
} else { } else {
log.debug("Webhook cooldown active: webhookId={}, fingerprint={}", webhook.getId(), issue.getFingerprint()); 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.<level>" matching the current level
*/
private boolean matchesEventType(String eventsJson, String eventType, String level) {
try {
@SuppressWarnings("unchecked")
List<String> 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 { try {
Map<String, Object> body = Map.of( Map<String, Object> body = Map.of(
"event", "issue.new", "event", eventType,
"appKey", issue.getAppKey(), "appKey", issue.getAppKey(),
"issue", Map.of( "issue", Map.of(
"id", issue.getId(),
"fingerprint", issue.getFingerprint(), "fingerprint", issue.getFingerprint(),
"type", issue.getType(), "level", issue.getLevel() != null ? issue.getLevel() : "",
"title", issue.getTitle(), "title", issue.getTitle(),
"status", issue.getStatus() != null ? issue.getStatus() : "open",
"count", issue.getCount(), "count", issue.getCount(),
"affectedUsers", issue.getAffectedUsers(),
"lastSeenAt", issue.getLastSeenAt().toString(), "lastSeenAt", issue.getLastSeenAt().toString(),
"platform", issue.getPlatform() != null ? issue.getPlatform() : "", "platform", issue.getPlatform() != null ? issue.getPlatform() : "",
"appVersion", issue.getRelease() != null ? issue.getRelease() : "" "release", issue.getRelease() != null ? issue.getRelease() : ""
) )
); );
String jsonBody = objectMapper.writeValueAsString(body); String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(webhook.getUrl())) .uri(URI.create(webhook.getUrl()))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.timeout(Duration.ofMillis(5000)) .timeout(Duration.ofMillis(5000));
.build();
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 -> { .thenAccept(response -> {
if (response.statusCode() >= 200 && response.statusCode() < 300) { 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 { } 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 -> { .exceptionally(ex -> {
@ -107,13 +159,13 @@ public class WebhookService {
} }
} }
private boolean matchesEventType(String eventsJson, String eventType) { private String hmacSha256(String secret, String payload) {
try { try {
@SuppressWarnings("unchecked") Mac mac = Mac.getInstance("HmacSHA256");
List<String> events = objectMapper.readValue(eventsJson, List.class); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return events.contains(eventType); return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) { } 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.setAppKey(request.appKey());
entity.setUrl(request.url()); entity.setUrl(request.url());
entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600);
entity.setSecret(request.secret());
entity.setEnabled(true); entity.setEnabled(true);
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
try { try {
@ -148,6 +201,9 @@ public class WebhookService {
entity.setAppKey(request.appKey()); entity.setAppKey(request.appKey());
entity.setUrl(request.url()); entity.setUrl(request.url());
entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600);
if (request.secret() != null) {
entity.setSecret(request.secret().isBlank() ? null : request.secret());
}
try { try {
entity.setEvents(objectMapper.writeValueAsString(request.events())); entity.setEvents(objectMapper.writeValueAsString(request.events()));
} catch (Exception e) { } catch (Exception e) {
@ -165,6 +221,7 @@ public class WebhookService {
private WebhookResponse toResponse(LogWebhookEntity entity) { private WebhookResponse toResponse(LogWebhookEntity entity) {
List<String> events; List<String> events;
try { try {
//noinspection unchecked
events = objectMapper.readValue(entity.getEvents(), List.class); events = objectMapper.readValue(entity.getEvents(), List.class);
} catch (Exception e) { } catch (Exception e) {
events = List.of(); events = List.of();
@ -172,6 +229,7 @@ public class WebhookService {
return new WebhookResponse( return new WebhookResponse(
entity.getId(), entity.getAppKey(), entity.getUrl(), entity.getId(), entity.getAppKey(), entity.getUrl(),
events, entity.getCooldownSec(), entity.isEnabled(), events, entity.getCooldownSec(), entity.isEnabled(),
entity.getSecret() != null && !entity.getSecret().isBlank(),
entity.getCreatedAt() entity.getCreatedAt()
); );
} }

查看文件

@ -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);