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