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>
这个提交包含在:
父节点
696a50aae8
当前提交
8770f505f7
@ -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<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));
|
||||
}
|
||||
|
||||
@ -106,8 +109,8 @@ public class LogController {
|
||||
@PutMapping("/issues/{id}/assign")
|
||||
public ApiResponse<Void> assignIssue(
|
||||
@PathVariable Long id,
|
||||
@RequestParam String assignee) {
|
||||
logService.assignIssue(id, assignee);
|
||||
@RequestBody java.util.Map<String, String> 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -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<IssueEventResponse> events
|
||||
) {}
|
||||
|
||||
@ -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<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,
|
||||
@JsonProperty("cooldownSec") int cooldownSec,
|
||||
boolean enabled,
|
||||
/** 是否配置了签名密钥(密钥本身不返回,只返回是否已配置) */
|
||||
@JsonProperty("hasSecret") boolean hasSecret,
|
||||
@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)")
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -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")
|
||||
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<LogIssueEntity> findByFilters(
|
||||
@ -34,6 +36,8 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
|
||||
@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);
|
||||
|
||||
@ -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 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<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) {
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<String> 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)
|
||||
|
||||
@ -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<LogWebhookEntity> 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.<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 {
|
||||
Map<String, Object> 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<String> 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<String> 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()
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
正在加载...
在新工单中引用
屏蔽一个用户